From 3c658aec0e55d1f778dee4b0a5f7f7f0fbb0e57d Mon Sep 17 00:00:00 2001 From: devswha Date: Wed, 4 Nov 2026 19:57:56 +0900 Subject: [PATCH 1/3] fix(access): re-read the PC's Tailscale login after a boot instead of caching it unknown A server started at boot read tailscale status before tailscaled had logged in and kept the empty owner for five minutes, so the owner's own devices met the pairing gate. A request stating a Tailscale login now waits for one bounded read until a Running status names the PC's login. --- changelog.d/boot-tailscale-login-pairing.md | 4 +++ server/api.contract.test.ts | 30 +++++++++++++++++++++ server/dispatch.ts | 7 +++-- server/index.ts | 4 +-- server/tailscale.ts | 27 ++++++++++++++++--- 5 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 changelog.d/boot-tailscale-login-pairing.md diff --git a/changelog.d/boot-tailscale-login-pairing.md b/changelog.d/boot-tailscale-login-pairing.md new file mode 100644 index 0000000000..e00e8fbb2e --- /dev/null +++ b/changelog.d/boot-tailscale-login-pairing.md @@ -0,0 +1,4 @@ +### Fixed +- After the PC restarts, your own devices on Tailscale are no longer asked for a pairing code for + up to five minutes: Ovis reads the PC's Tailscale login again once Tailscale has finished + starting, instead of keeping the empty answer it got at boot. diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index 6da9d75a62..f782585e3d 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -4210,6 +4210,36 @@ describe("pairing and identity", () => { } }); + it("regression: lets the owner's login in as soon as Tailscale finishes starting, not five minutes after a boot", async () => { + const state = mkdtempSync(join(tmpdir(), "herdr-boot-identity-")); + const savedOwner = process.env["OVIS_TAILSCALE_OWNER"]; + delete process.env["OVIS_TAILSCALE_OWNER"]; + // a PC that paired a device before: the gate is closed to strangers + writeFileSync(join(state, "devices.json"), JSON.stringify({ gate_closed_at: new Date().toISOString(), devices: [] })); + // at boot the server asks before tailscaled has logged in: no user yet; every later read is Running + const starting = JSON.stringify({ BackendState: "NoState", Self: { DNSName: "" } }); + const running = JSON.stringify({ BackendState: "Running", Self: { DNSName: "pc.example.ts.net.", UserID: 7, TailscaleIPs: ["100.101.102.103"] }, User: { "7": { LoginName: OWNER } } }); + let reads = 0; + const tailnet = new TailnetIdentitySource(async () => reads++ === 0 ? starting : running); + await tailnet.freshIdentity(null); + const booted = createServer({ port: 0, stateDir: state, tailnet }); + const at = async (headers: Record) => ((await (await fetch(`http://127.0.0.1:${booted.port}/api/health?scope=bridge`, { headers })).json()) as { auth: HealthAuth }).auth; + try { + expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" }); + // once the login is known it is kept for the cache's life: later polls read nothing + const settled = reads; + expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" }); + expect(reads).toBe(settled); + // a stranger still pairs + expect(await at(proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" }); + expect(await at(proxied())).toMatchObject({ authenticated: false, reason: "pairing_required" }); + } finally { + booted.stop(); + if (savedOwner === undefined) delete process.env["OVIS_TAILSCALE_OWNER"]; else process.env["OVIS_TAILSCALE_OWNER"] = savedOwner; + rmSync(state, { recursive: true, force: true }); + } + }); + it("takes the login named in OVIS_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["OVIS_TAILSCALE_OWNER"]; diff --git a/server/dispatch.ts b/server/dispatch.ts index e5df948210..c6a91a2701 100644 --- a/server/dispatch.ts +++ b/server/dispatch.ts @@ -71,7 +71,8 @@ export interface RequestServices { readonly devices: DeviceStore; readonly preferences: PreferenceStore; readonly herdrSidebar: HerdrSidebarConfig; - readonly identityOf: (grantPath: boolean, host: string | null) => Promise; + /** `loginPath`: the request states a Tailscale login, so the PC's own login must be known, not merely cached as unknown */ + readonly identityOf: (grantPath: boolean, host: string | null, loginPath?: boolean) => Promise; readonly serveOnly: boolean; readonly portalGuards: () => { tokenSet: boolean; serveOnly: boolean }; readonly shellAgents: ShellAgentLocator; @@ -151,8 +152,10 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi return errorResponse(error); } const requestShape = { loopback, forwarded, funnel, tailscaleLogin, forwardedFor, serveOnly }; + const apiPath = pathname === "/ws" || pathname.startsWith("/api/"); const identity = relayPeer === undefined - ? await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && (pathname === "/ws" || pathname.startsWith("/api/")), requestHost) + ? await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && apiPath, requestHost, + token === "" && pairedDevice === null && loopback && tailscaleLogin !== null && apiPath) : { owner: null, tagged: false, soleLogin: null, dnsName: null, tailnetIp: null }; const portalProven = relayPeer === undefined && (options.portal?.vouches(request.headers.get("x-portal-proof") ?? null) ?? false); const access = decideAccess({ diff --git a/server/index.ts b/server/index.ts index 7dec35c057..bf4322b7f6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -255,9 +255,9 @@ export function createServer( /** a login named here is taken as it is: a tagged node has none of its own to read (OVIS_TAILSCALE_OWNER) */ const namedOwner = options.tailscaleOwner !== undefined ? options.tailscaleOwner : process.env["OVIS_TAILSCALE_OWNER"]?.trim() || undefined; const tailnet = options.tailnet ?? new TailnetIdentitySource(); - const identityOf = async (grantPath: boolean, host: string | null) => { + const identityOf = async (grantPath: boolean, host: string | null, loginPath = false) => { 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(); + return grantPath ? tailnet.freshIdentity(host) : loginPath ? tailnet.loginIdentity() : tailnet.identity(); }; const serveOnly = options.tailscaleServeOnly ?? process.env["OVIS_TAILSCALE_SERVE_ONLY"] === "1"; /** what lets Portal open an address: this server's own values, never a request's */ diff --git a/server/tailscale.ts b/server/tailscale.ts index 92d43057a7..6f30aacf48 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -208,8 +208,11 @@ const cliStatus = async (): Promise => { * that waits for a read, shared with any read already in flight. */ 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 last read's answer and when it landed; null until the first read lands. `ownerKnown`: a + * Running status named this PC's login, or that its node is tagged and has none + */ + private cache: (TailnetIdentity & { at: number; settled: boolean; ownerKnown: boolean }) | null = null; /** the read in flight, so concurrent grants share one bounded read */ private reading: Promise | null = null; @@ -232,18 +235,22 @@ export class TailnetIdentitySource { dnsName: this.cache?.dnsName ?? null, tailnetIp: this.cache?.tailnetIp ?? null, settled: false, + ownerKnown: false, at: Date.now(), }; return; } const sole = readSoleLogin(status); + const owner = parseTailscaleOwner(status); + const tagged = isTaggedNode(status); this.cache = { - owner: parseTailscaleOwner(status), - tagged: isTaggedNode(status), + owner, + tagged, soleLogin: sole.login, dnsName: parseNodeName(status), tailnetIp: parseTailscaleIp(status), settled: sole.settled, + ownerKnown: parseStatus(status)?.BackendState === "Running" && (owner !== null || tagged), at: Date.now(), }; } @@ -276,6 +283,18 @@ export class TailnetIdentitySource { * 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. */ + /** + * `identity` for a request that states a Tailscale login. Until a read has named this PC's own + * login (or found its node tagged), this waits for one read, shared and bounded like + * `freshIdentity`: a server started at boot asks before tailscaled has logged in, and that + * answer must not stand for five minutes against the owner's own devices. + */ + async loginIdentity(): Promise { + if (this.cache !== null && this.cache.ownerKnown) return this.identity(); + await this.refresh(); + return this.identity(); + } + 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; From a61b6f75f7de57552e930f60d6abe1f02907903c Mon Sep 17 00:00:00 2001 From: devswha Date: Wed, 4 Nov 2026 20:05:56 +0900 Subject: [PATCH 2/3] fix(access): keep a known owner through a non-running status, and bound the login re-read Review follow-up: a failed or not-Running status no longer drops a login a Running status named; the login path re-reads at most every two seconds, and only for a loopback request from one tailnet address that is not Funnel. --- server/access.ts | 3 ++- server/api.contract.test.ts | 4 ++++ server/dispatch.ts | 5 +++-- server/tailscale.test.ts | 44 +++++++++++++++++++++++++++++++++++++ server/tailscale.ts | 33 +++++++++++++++++----------- 5 files changed, 73 insertions(+), 16 deletions(-) diff --git a/server/access.ts b/server/access.ts index 6a94b85af9..868391b393 100644 --- a/server/access.ts +++ b/server/access.ts @@ -131,7 +131,8 @@ export function bindScope(hostname: string): BindScope { return (family === 4 ? tailnetIpv4.check(address, "ipv4") : family === 6 && tailnetIpv6.check(address, "ipv6")) ? "tailnet" : "other"; } -const inTailnet = (address: string | null | undefined): boolean => { +/** Is this one address (never a list) in Tailscale's assigned ranges? */ +export const inTailnet = (address: string | null | undefined): boolean => { const family = isIP(address ?? ""); return family === 4 ? tailnetIpv4.check(address!, "ipv4") : family === 6 && tailnetIpv6.check(address!, "ipv6"); }; diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index f782585e3d..8740f69af9 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -4224,6 +4224,9 @@ describe("pairing and identity", () => { await tailnet.freshIdentity(null); const booted = createServer({ port: 0, stateDir: state, tailnet }); const at = async (headers: Record) => ((await (await fetch(`http://127.0.0.1:${booted.port}/api/health?scope=bridge`, { headers })).json()) as { auth: HealthAuth }).auth; + // the owner's phone asks a few seconds after the boot read, once tailscaled is up + const start = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => start + 3_000); try { expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" }); // once the login is known it is kept for the cache's life: later polls read nothing @@ -4234,6 +4237,7 @@ describe("pairing and identity", () => { expect(await at(proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" }); expect(await at(proxied())).toMatchObject({ authenticated: false, reason: "pairing_required" }); } finally { + clock.mockRestore(); booted.stop(); if (savedOwner === undefined) delete process.env["OVIS_TAILSCALE_OWNER"]; else process.env["OVIS_TAILSCALE_OWNER"] = savedOwner; rmSync(state, { recursive: true, force: true }); diff --git a/server/dispatch.ts b/server/dispatch.ts index c6a91a2701..07d50ae393 100644 --- a/server/dispatch.ts +++ b/server/dispatch.ts @@ -8,7 +8,7 @@ import type { OrderSequence } from "./order-sequence.ts"; import type { DevicePairedEvent, HealthAuth, SecuritySummary, SessionSnapshot } from "../shared/protocol.ts"; import { DEFAULT_PORT } from "../shared/protocol.ts"; import { authClient, deviceCookieValue, bearerMatches, handleAuthRequest, isAuthenticated, isSecureRequest, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts"; -import { bindScope, cameThroughProxy, decideAccess, forwardingHeaders, isLoopbackAddress, isServeOwnerRequest, lanExposed, observedIngress } from "./access.ts"; +import { bindScope, cameThroughProxy, decideAccess, forwardingHeaders, inTailnet, isLoopbackAddress, isServeOwnerRequest, lanExposed, observedIngress } from "./access.ts"; import { type DeviceStore, handleDeviceRequest, newStreamKey, sameBrowser, streamCookie, streamKeys, type DeviceMatch } from "./devices.ts"; import { handlePreferencesRequest, type PreferenceStore } from "./preferences.ts"; import { handleSeenRequest, type SeenStore } from "./seen.ts"; @@ -155,7 +155,8 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi const apiPath = pathname === "/ws" || pathname.startsWith("/api/"); const identity = relayPeer === undefined ? await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && apiPath, requestHost, - token === "" && pairedDevice === null && loopback && tailscaleLogin !== null && apiPath) + // only the shape decideAccess could let in by login: from loopback, one tailnet address, not Funnel + token === "" && pairedDevice === null && loopback && tailscaleLogin !== null && !funnel && inTailnet(forwardedFor) && apiPath) : { owner: null, tagged: false, soleLogin: null, dnsName: null, tailnetIp: null }; const portalProven = relayPeer === undefined && (options.portal?.vouches(request.headers.get("x-portal-proof") ?? null) ?? false); const access = decideAccess({ diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index 80834d60ba..13d063fe52 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -154,6 +154,50 @@ describe("the owner's grant read, by Host and by outcome", () => { }); }); +describe("loginIdentity", () => { + /** a source whose reads answer `reads` in turn (the last one from then on), on a clock the test moves */ + function source(reads: (string | null)[]) { + let now = 1_000_000; + let count = 0; + const clock = spyOn(Date, "now").mockImplementation(() => now); + const identities = new TailnetIdentitySource(async () => reads[Math.min(count++, reads.length - 1)] ?? null); + return { identities, reads: () => count, later: (ms: number) => { now += ms; }, restore: () => clock.mockRestore() }; + } + const booting = JSON.stringify({ BackendState: "NoState", Self: { DNSName: "" } }); + + it("reads again after a boot read named no owner, at most every two seconds", async () => { + const s = source([booting, booting, tailnet([42, undefined])]); + try { + expect((await s.identities.loginIdentity()).owner).toBeNull(); + // within two seconds of a read, requests answer from it: nothing can keep the CLI spawning + for (let i = 0; i < 20; i++) await s.identities.loginIdentity(); + expect(s.reads()).toBe(1); + s.later(2_000); + expect((await s.identities.loginIdentity()).owner).toBeNull(); + s.later(2_000); + expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com"); + expect(s.reads()).toBe(3); + // known, it stands for the cache's life + s.later(2_000); + await s.identities.loginIdentity(); + expect(s.reads()).toBe(3); + } finally { s.restore(); } + }); + + it("keeps a known owner through a failed read and a daemon that is not Running", async () => { + const s = source([tailnet([42, undefined]), null, booting]); + try { + expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com"); + for (let i = 0; i < 2; i++) { + s.later(6 * 60_000); + await s.identities.freshIdentity(HOST); + expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com"); + } + expect(s.reads()).toBe(3); + } finally { s.restore(); } + }); +}); + 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 6f30aacf48..3f1737eb3b 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -194,6 +194,8 @@ export interface TailnetIdentity { } const OWNER_TTL_MS = 5 * 60_000; +/** how soon a request stating a login may read the status again while this PC's own login is unknown */ +const LOGIN_RETRY_MS = 2_000; const cliStatus = async (): Promise => { const binary = tailscaleBinary(); @@ -235,14 +237,17 @@ export class TailnetIdentitySource { dnsName: this.cache?.dnsName ?? null, tailnetIp: this.cache?.tailnetIp ?? null, settled: false, - ownerKnown: false, + ownerKnown: this.cache?.ownerKnown ?? false, at: Date.now(), }; return; } const sole = readSoleLogin(status); - const owner = parseTailscaleOwner(status); - const tagged = isTaggedNode(status); + // a daemon that is not Running says nothing about whose PC this is: keep the login a Running one named + const running = parseStatus(status)?.BackendState === "Running"; + const kept = !running && this.cache?.ownerKnown === true ? this.cache : null; + const owner = kept ? kept.owner : parseTailscaleOwner(status); + const tagged = kept ? kept.tagged : isTaggedNode(status); this.cache = { owner, tagged, @@ -250,7 +255,7 @@ export class TailnetIdentitySource { dnsName: parseNodeName(status), tailnetIp: parseTailscaleIp(status), settled: sole.settled, - ownerKnown: parseStatus(status)?.BackendState === "Running" && (owner !== null || tagged), + ownerKnown: kept !== null || (running && (owner !== null || tagged)), at: Date.now(), }; } @@ -276,25 +281,27 @@ export class TailnetIdentitySource { }; } - /** - * `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. - */ /** * `identity` for a request that states a Tailscale login. Until a read has named this PC's own - * login (or found its node tagged), this waits for one read, shared and bounded like + * login (or found its node tagged), this waits for a read, shared and bounded like * `freshIdentity`: a server started at boot asks before tailscaled has logged in, and that - * answer must not stand for five minutes against the owner's own devices. + * answer must not stand for five minutes against the owner's own devices. A new read starts at + * most every `LOGIN_RETRY_MS`, so requests cannot keep the tailscale CLI spawning. */ async loginIdentity(): Promise { if (this.cache !== null && this.cache.ownerKnown) return this.identity(); + if (this.reading === null && this.cache !== null && Date.now() - this.cache.at < LOGIN_RETRY_MS) return this.identity(); await this.refresh(); return this.identity(); } + /** + * `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; From 7efc19d989318fefb97ae5dcd07b04efad9c94da Mon Sep 17 00:00:00 2001 From: devswha Date: Wed, 4 Nov 2026 20:12:25 +0900 Subject: [PATCH 3/3] fix(access): leave a kept owner unconfirmed so a new account is read on the next login request Review follow-up: a not-Running status keeps the last named login but no longer confirms it, so after tailscaled restarts under another account the login path reads the new owner (at most every two seconds). --- server/tailscale.test.ts | 12 ++++++++++++ server/tailscale.ts | 7 ++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/server/tailscale.test.ts b/server/tailscale.test.ts index 13d063fe52..fa177ad9f1 100644 --- a/server/tailscale.test.ts +++ b/server/tailscale.test.ts @@ -196,6 +196,18 @@ describe("loginIdentity", () => { expect(s.reads()).toBe(3); } finally { s.restore(); } }); + + it("reads the new login on a login request after the daemon restarts under another account", async () => { + const s = source([tailnet([42, undefined]), booting, tailnet([43, undefined])]); + try { + expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com"); + s.later(6 * 60_000); + await s.identities.freshIdentity(HOST); + s.later(2_000); + expect((await s.identities.loginIdentity()).owner).toBe("user43@example.com"); + expect(s.reads()).toBe(3); + } finally { s.restore(); } + }); }); describe("parseTailscaleIp", () => { diff --git a/server/tailscale.ts b/server/tailscale.ts index 3f1737eb3b..8e31f6eec6 100644 --- a/server/tailscale.ts +++ b/server/tailscale.ts @@ -243,9 +243,10 @@ export class TailnetIdentitySource { return; } const sole = readSoleLogin(status); - // a daemon that is not Running says nothing about whose PC this is: keep the login a Running one named + // a daemon that is not Running says nothing about whose PC this is: keep the login a Running one + // named, and leave it unconfirmed so a login request reads again (`loginIdentity`) const running = parseStatus(status)?.BackendState === "Running"; - const kept = !running && this.cache?.ownerKnown === true ? this.cache : null; + const kept = !running && this.cache !== null && (this.cache.owner !== null || this.cache.tagged) ? this.cache : null; const owner = kept ? kept.owner : parseTailscaleOwner(status); const tagged = kept ? kept.tagged : isTaggedNode(status); this.cache = { @@ -255,7 +256,7 @@ export class TailnetIdentitySource { dnsName: parseNodeName(status), tailnetIp: parseTailscaleIp(status), settled: sole.settled, - ownerKnown: kept !== null || (running && (owner !== null || tagged)), + ownerKnown: running && (owner !== null || tagged), at: Date.now(), }; }