fix(gateway): guard tailscale serve cleanup ownership

This commit is contained in:
Nimrod Gutman
2026-03-16 10:59:35 +02:00
parent 2a85fa7db1
commit c47caaa198
2 changed files with 201 additions and 1 deletions

View File

@@ -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();
});
});

View File

@@ -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<GatewayTailscaleMode, "off">;
port: number;
pid: number;
claimedAt: string;
};
type TailscaleExposureOwnerStore = {
claim(
mode: Exclude<GatewayTailscaleMode, "off">,
port: number,
): Promise<TailscaleExposureOwnerRecord>;
isCurrentOwner(token: string): Promise<boolean>;
clearIfCurrentOwner(token: string): Promise<void>;
};
function createTailscaleExposureOwnerStore(): TailscaleExposureOwnerStore {
const ownerFilePath = path.join(resolveGatewayLockDir(), "tailscale-exposure-owner.json");
async function readOwner(): Promise<TailscaleExposureOwnerRecord | null> {
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<void>) | 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)}`,