mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 21:50:22 +00:00
Gateway: disconnect shared-auth sessions on auth change
This commit is contained in:
committed by
Peter Steinberger
parent
e0580e6863
commit
54b269b2cb
@@ -47,7 +47,7 @@ import {
|
||||
} from "../protocol/index.js";
|
||||
import { resolveBaseHashParam } from "./base-hash.js";
|
||||
import { parseRestartRequestParams } from "./restart-request.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
const MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE = 3;
|
||||
@@ -220,6 +220,28 @@ function parseValidateConfigFromRawOrRespond(
|
||||
return { config: validated.config, schema };
|
||||
}
|
||||
|
||||
function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): boolean {
|
||||
const prevAuth = prev.gateway?.auth;
|
||||
const nextAuth = next.gateway?.auth;
|
||||
return (
|
||||
prevAuth?.mode !== nextAuth?.mode ||
|
||||
prevAuth?.token !== nextAuth?.token ||
|
||||
prevAuth?.password !== nextAuth?.password
|
||||
);
|
||||
}
|
||||
|
||||
function queueSharedGatewayAuthDisconnect(
|
||||
shouldDisconnect: boolean,
|
||||
context?: GatewayRequestContext,
|
||||
): void {
|
||||
if (!shouldDisconnect) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
context?.disconnectClientsUsingSharedGatewayAuth?.();
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeConfigValidationIssues(issues: ReadonlyArray<ConfigValidationIssue>): string {
|
||||
const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE);
|
||||
const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true })
|
||||
@@ -371,7 +393,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"config.set": async ({ params, respond }) => {
|
||||
"config.set": async ({ params, respond, context }) => {
|
||||
if (!assertValidParams(params, validateConfigSetParams, "config.set", respond)) {
|
||||
return;
|
||||
}
|
||||
@@ -386,6 +408,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
if (!(await ensureResolvableSecretRefsOrRespond({ config: parsed.config, respond }))) {
|
||||
return;
|
||||
}
|
||||
const disconnectSharedAuthClients = didSharedGatewayAuthChange(snapshot.config, parsed.config);
|
||||
await writeConfigFile(parsed.config, writeOptions);
|
||||
respond(
|
||||
true,
|
||||
@@ -396,6 +419,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
|
||||
},
|
||||
"config.patch": async ({ params, respond, client, context }) => {
|
||||
if (!assertValidParams(params, validateConfigPatchParams, "config.patch", respond)) {
|
||||
@@ -501,6 +525,10 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
context?.logGateway?.info(
|
||||
`config.patch write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.patch`,
|
||||
);
|
||||
const disconnectSharedAuthClients = didSharedGatewayAuthChange(
|
||||
snapshot.config,
|
||||
validated.config,
|
||||
);
|
||||
await writeConfigFile(validated.config, writeOptions);
|
||||
|
||||
const { sessionKey, note, restartDelayMs, deliveryContext, threadId } =
|
||||
@@ -543,6 +571,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
|
||||
},
|
||||
"config.apply": async ({ params, respond, client, context }) => {
|
||||
if (!assertValidParams(params, validateConfigApplyParams, "config.apply", respond)) {
|
||||
@@ -564,6 +593,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
context?.logGateway?.info(
|
||||
`config.apply write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.apply`,
|
||||
);
|
||||
const disconnectSharedAuthClients = didSharedGatewayAuthChange(snapshot.config, parsed.config);
|
||||
await writeConfigFile(parsed.config, writeOptions);
|
||||
|
||||
const { sessionKey, note, restartDelayMs, deliveryContext, threadId } =
|
||||
@@ -606,6 +636,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
|
||||
},
|
||||
"config.openFile": async ({ params, respond, context }) => {
|
||||
if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) {
|
||||
|
||||
@@ -58,6 +58,7 @@ export type GatewayRequestContext = {
|
||||
hasConnectedMobileNode: () => boolean;
|
||||
hasExecApprovalClients?: (excludeConnId?: string) => boolean;
|
||||
disconnectClientsForDevice?: (deviceId: string, opts?: { role?: string }) => void;
|
||||
disconnectClientsUsingSharedGatewayAuth?: () => void;
|
||||
nodeRegistry: NodeRegistry;
|
||||
agentRunSeq: Map<string, number>;
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
|
||||
@@ -1277,6 +1277,18 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
},
|
||||
disconnectClientsUsingSharedGatewayAuth: () => {
|
||||
for (const gatewayClient of clients) {
|
||||
if (!gatewayClient.usesSharedGatewayAuth) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
gatewayClient.socket.close(4001, "gateway auth changed");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeRegistry,
|
||||
agentRunSeq,
|
||||
chatAbortControllers,
|
||||
|
||||
79
src/gateway/server.shared-auth-rotation.test.ts
Normal file
79
src/gateway/server.shared-auth-rotation.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startGatewayServer,
|
||||
testState,
|
||||
trackConnectChallengeNonce,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const ORIGINAL_GATEWAY_AUTH = testState.gatewayAuth;
|
||||
const OLD_TOKEN = "shared-token-old";
|
||||
const NEW_TOKEN = "shared-token-new";
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await getFreePort();
|
||||
testState.gatewayAuth = { mode: "token", token: OLD_TOKEN };
|
||||
server = await startGatewayServer(port, { controlUiEnabled: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
testState.gatewayAuth = ORIGINAL_GATEWAY_AUTH;
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function openAuthenticatedWs(token: string): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
await connectOk(ws, { token });
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
|
||||
return await new Promise((resolve) => {
|
||||
ws.once("close", (code, reason) => {
|
||||
resolve({ code, reason: reason.toString() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway shared auth rotation", () => {
|
||||
it("disconnects existing shared-token websocket sessions after config rotation", async () => {
|
||||
const ws = await openAuthenticatedWs(OLD_TOKEN);
|
||||
try {
|
||||
const current = await rpcReq<{ hash?: string }>(ws, "config.get", {});
|
||||
expect(current.ok).toBe(true);
|
||||
expect(typeof current.payload?.hash).toBe("string");
|
||||
|
||||
const closed = waitForClose(ws);
|
||||
const res = await rpcReq<{ restart?: { scheduled?: boolean } }>(ws, "config.patch", {
|
||||
baseHash: current.payload?.hash,
|
||||
raw: JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: NEW_TOKEN,
|
||||
},
|
||||
},
|
||||
}),
|
||||
restartDelayMs: 60_000,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
await expect(closed).resolves.toMatchObject({
|
||||
code: 4001,
|
||||
reason: "gateway auth changed",
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1103,6 +1103,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
socket,
|
||||
connect: connectParams,
|
||||
connId,
|
||||
usesSharedGatewayAuth: authMethod === "token" || authMethod === "password",
|
||||
presenceKey,
|
||||
clientIp: reportedClientIp,
|
||||
canvasHostUrl,
|
||||
|
||||
@@ -5,6 +5,7 @@ export type GatewayWsClient = {
|
||||
socket: WebSocket;
|
||||
connect: ConnectParams;
|
||||
connId: string;
|
||||
usesSharedGatewayAuth?: boolean;
|
||||
presenceKey?: string;
|
||||
clientIp?: string;
|
||||
canvasHostUrl?: string;
|
||||
|
||||
Reference in New Issue
Block a user