mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 21:21:10 +00:00
fix(gateway): harden tailscale owner fallback
This commit is contained in:
@@ -73,7 +73,7 @@ const modeCases = [
|
||||
];
|
||||
|
||||
describe.each(modeCases)(
|
||||
"startGatewayTailscaleExposure (%s)",
|
||||
"startGatewayTailscaleExposure ($mode)",
|
||||
({ mode, enableMock, disableMock }) => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -195,5 +195,39 @@ describe.each(modeCases)(
|
||||
await cleanupB?.();
|
||||
expect(disableMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to unguarded cleanup when the ownership guard cannot claim", async () => {
|
||||
const logTailscale = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
|
||||
const cleanup = await startGatewayTailscaleExposure({
|
||||
tailscaleMode: mode,
|
||||
resetOnExit: true,
|
||||
port: 18789,
|
||||
logTailscale,
|
||||
ownerStore: {
|
||||
async claim() {
|
||||
throw new Error("lock dir unavailable");
|
||||
},
|
||||
async replaceIfCurrent() {
|
||||
return false;
|
||||
},
|
||||
async runCleanupIfCurrentOwner() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(cleanup).not.toBeNull();
|
||||
expect(enableMock).toHaveBeenCalledTimes(1);
|
||||
expect(logTailscale.warn).toHaveBeenCalledWith(
|
||||
`${mode} ownership guard unavailable: lock dir unavailable`,
|
||||
);
|
||||
|
||||
await cleanup?.();
|
||||
expect(disableMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -79,9 +79,15 @@ function createTailscaleExposureOwnerStore(): TailscaleExposureOwnerStore {
|
||||
if (Date.now() - stat.mtimeMs < lockStaleMs) {
|
||||
return;
|
||||
}
|
||||
// All lock holders only perform short file I/O plus the Tailscale CLI calls,
|
||||
// and those helpers already time out after 15s. If the lock still exists after
|
||||
// the wider stale window, assume the holder is wedged and break it.
|
||||
try {
|
||||
const raw = await fs.readFile(ownerLockPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { pid?: unknown };
|
||||
if (typeof parsed.pid === "number" && isPidAlive(parsed.pid)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Unreadable lock state is treated as stale so a dead holder cannot block recovery.
|
||||
}
|
||||
await fs.unlink(ownerLockPath).catch(() => {});
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
|
||||
@@ -153,15 +159,19 @@ function createTailscaleExposureOwnerStore(): TailscaleExposureOwnerStore {
|
||||
});
|
||||
},
|
||||
async runCleanupIfCurrentOwner(token, cleanup) {
|
||||
return await withOwnerLock(async () => {
|
||||
const shouldRunCleanup = await withOwnerLock(async () => {
|
||||
const current = await readOwner();
|
||||
if (current?.token !== token) {
|
||||
return false;
|
||||
}
|
||||
await cleanup();
|
||||
await deleteOwnerFile();
|
||||
return true;
|
||||
});
|
||||
if (!shouldRunCleanup) {
|
||||
return false;
|
||||
}
|
||||
await cleanup();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -179,7 +189,16 @@ export async function startGatewayTailscaleExposure(params: {
|
||||
}
|
||||
|
||||
const ownerStore = params.ownerStore ?? createTailscaleExposureOwnerStore();
|
||||
const { owner, previousOwner } = await ownerStore.claim(params.tailscaleMode, params.port);
|
||||
let owner: TailscaleExposureOwnerRecord | null = null;
|
||||
let previousOwner: TailscaleExposureOwnerRecord | null = null;
|
||||
|
||||
try {
|
||||
({ owner, previousOwner } = await ownerStore.claim(params.tailscaleMode, params.port));
|
||||
} catch (err) {
|
||||
params.logTailscale.warn(
|
||||
`${params.tailscaleMode} ownership guard unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (params.tailscaleMode === "serve") {
|
||||
@@ -197,13 +216,15 @@ export async function startGatewayTailscaleExposure(params: {
|
||||
params.logTailscale.info(`${params.tailscaleMode} enabled`);
|
||||
}
|
||||
} catch (err) {
|
||||
const nextOwner =
|
||||
previousOwner && isPidAlive(previousOwner.pid)
|
||||
? previousOwner
|
||||
: params.resetOnExit
|
||||
? owner
|
||||
: null;
|
||||
await ownerStore.replaceIfCurrent(owner.token, nextOwner).catch(() => {});
|
||||
if (owner) {
|
||||
const nextOwner =
|
||||
previousOwner && isPidAlive(previousOwner.pid)
|
||||
? previousOwner
|
||||
: params.resetOnExit
|
||||
? owner
|
||||
: null;
|
||||
await ownerStore.replaceIfCurrent(owner.token, nextOwner).catch(() => {});
|
||||
}
|
||||
params.logTailscale.warn(
|
||||
`${params.tailscaleMode} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
@@ -215,15 +236,26 @@ export async function startGatewayTailscaleExposure(params: {
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const cleanedUp = await ownerStore.runCleanupIfCurrentOwner(owner.token, async () => {
|
||||
if (params.tailscaleMode === "serve") {
|
||||
await disableTailscaleServe();
|
||||
} else {
|
||||
await disableTailscaleFunnel();
|
||||
if (owner) {
|
||||
const cleanedUp = await ownerStore.runCleanupIfCurrentOwner(owner.token, async () => {
|
||||
if (params.tailscaleMode === "serve") {
|
||||
await disableTailscaleServe();
|
||||
} else {
|
||||
await disableTailscaleFunnel();
|
||||
}
|
||||
});
|
||||
if (!cleanedUp) {
|
||||
params.logTailscale.info(
|
||||
`${params.tailscaleMode} cleanup skipped: not the current owner`,
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!cleanedUp) {
|
||||
params.logTailscale.info(`${params.tailscaleMode} cleanup skipped: not the current owner`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.tailscaleMode === "serve") {
|
||||
await disableTailscaleServe();
|
||||
} else {
|
||||
await disableTailscaleFunnel();
|
||||
}
|
||||
} catch (err) {
|
||||
params.logTailscale.warn(
|
||||
|
||||
Reference in New Issue
Block a user