mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 01:01:13 +00:00
Gateway: refresh websocket auth after secrets reload (#60323)
* Gateway: refresh websocket auth after secrets reload * Gateway: always restore auth reload test globals * chore: add changelog for websocket auth reload --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
@@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
|
||||
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
|
||||
- Slack/app manifest: set `bot_user.always_online` to `true` in the onboarding and example Slack app manifest so the Slack app appears ready to respond.
|
||||
- Gateway/websocket auth: refresh auth on new websocket connects after secrets reload so rotated gateway tokens take effect immediately without requiring a restart. (#60323) Thanks @mappel-nv.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) {
|
||||
canvasHostEnabled: params.canvasHostEnabled,
|
||||
canvasHostServerPort: params.canvasHostServerPort,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
getResolvedAuth: params.getResolvedAuth,
|
||||
rateLimiter: params.rateLimiter,
|
||||
browserRateLimiter: params.browserRateLimiter,
|
||||
gatewayMethods: params.gatewayMethods,
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
} from "../tasks/task-registry.maintenance.js";
|
||||
import { runSetupWizard } from "../wizard/setup.js";
|
||||
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import { resolveGatewayAuth } from "./auth.js";
|
||||
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
|
||||
import { startGatewayConfigReloader } from "./config-reload.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
@@ -633,6 +634,13 @@ export async function startGatewayServer(
|
||||
tailscaleConfig,
|
||||
tailscaleMode,
|
||||
} = runtimeConfig;
|
||||
const getResolvedAuth = () =>
|
||||
resolveGatewayAuth({
|
||||
authConfig: getRuntimeConfig().gateway?.auth,
|
||||
authOverride: opts.auth,
|
||||
env: process.env,
|
||||
tailscaleMode,
|
||||
});
|
||||
let hooksConfig = runtimeConfig.hooksConfig;
|
||||
let hookClientIpConfig = resolveHookClientIpConfig(cfgAtStart);
|
||||
const canvasHostEnabled = runtimeConfig.canvasHostEnabled;
|
||||
@@ -1311,6 +1319,7 @@ export async function startGatewayServer(
|
||||
canvasHostEnabled: Boolean(canvasHost),
|
||||
canvasHostServerPort,
|
||||
resolvedAuth,
|
||||
getResolvedAuth,
|
||||
rateLimiter: authRateLimiter,
|
||||
browserRateLimiter: browserAuthRateLimiter,
|
||||
gatewayMethods,
|
||||
|
||||
@@ -3,7 +3,9 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
|
||||
import { drainSystemEvents } from "../infra/system-events.js";
|
||||
import { openTrackedWs } from "./device-authz.test-helpers.js";
|
||||
import {
|
||||
connectReq,
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
@@ -848,12 +850,13 @@ process.stdin.on("end", () => {
|
||||
|
||||
const previousGatewayAuth = testState.gatewayAuth;
|
||||
const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
testState.gatewayAuth = undefined;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
|
||||
const started = await startServerWithClient();
|
||||
const { server, ws, envSnapshot } = started;
|
||||
let started: Awaited<ReturnType<typeof startServerWithClient>> | undefined;
|
||||
try {
|
||||
testState.gatewayAuth = undefined;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
|
||||
started = await startServerWithClient();
|
||||
const { ws } = started;
|
||||
await connectOk(ws, {
|
||||
token: tokenValue,
|
||||
});
|
||||
@@ -889,9 +892,114 @@ process.stdin.on("end", () => {
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
ws.close();
|
||||
await server.close();
|
||||
started?.envSnapshot.restore();
|
||||
started?.ws.close();
|
||||
await started?.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses refreshed gateway auth for new websocket connects after secrets reload", async () => {
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("OPENCLAW_STATE_DIR is not set");
|
||||
}
|
||||
const resolverScriptPath = path.join(stateDir, "gateway-auth-refresh-resolver.cjs");
|
||||
const tokenPath = path.join(stateDir, "gateway-auth-refresh-token.txt");
|
||||
await fs.mkdir(path.dirname(resolverScriptPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
resolverScriptPath,
|
||||
`const fs = require("node:fs");
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
const tokenPath = process.argv[2];
|
||||
const token = fs.readFileSync(tokenPath, "utf8").trim();
|
||||
let ids = ["gateway/token"];
|
||||
try {
|
||||
const parsed = JSON.parse(input || "{}");
|
||||
if (Array.isArray(parsed.ids) && parsed.ids.length > 0) {
|
||||
ids = parsed.ids.map((entry) => String(entry));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const values = {};
|
||||
for (const id of ids) {
|
||||
values[id] = token;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ protocolVersion: 1, values }) + "\\n");
|
||||
});
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(tokenPath, "token-before-reload\n", "utf8");
|
||||
await writeConfigFile({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "exec", provider: "vault", id: "gateway/token" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
vault: {
|
||||
source: "exec",
|
||||
command: process.execPath,
|
||||
allowSymlinkCommand: true,
|
||||
args: [resolverScriptPath, tokenPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const previousGatewayAuth = testState.gatewayAuth;
|
||||
const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
let started: Awaited<ReturnType<typeof startServerWithClient>> | undefined;
|
||||
try {
|
||||
testState.gatewayAuth = undefined;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
|
||||
started = await startServerWithClient();
|
||||
const { ws, port } = started;
|
||||
await connectOk(ws, { token: "token-before-reload" });
|
||||
|
||||
await fs.writeFile(tokenPath, "token-after-reload\n", "utf8");
|
||||
const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {});
|
||||
expect(reload.ok).toBe(true);
|
||||
|
||||
const staleWs = await openTrackedWs(port);
|
||||
try {
|
||||
const staleConnect = await connectReq(staleWs, {
|
||||
token: "token-before-reload",
|
||||
skipDefaultAuth: true,
|
||||
});
|
||||
expect(staleConnect.ok).toBe(false);
|
||||
expect(staleConnect.error?.message ?? "").toContain("gateway token mismatch");
|
||||
} finally {
|
||||
staleWs.close();
|
||||
}
|
||||
|
||||
const freshWs = await openTrackedWs(port);
|
||||
try {
|
||||
await connectOk(freshWs, {
|
||||
token: "token-after-reload",
|
||||
skipDefaultAuth: true,
|
||||
});
|
||||
} finally {
|
||||
freshWs.close();
|
||||
}
|
||||
} finally {
|
||||
testState.gatewayAuth = previousGatewayAuth;
|
||||
if (previousGatewayTokenEnv === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv;
|
||||
}
|
||||
started?.envSnapshot.restore();
|
||||
started?.ws.close();
|
||||
await started?.server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ export type GatewayWsSharedHandlerParams = {
|
||||
canvasHostEnabled: boolean;
|
||||
canvasHostServerPort?: number;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
getResolvedAuth?: () => ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
/** Browser-origin fallback limiter (loopback is never exempt). */
|
||||
@@ -102,6 +103,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
canvasHostEnabled,
|
||||
canvasHostServerPort,
|
||||
resolvedAuth,
|
||||
getResolvedAuth = () => resolvedAuth,
|
||||
rateLimiter,
|
||||
browserRateLimiter,
|
||||
gatewayMethods,
|
||||
@@ -313,7 +315,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
requestUserAgent,
|
||||
canvasHostUrl,
|
||||
connectNonce,
|
||||
resolvedAuth,
|
||||
getResolvedAuth,
|
||||
rateLimiter,
|
||||
browserRateLimiter,
|
||||
gatewayMethods,
|
||||
|
||||
@@ -159,7 +159,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
requestUserAgent?: string;
|
||||
canvasHostUrl?: string;
|
||||
connectNonce: string;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
getResolvedAuth: () => ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
/** Browser-origin fallback limiter (loopback is never exempt). */
|
||||
@@ -194,7 +194,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
requestUserAgent,
|
||||
canvasHostUrl,
|
||||
connectNonce,
|
||||
resolvedAuth,
|
||||
getResolvedAuth,
|
||||
rateLimiter,
|
||||
browserRateLimiter,
|
||||
gatewayMethods,
|
||||
@@ -433,6 +433,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const isControlUi = isOperatorUiClient(connectParams.client);
|
||||
const isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client);
|
||||
const isWebchat = isWebchatConnect(connectParams);
|
||||
const resolvedAuth = getResolvedAuth();
|
||||
if (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) {
|
||||
const hostHeaderOriginFallbackEnabled =
|
||||
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
|
||||
|
||||
Reference in New Issue
Block a user