diff --git a/src/gateway/server-tailscale.test.ts b/src/gateway/server-tailscale.test.ts new file mode 100644 index 00000000000..fdc764f59af --- /dev/null +++ b/src/gateway/server-tailscale.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tailscaleState = vi.hoisted(() => ({ + enableServe: vi.fn(async (_port: number) => {}), + disableServe: vi.fn(async () => {}), + enableFunnel: vi.fn(async (_port: number) => {}), + disableFunnel: vi.fn(async () => {}), + getHost: vi.fn(async () => "gateway.tailnet.ts.net"), +})); + +vi.mock("../infra/tailscale.js", () => ({ + enableTailscaleServe: (port: number) => tailscaleState.enableServe(port), + disableTailscaleServe: () => tailscaleState.disableServe(), + enableTailscaleFunnel: (port: number) => tailscaleState.enableFunnel(port), + disableTailscaleFunnel: () => tailscaleState.disableFunnel(), + getTailnetHostname: () => tailscaleState.getHost(), +})); + +import { startGatewayTailscaleExposure } from "./server-tailscale.js"; + +function createOwnerStore() { + let currentToken: string | null = null; + let nextId = 0; + + return { + async claim(mode: "serve" | "funnel", port: number) { + const record = { + token: `owner-${++nextId}`, + mode, + port, + pid: nextId, + claimedAt: new Date(0).toISOString(), + }; + currentToken = record.token; + return record; + }, + async isCurrentOwner(token: string) { + return currentToken === token; + }, + async clearIfCurrentOwner(token: string) { + if (currentToken === token) { + currentToken = null; + } + }, + }; +} + +describe("startGatewayTailscaleExposure", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips stale serve cleanup after a newer gateway takes ownership", async () => { + const ownerStore = createOwnerStore(); + const logTailscale = { + info: vi.fn(), + warn: vi.fn(), + }; + + const cleanupA = await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + resetOnExit: true, + port: 18789, + logTailscale, + ownerStore, + }); + const cleanupB = await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + resetOnExit: true, + port: 18789, + logTailscale, + ownerStore, + }); + + await cleanupA?.(); + expect(tailscaleState.disableServe).not.toHaveBeenCalled(); + expect(logTailscale.info).toHaveBeenCalledWith( + "serve cleanup skipped: newer gateway owns Tailscale exposure", + ); + + await cleanupB?.(); + expect(tailscaleState.disableServe).toHaveBeenCalledTimes(1); + }); + + it("clears ownership after a startup failure", async () => { + const ownerStore = createOwnerStore(); + const logTailscale = { + info: vi.fn(), + warn: vi.fn(), + }; + tailscaleState.enableServe.mockRejectedValueOnce(new Error("boom")); + + const cleanup = await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + resetOnExit: true, + port: 18789, + logTailscale, + ownerStore, + }); + + expect(cleanup).not.toBeNull(); + expect(logTailscale.warn).toHaveBeenCalledWith("serve failed: boom"); + + await cleanup?.(); + expect(tailscaleState.disableServe).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-tailscale.ts b/src/gateway/server-tailscale.ts index 3dbdddb566c..74e394748d7 100644 --- a/src/gateway/server-tailscale.ts +++ b/src/gateway/server-tailscale.ts @@ -1,3 +1,7 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveGatewayLockDir } from "../config/paths.js"; import { disableTailscaleFunnel, disableTailscaleServe, @@ -6,17 +10,98 @@ import { getTailnetHostname, } from "../infra/tailscale.js"; +type GatewayTailscaleMode = "off" | "serve" | "funnel"; + +type TailscaleExposureOwnerRecord = { + token: string; + mode: Exclude; + port: number; + pid: number; + claimedAt: string; +}; + +type TailscaleExposureOwnerStore = { + claim( + mode: Exclude, + port: number, + ): Promise; + isCurrentOwner(token: string): Promise; + clearIfCurrentOwner(token: string): Promise; +}; + +function createTailscaleExposureOwnerStore(): TailscaleExposureOwnerStore { + const ownerFilePath = path.join(resolveGatewayLockDir(), "tailscale-exposure-owner.json"); + + async function readOwner(): Promise { + try { + const raw = await fs.readFile(ownerFilePath, "utf8"); + const parsed = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + typeof parsed.token === "string" && + typeof parsed.mode === "string" && + typeof parsed.port === "number" && + typeof parsed.pid === "number" && + typeof parsed.claimedAt === "string" + ) { + return parsed as TailscaleExposureOwnerRecord; + } + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + // Ignore malformed or unreadable ownership state and continue. + } + } + return null; + } + + return { + async claim(mode, port) { + const record: TailscaleExposureOwnerRecord = { + token: randomUUID(), + mode, + port, + pid: process.pid, + claimedAt: new Date().toISOString(), + }; + await fs.mkdir(path.dirname(ownerFilePath), { recursive: true }); + await fs.writeFile(ownerFilePath, JSON.stringify(record), "utf8"); + return record; + }, + async isCurrentOwner(token) { + const current = await readOwner(); + return current?.token === token; + }, + async clearIfCurrentOwner(token) { + if (!(await this.isCurrentOwner(token))) { + return; + } + try { + await fs.unlink(ownerFilePath); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + throw err; + } + } + }, + }; +} + export async function startGatewayTailscaleExposure(params: { - tailscaleMode: "off" | "serve" | "funnel"; + tailscaleMode: GatewayTailscaleMode; resetOnExit?: boolean; port: number; controlUiBasePath?: string; logTailscale: { info: (msg: string) => void; warn: (msg: string) => void }; + ownerStore?: TailscaleExposureOwnerStore; }): Promise<(() => Promise) | null> { if (params.tailscaleMode === "off") { return null; } + const ownerStore = params.ownerStore ?? createTailscaleExposureOwnerStore(); + const owner = await ownerStore.claim(params.tailscaleMode, params.port); + try { if (params.tailscaleMode === "serve") { await enableTailscaleServe(params.port); @@ -33,6 +118,7 @@ export async function startGatewayTailscaleExposure(params: { params.logTailscale.info(`${params.tailscaleMode} enabled`); } } catch (err) { + await ownerStore.clearIfCurrentOwner(owner.token).catch(() => {}); params.logTailscale.warn( `${params.tailscaleMode} failed: ${err instanceof Error ? err.message : String(err)}`, ); @@ -44,11 +130,18 @@ export async function startGatewayTailscaleExposure(params: { return async () => { try { + if (!(await ownerStore.isCurrentOwner(owner.token))) { + params.logTailscale.info( + `${params.tailscaleMode} cleanup skipped: newer gateway owns Tailscale exposure`, + ); + return; + } if (params.tailscaleMode === "serve") { await disableTailscaleServe(); } else { await disableTailscaleFunnel(); } + await ownerStore.clearIfCurrentOwner(owner.token); } catch (err) { params.logTailscale.warn( `${params.tailscaleMode} cleanup failed: ${err instanceof Error ? err.message : String(err)}`,