mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Security: default gateway auth bootstrap and explicit mode none (#20686)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: be1b73182c
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
a2e846f649
commit
c5698caca3
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -616,6 +616,36 @@ describe("gateway server auth/connect", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit none auth", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
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<ReturnType<typeof startGatewayServer>>;
|
||||
let port: number;
|
||||
|
||||
@@ -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 <token>`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
|
||||
if (diagnosticsEnabled) {
|
||||
startDiagnosticHeartbeat();
|
||||
|
||||
212
src/gateway/startup-auth.test.ts
Normal file
212
src/gateway/startup-auth.test.ts
Normal file
@@ -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<typeof import("../config/config.js")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
147
src/gateway/startup-auth.ts
Normal file
147
src/gateway/startup-auth.ts
Normal file
@@ -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<typeof resolveGatewayAuth>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user