mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 00:11:31 +00:00
fix(gateway): guard tailscale serve cleanup ownership
This commit is contained in:
107
src/gateway/server-tailscale.test.ts
Normal file
107
src/gateway/server-tailscale.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
|
||||
Reference in New Issue
Block a user