diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd130a45e..0de6eac53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. - Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 5f551a2de5..e800690fd8 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1976,7 +1976,7 @@ See [Plugins](/tools/plugin). port: 18789, bind: "loopback", auth: { - mode: "token", // token | password | trusted-proxy + mode: "token", // none | token | password | trusted-proxy token: "your-token", // password: "your-password", // or OPENCLAW_GATEWAY_PASSWORD // trustedProxy: { userHeader: "x-forwarded-user" }, // for mode=trusted-proxy; see /gateway/trusted-proxy-auth @@ -2022,6 +2022,7 @@ See [Plugins](/tools/plugin). - `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`. - `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`. - **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default. +- `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts. - `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). Defaults to `true` when `tailscale.mode = "serve"`. - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. diff --git a/docs/help/faq.md b/docs/help/faq.md index 9dbfbca7ce..053e7bbb4a 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1385,9 +1385,9 @@ Notes: ### Why do I need a token on localhost now -The wizard generates a gateway token by default (even on loopback) so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway. Paste the token into the Control UI settings (or your client config) to connect. +OpenClaw enforces token auth by default, including loopback. If no token is configured, gateway startup auto-generates one and saves it to `gateway.auth.token`, so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway. -If you **really** want open loopback, remove `gateway.auth` from your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`. +If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`. ### Do I have to restart after changing config diff --git a/src/browser/control-auth.auto-token.test.ts b/src/browser/control-auth.auto-token.test.ts index 0c2ffee811..41107b2cbf 100644 --- a/src/browser/control-auth.auto-token.test.ts +++ b/src/browser/control-auth.auto-token.test.ts @@ -98,6 +98,25 @@ describe("ensureBrowserControlAuth", () => { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); + it("respects explicit none mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + browser: { + enabled: true, + }, + }; + + const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(result).toEqual({ auth: {} }); + expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + it("reuses auth from latest config snapshot", async () => { const cfg: OpenClawConfig = { browser: { diff --git a/src/browser/control-auth.test.ts b/src/browser/control-auth.test.ts index 817503fb38..b88816adb5 100644 --- a/src/browser/control-auth.test.ts +++ b/src/browser/control-auth.test.ts @@ -49,6 +49,27 @@ describe("ensureBrowserControlAuth", () => { }); }); + describe("none mode", () => { + it("should not auto-generate token when auth mode is none", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + }; + + const result = await ensureBrowserControlAuth({ + cfg, + env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.auth.token).toBeUndefined(); + expect(result.auth.password).toBeUndefined(); + }); + }); + describe("token mode", () => { it("should return existing token if configured", async () => { const cfg: OpenClawConfig = { diff --git a/src/browser/control-auth.ts b/src/browser/control-auth.ts index 0fa25ab86f..abbafc8d02 100644 --- a/src/browser/control-auth.ts +++ b/src/browser/control-auth.ts @@ -1,7 +1,7 @@ -import crypto from "node:crypto"; import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; +import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; export type BrowserControlAuth = { token?: string; @@ -58,6 +58,10 @@ export async function ensureBrowserControlAuth(params: { return { auth }; } + if (params.cfg.gateway?.auth?.mode === "none") { + return { auth }; + } + if (params.cfg.gateway?.auth?.mode === "trusted-proxy") { return { auth }; } @@ -71,25 +75,21 @@ export async function ensureBrowserControlAuth(params: { if (latestCfg.gateway?.auth?.mode === "password") { return { auth: latestAuth }; } + if (latestCfg.gateway?.auth?.mode === "none") { + return { auth: latestAuth }; + } if (latestCfg.gateway?.auth?.mode === "trusted-proxy") { return { auth: latestAuth }; } - const generatedToken = crypto.randomBytes(24).toString("hex"); - const nextCfg: OpenClawConfig = { - ...latestCfg, - gateway: { - ...latestCfg.gateway, - auth: { - ...latestCfg.gateway?.auth, - mode: "token", - token: generatedToken, - }, - }, - }; - await writeConfigFile(nextCfg); + const ensured = await ensureGatewayStartupAuth({ + cfg: latestCfg, + env, + persist: true, + }); + const ensuredAuth = resolveBrowserControlAuth(ensured.cfg, env); return { - auth: { token: generatedToken }, - generatedToken, + auth: ensuredAuth, + generatedToken: ensured.generatedToken, }; } diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 93deae6b5d..132962609e 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -132,4 +132,22 @@ describe("gateway run option collisions", () => { }), ); }); + + it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => { + const { addGatewayRunCommand } = await import("./run.js"); + const program = new Command(); + const gateway = addGatewayRunCommand(program.command("gateway")); + addGatewayRunCommand(gateway.command("run")); + + await program.parseAsync(["gateway", "run", "--allow-unconfigured"], { + from: "user", + }); + + expect(startGatewayServer).toHaveBeenCalledWith( + 18789, + expect.objectContaining({ + bind: "loopback", + }), + ); + }); }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 7e99e30d36..74c8394b5e 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; -import type { GatewayAuthMode } from "../../config/config.js"; +import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js"; import { CONFIG_PATH, loadConfig, @@ -193,7 +193,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { return; } const tailscaleRaw = toOptionString(opts.tailscale); - const tailscaleMode = + const tailscaleMode: GatewayTailscaleMode | null = tailscaleRaw === "off" || tailscaleRaw === "serve" || tailscaleRaw === "funnel" ? tailscaleRaw : null; @@ -239,14 +239,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } const miskeys = extractGatewayMiskeys(snapshot?.parsed); - const authConfig = { - ...cfg.gateway?.auth, - ...(authMode ? { mode: authMode } : {}), - ...(passwordRaw ? { password: passwordRaw } : {}), - ...(tokenRaw ? { token: tokenRaw } : {}), - }; + const authOverride = + authMode || passwordRaw || tokenRaw || authModeRaw + ? { + ...(authMode ? { mode: authMode } : {}), + ...(tokenRaw ? { token: tokenRaw } : {}), + ...(passwordRaw ? { password: passwordRaw } : {}), + } + : undefined; const resolvedAuth = resolveGatewayAuth({ - authConfig, + authConfig: cfg.gateway?.auth, + authOverride, env: process.env, tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off", }); @@ -257,6 +260,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0; const hasSharedSecret = (resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword); + const canBootstrapToken = resolvedAuthMode === "token" && !hasToken; const authHints: string[] = []; if (miskeys.hasGatewayToken) { authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.'); @@ -266,19 +270,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) { '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', ); } - if (resolvedAuthMode === "token" && !hasToken && !resolvedAuth.allowTailscale) { - defaultRuntime.error( - [ - "Gateway auth is set to token, but no token is configured.", - "Set gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN), or pass --token.", - ...authHints, - ] - .filter(Boolean) - .join("\n"), - ); - defaultRuntime.exit(1); - return; - } if (resolvedAuthMode === "password" && !hasPassword) { defaultRuntime.error( [ @@ -292,7 +283,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } - if (bind !== "loopback" && !hasSharedSecret && resolvedAuthMode !== "trusted-proxy") { + if (resolvedAuthMode === "none") { + gatewayLog.warn( + "Gateway auth mode=none explicitly configured; all gateway connections are unauthenticated.", + ); + } + if ( + bind !== "loopback" && + !hasSharedSecret && + !canBootstrapToken && + resolvedAuthMode !== "trusted-proxy" + ) { defaultRuntime.error( [ `Refusing to bind gateway to ${bind} without auth.`, @@ -305,6 +306,13 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } + const tailscaleOverride = + tailscaleMode || opts.tailscaleResetOnExit + ? { + ...(tailscaleMode ? { mode: tailscaleMode } : {}), + ...(opts.tailscaleResetOnExit ? { resetOnExit: true } : {}), + } + : undefined; try { await runGatewayLoop({ @@ -312,21 +320,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) { start: async () => await startGatewayServer(port, { bind, - auth: - authMode || passwordRaw || tokenRaw || authModeRaw - ? { - mode: authMode ?? undefined, - token: tokenRaw, - password: passwordRaw, - } - : undefined, - tailscale: - tailscaleMode || opts.tailscaleResetOnExit - ? { - mode: tailscaleMode ?? undefined, - resetOnExit: Boolean(opts.tailscaleResetOnExit), - } - : undefined, + auth: authOverride, + tailscale: tailscaleOverride, }), }); } catch (err) { diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 141f5a0b3b..5015286e88 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -76,7 +76,7 @@ export type GatewayControlUiConfig = { dangerouslyDisableDeviceAuth?: boolean; }; -export type GatewayAuthMode = "token" | "password" | "trusted-proxy"; +export type GatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; /** * Configuration for trusted reverse proxy authentication. @@ -104,7 +104,7 @@ export type GatewayTrustedProxyConfig = { }; export type GatewayAuthConfig = { - /** Authentication mode for Gateway connections. Defaults to token when set. */ + /** Authentication mode for Gateway connections. Defaults to token when unset. */ mode?: GatewayAuthMode; /** Shared token for token mode (stored locally for CLI auth). */ token?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ca1a781fa3..b47418302c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -410,7 +410,12 @@ export const OpenClawSchema = z auth: z .object({ mode: z - .union([z.literal("token"), z.literal("password"), z.literal("trusted-proxy")]) + .union([ + z.literal("none"), + z.literal("token"), + z.literal("password"), + z.literal("trusted-proxy"), + ]) .optional(), token: z.string().optional().register(sensitive), password: z.string().optional().register(sensitive), diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index f0982ab253..acc761ea88 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -34,6 +34,7 @@ describe("gateway auth", () => { }), ).toMatchObject({ mode: "password", + modeSource: "password", token: "env-token", password: "env-password", }); @@ -49,12 +50,42 @@ describe("gateway auth", () => { } as NodeJS.ProcessEnv, }), ).toMatchObject({ - mode: "none", + mode: "token", + modeSource: "default", token: undefined, password: undefined, }); }); + it("resolves explicit auth mode none from config", () => { + expect( + resolveGatewayAuth({ + authConfig: { mode: "none" }, + env: {} as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + mode: "none", + modeSource: "config", + token: undefined, + password: undefined, + }); + }); + + it("marks mode source as override when runtime mode override is provided", () => { + expect( + resolveGatewayAuth({ + authConfig: { mode: "password", password: "config-password" }, + authOverride: { mode: "token" }, + env: {} as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + mode: "token", + modeSource: "override", + token: undefined, + password: "config-password", + }); + }); + it("does not throw when req is missing socket", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: false }, @@ -90,6 +121,34 @@ describe("gateway auth", () => { expect(res.reason).toBe("token_missing_config"); }); + it("allows explicit auth mode none", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: false }, + connectAuth: null, + }); + expect(res.ok).toBe(true); + expect(res.method).toBe("none"); + }); + + it("keeps none mode authoritative even when token is present", async () => { + const auth = resolveGatewayAuth({ + authConfig: { mode: "none", token: "configured-token" }, + env: {} as NodeJS.ProcessEnv, + }); + expect(auth).toMatchObject({ + mode: "none", + modeSource: "config", + token: "configured-token", + }); + + const res = await authorizeGatewayConnect({ + auth, + connectAuth: null, + }); + expect(res.ok).toBe(true); + expect(res.method).toBe("none"); + }); + it("reports missing and mismatched password reasons", async () => { const missing = await authorizeGatewayConnect({ auth: { mode: "password", password: "secret", allowTailscale: false }, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 3212885c6a..f3a7f2d905 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -20,9 +20,16 @@ import { } from "./net.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; +export type ResolvedGatewayAuthModeSource = + | "override" + | "config" + | "password" + | "token" + | "default"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; + modeSource?: ResolvedGatewayAuthModeSource; token?: string; password?: string; allowTailscale: boolean; @@ -178,24 +185,55 @@ async function resolveVerifiedTailscaleUser(params: { export function resolveGatewayAuth(params: { authConfig?: GatewayAuthConfig | null; + authOverride?: GatewayAuthConfig | null; env?: NodeJS.ProcessEnv; tailscaleMode?: GatewayTailscaleMode; }): ResolvedGatewayAuth { - const authConfig = params.authConfig ?? {}; + const baseAuthConfig = params.authConfig ?? {}; + const authOverride = params.authOverride ?? undefined; + const authConfig: GatewayAuthConfig = { ...baseAuthConfig }; + if (authOverride) { + if (authOverride.mode !== undefined) { + authConfig.mode = authOverride.mode; + } + if (authOverride.token !== undefined) { + authConfig.token = authOverride.token; + } + if (authOverride.password !== undefined) { + authConfig.password = authOverride.password; + } + if (authOverride.allowTailscale !== undefined) { + authConfig.allowTailscale = authOverride.allowTailscale; + } + if (authOverride.rateLimit !== undefined) { + authConfig.rateLimit = authOverride.rateLimit; + } + if (authOverride.trustedProxy !== undefined) { + authConfig.trustedProxy = authOverride.trustedProxy; + } + } const env = params.env ?? process.env; const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined; const trustedProxy = authConfig.trustedProxy; let mode: ResolvedGatewayAuth["mode"]; - if (authConfig.mode) { + let modeSource: ResolvedGatewayAuth["modeSource"]; + if (authOverride?.mode !== undefined) { + mode = authOverride.mode; + modeSource = "override"; + } else if (authConfig.mode) { mode = authConfig.mode; + modeSource = "config"; } else if (password) { mode = "password"; + modeSource = "password"; } else if (token) { mode = "token"; + modeSource = "token"; } else { - mode = "none"; + mode = "token"; + modeSource = "default"; } const allowTailscale = @@ -204,6 +242,7 @@ export function resolveGatewayAuth(params: { return { mode, + modeSource, token, password, allowTailscale, @@ -317,6 +356,10 @@ export async function authorizeGatewayConnect(params: { return { ok: false, reason: result.reason }; } + if (auth.mode === "none") { + return { ok: true, method: "none" }; + } + const limiter = params.rateLimiter; const ip = params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress; diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 2f85796886..119c9cad9a 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -115,5 +115,42 @@ describe("resolveGatewayRuntimeConfig", () => { expect(result.authMode).toBe("token"); expect(result.bindHost).toBe("0.0.0.0"); }); + + it("should allow loopback binding with explicit none mode", async () => { + const cfg = { + gateway: { + bind: "loopback" as const, + auth: { + mode: "none" as const, + }, + }, + }; + + const result = await resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }); + + expect(result.authMode).toBe("none"); + expect(result.bindHost).toBe("127.0.0.1"); + }); + + it("should reject lan binding with explicit none mode", async () => { + const cfg = { + gateway: { + bind: "lan" as const, + auth: { + mode: "none" as const, + }, + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow("refusing to bind gateway"); + }); }); }); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 8763341f00..614b8c0b54 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -12,6 +12,7 @@ import { import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; import { isLoopbackHost, resolveGatewayBindHost } from "./net.js"; +import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { bindHost: string; @@ -57,21 +58,13 @@ export async function resolveGatewayRuntimeConfig(params: { typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0 ? controlUiRootRaw.trim() : undefined; - const authBase = params.cfg.gateway?.auth ?? {}; - const authOverrides = params.auth ?? {}; - const authConfig = { - ...authBase, - ...authOverrides, - }; const tailscaleBase = params.cfg.gateway?.tailscale ?? {}; const tailscaleOverrides = params.tailscale ?? {}; - const tailscaleConfig = { - ...tailscaleBase, - ...tailscaleOverrides, - }; + const tailscaleConfig = mergeGatewayTailscaleConfig(tailscaleBase, tailscaleOverrides); const tailscaleMode = tailscaleConfig.mode ?? "off"; const resolvedAuth = resolveGatewayAuth({ - authConfig, + authConfig: params.cfg.gateway?.auth, + authOverride: params.auth, env: process.env, tailscaleMode, }); diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 8a6f050575..bbfbbf29c3 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -616,6 +616,36 @@ describe("gateway server auth/connect", () => { }); }); + describe("explicit none auth", () => { + let server: Awaited>; + let port: number; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = { mode: "none" }; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + }); + + test("allows loopback connect without shared secret when mode is none", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { skipDefaultAuth: true }); + expect(res.ok).toBe(true); + ws.close(); + }); + }); + describe("tailscale auth", () => { let server: Awaited>; let port: number; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 2cfa561e99..a4add4d948 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -84,6 +84,7 @@ import { refreshGatewayHealthSnapshot, } from "./server/health-state.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; +import { ensureGatewayStartupAuth } from "./startup-auth.js"; export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js"; @@ -227,7 +228,26 @@ export async function startGatewayServer( } } - const cfgAtStart = loadConfig(); + let cfgAtStart = loadConfig(); + const authBootstrap = await ensureGatewayStartupAuth({ + cfg: cfgAtStart, + env: process.env, + authOverride: opts.auth, + tailscaleOverride: opts.tailscale, + persist: true, + }); + cfgAtStart = authBootstrap.cfg; + if (authBootstrap.generatedToken) { + if (authBootstrap.persistedGeneratedToken) { + log.info( + "Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).", + ); + } else { + log.warn( + "Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token `.", + ); + } + } const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); if (diagnosticsEnabled) { startDiagnosticHeartbeat(); diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts new file mode 100644 index 0000000000..4cd1094655 --- /dev/null +++ b/src/gateway/startup-auth.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const mocks = vi.hoisted(() => ({ + writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeConfigFile: mocks.writeConfigFile, + }; +}); + +import { ensureGatewayStartupAuth } from "./startup-auth.js"; + +describe("ensureGatewayStartupAuth", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mocks.writeConfigFile.mockReset(); + }); + + it("generates and persists a token when startup auth is missing", async () => { + const result = await ensureGatewayStartupAuth({ + cfg: {}, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(true); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1); + const persisted = mocks.writeConfigFile.mock.calls[0]?.[0]; + expect(persisted?.gateway?.auth?.mode).toBe("token"); + expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken); + }); + + it("does not generate when token already exists", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "configured-token", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe("configured-token"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not generate in password mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("password"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not generate in trusted-proxy mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { userHeader: "x-forwarded-user" }, + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("trusted-proxy"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not generate in explicit none mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("none"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("treats undefined token override as no override", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "from-config", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token", token: undefined }, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe("from-config"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps generated token ephemeral when runtime override flips explicit non-token mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token" }, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps generated token ephemeral when runtime override flips explicit none mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token" }, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps generated token ephemeral when runtime override flips implicit password mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + password: "configured-password", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token" }, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts new file mode 100644 index 0000000000..ec1ef7dd56 --- /dev/null +++ b/src/gateway/startup-auth.ts @@ -0,0 +1,147 @@ +import crypto from "node:crypto"; +import type { + GatewayAuthConfig, + GatewayTailscaleConfig, + OpenClawConfig, +} from "../config/config.js"; +import { writeConfigFile } from "../config/config.js"; +import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js"; + +export function mergeGatewayAuthConfig( + base?: GatewayAuthConfig, + override?: GatewayAuthConfig, +): GatewayAuthConfig { + const merged: GatewayAuthConfig = { ...base }; + if (!override) { + return merged; + } + if (override.mode !== undefined) { + merged.mode = override.mode; + } + if (override.token !== undefined) { + merged.token = override.token; + } + if (override.password !== undefined) { + merged.password = override.password; + } + if (override.allowTailscale !== undefined) { + merged.allowTailscale = override.allowTailscale; + } + if (override.rateLimit !== undefined) { + merged.rateLimit = override.rateLimit; + } + if (override.trustedProxy !== undefined) { + merged.trustedProxy = override.trustedProxy; + } + return merged; +} + +export function mergeGatewayTailscaleConfig( + base?: GatewayTailscaleConfig, + override?: GatewayTailscaleConfig, +): GatewayTailscaleConfig { + const merged: GatewayTailscaleConfig = { ...base }; + if (!override) { + return merged; + } + if (override.mode !== undefined) { + merged.mode = override.mode; + } + if (override.resetOnExit !== undefined) { + merged.resetOnExit = override.resetOnExit; + } + return merged; +} + +function resolveGatewayAuthFromConfig(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + authOverride?: GatewayAuthConfig; + tailscaleOverride?: GatewayTailscaleConfig; +}) { + const tailscaleConfig = mergeGatewayTailscaleConfig( + params.cfg.gateway?.tailscale, + params.tailscaleOverride, + ); + return resolveGatewayAuth({ + authConfig: params.cfg.gateway?.auth, + authOverride: params.authOverride, + env: params.env, + tailscaleMode: tailscaleConfig.mode ?? "off", + }); +} + +function shouldPersistGeneratedToken(params: { + persistRequested: boolean; + resolvedAuth: ResolvedGatewayAuth; +}): boolean { + if (!params.persistRequested) { + return false; + } + + // Keep CLI/runtime mode overrides ephemeral: startup should not silently + // mutate durable auth policy when mode was chosen by an override flag. + if (params.resolvedAuth.modeSource === "override") { + return false; + } + + return true; +} + +export async function ensureGatewayStartupAuth(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + authOverride?: GatewayAuthConfig; + tailscaleOverride?: GatewayTailscaleConfig; + persist?: boolean; +}): Promise<{ + cfg: OpenClawConfig; + auth: ReturnType; + generatedToken?: string; + persistedGeneratedToken: boolean; +}> { + const env = params.env ?? process.env; + const persistRequested = params.persist === true; + const resolved = resolveGatewayAuthFromConfig({ + cfg: params.cfg, + env, + authOverride: params.authOverride, + tailscaleOverride: params.tailscaleOverride, + }); + if (resolved.mode !== "token" || (resolved.token?.trim().length ?? 0) > 0) { + return { cfg: params.cfg, auth: resolved, persistedGeneratedToken: false }; + } + + const generatedToken = crypto.randomBytes(24).toString("hex"); + const nextCfg: OpenClawConfig = { + ...params.cfg, + gateway: { + ...params.cfg.gateway, + auth: { + ...params.cfg.gateway?.auth, + mode: "token", + token: generatedToken, + }, + }, + }; + const persist = shouldPersistGeneratedToken({ + persistRequested, + resolvedAuth: resolved, + }); + if (persist) { + await writeConfigFile(nextCfg); + } + + const nextAuth = resolveGatewayAuthFromConfig({ + cfg: nextCfg, + env, + authOverride: params.authOverride, + tailscaleOverride: params.tailscaleOverride, + }); + return { + cfg: nextCfg, + auth: nextAuth, + generatedToken, + persistedGeneratedToken: persist, + }; +}