From 53d10f8688b467a9290990e86c496063158affa3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 00:05:48 +0000 Subject: [PATCH] fix(gateway): land access/auth/config migration cluster Land #28960 by @Glucksberg (Tailscale origin auto-allowlist). Land #29394 by @synchronic1 (allowedOrigins upgrade migration). Land #29198 by @Mariana-Codebase (plugin HTTP auth guard + route precedence). Land #30910 by @liuxiaopai-ai (tailscale bind/config.patch guard). Co-authored-by: Glucksberg Co-authored-by: synchronic1 Co-authored-by: Mariana Sinisterra Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com> --- CHANGELOG.md | 4 + src/commands/configure.gateway.test.ts | 80 +++++++- src/commands/configure.gateway.ts | 30 ++- .../config.gateway-tailscale-bind.test.ts | 79 ++++++++ ...etection.rejects-routing-allowfrom.test.ts | 6 +- src/config/legacy-migrate.test.ts | 110 +++++++++++ src/config/legacy.migrations.part-3.ts | 49 +++++ src/config/validation.ts | 32 ++++ src/gateway/gateway-config-prompts.shared.ts | 35 ++++ src/gateway/server-http.ts | 45 +++-- src/gateway/server-runtime-state.ts | 13 +- src/gateway/server.config-patch.test.ts | 19 ++ src/gateway/server.impl.ts | 50 +++++ src/gateway/server.plugin-http-auth.test.ts | 172 +++++++++++++++++- src/gateway/server/plugins-http.test.ts | 38 +++- src/gateway/server/plugins-http.ts | 13 ++ src/wizard/onboarding.gateway-config.test.ts | 108 +++++++++++ src/wizard/onboarding.gateway-config.ts | 30 ++- 18 files changed, 876 insertions(+), 37 deletions(-) create mode 100644 src/config/config.gateway-tailscale-bind.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a413e35865f..923f55427e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,10 @@ Docs: https://docs.openclaw.ai - Gateway/Control UI API routing: when `gateway.controlUi.basePath` is unset (default), stop serving Control UI SPA HTML for `/api` and `/api/*` so API paths fall through to normal gateway handlers/404 responses instead of `index.html`. (#30333) Fixes #30295. thanks @Sid-Qin. - Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks @hugenshen. - Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks @MoerAI. +- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #28960 by @Glucksberg. Thanks @Glucksberg. +- Gateway/Upgrade migration for Control UI origins: seed `gateway.controlUi.allowedOrigins` on startup for legacy non-loopback configs (`lan`/`tailnet`/`custom`) when origins are missing or blank, preventing post-upgrade crash loops while preserving explicit existing policy. Landed from contributor PR #29394 by @synchronic1. Thanks @synchronic1. +- Gateway/Plugin HTTP auth hardening: require gateway auth for protected plugin paths and explicit `registerHttpRoute` paths (while preserving wildcard-handler behavior for signature-auth webhooks), and run plugin handlers after built-in handlers for deterministic route precedence. Landed from contributor PR #29198 by @Mariana-Codebase. Thanks @Mariana-Codebase. +- Gateway/Config patch guard: reject `config.patch` updates that set non-loopback `gateway.bind` while `gateway.tailscale.mode` is `serve`/`funnel`, preventing restart crash loops from invalid bind/tailscale combinations. Landed from contributor PR #30910 by @liuxiaopai-ai. Thanks @liuxiaopai-ai. - Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97. - Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97. - File tools/tilde paths: expand `~/...` against the user home directory before workspace-root checks in host file read/write/edit paths, while preserving root-boundary enforcement so outside-root targets remain blocked. (#29779) Thanks @Glucksberg. diff --git a/src/commands/configure.gateway.test.ts b/src/commands/configure.gateway.test.ts index 05f634d85fe..d23cfafadc7 100644 --- a/src/commands/configure.gateway.test.ts +++ b/src/commands/configure.gateway.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; const mocks = vi.hoisted(() => ({ @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ buildGatewayAuthConfig: vi.fn(), note: vi.fn(), randomToken: vi.fn(), + getTailnetHostname: vi.fn(), })); vi.mock("../config/config.js", async (importActual) => { @@ -35,6 +37,7 @@ vi.mock("./configure.gateway-auth.js", () => ({ vi.mock("../infra/tailscale.js", () => ({ findTailscaleBinary: vi.fn(async () => undefined), + getTailnetHostname: mocks.getTailnetHostname, })); vi.mock("./onboard-helpers.js", async (importActual) => { @@ -58,6 +61,7 @@ function makeRuntime(): RuntimeEnv { async function runGatewayPrompt(params: { selectQueue: string[]; textQueue: Array; + baseConfig?: OpenClawConfig; randomToken?: string; confirmResult?: boolean; authConfigFactory?: (input: Record) => Record; @@ -72,7 +76,7 @@ async function runGatewayPrompt(params: { params.authConfigFactory ? params.authConfigFactory(input as Record) : input, ); - const result = await promptGatewayConfig({}, makeRuntime()); + const result = await promptGatewayConfig(params.baseConfig ?? {}, makeRuntime()); const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; return { result, call }; } @@ -154,4 +158,78 @@ describe("promptGatewayConfig", () => { expect(result.config.gateway?.tailscale?.mode).toBe("off"); expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false); }); + + it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => { + mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); + const { result } = await runGatewayPrompt({ + // bind=loopback, auth=token, tailscale=serve + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", "my-token"], + confirmResult: true, + authConfigFactory: ({ mode, token }) => ({ mode, token }), + }); + expect(result.config.gateway?.controlUi?.allowedOrigins).toContain( + "https://my-host.tail1234.ts.net", + ); + }); + + it("adds Tailscale origin to controlUi.allowedOrigins when tailscale funnel is enabled", async () => { + mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); + const { result } = await runGatewayPrompt({ + // bind=loopback, auth=password (funnel requires password), tailscale=funnel + selectQueue: ["loopback", "password", "funnel"], + textQueue: ["18789", "my-password"], + confirmResult: true, + authConfigFactory: ({ mode, password }) => ({ mode, password }), + }); + expect(result.config.gateway?.controlUi?.allowedOrigins).toContain( + "https://my-host.tail1234.ts.net", + ); + }); + + it("does not add Tailscale origin when getTailnetHostname fails", async () => { + mocks.getTailnetHostname.mockRejectedValue(new Error("not found")); + const { result } = await runGatewayPrompt({ + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", "my-token"], + confirmResult: true, + authConfigFactory: ({ mode, token }) => ({ mode, token }), + }); + expect(result.config.gateway?.controlUi?.allowedOrigins).toBeUndefined(); + }); + + it("does not duplicate Tailscale origin if already present", async () => { + mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); + const { result } = await runGatewayPrompt({ + baseConfig: { + gateway: { + controlUi: { + allowedOrigins: ["HTTPS://MY-HOST.TAIL1234.TS.NET"], + }, + }, + }, + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", "my-token"], + confirmResult: true, + authConfigFactory: ({ mode, token }) => ({ mode, token }), + }); + const origins = result.config.gateway?.controlUi?.allowedOrigins ?? []; + const tsOriginCount = origins.filter( + (origin) => origin.toLowerCase() === "https://my-host.tail1234.ts.net", + ).length; + expect(tsOriginCount).toBe(1); + }); + + it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => { + mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::12"); + const { result } = await runGatewayPrompt({ + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", "my-token"], + confirmResult: true, + authConfigFactory: ({ mode, token }) => ({ mode, token }), + }); + expect(result.config.gateway?.controlUi?.allowedOrigins).toContain( + "https://[fd7a:115c:a1e0::12]", + ); + }); }); diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index ec9a2970e2c..2743272d081 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -1,11 +1,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort } from "../config/config.js"; import { + appendAllowedOrigin, + buildTailnetHttpsOrigin, TAILSCALE_DOCS_LINES, TAILSCALE_EXPOSURE_OPTIONS, TAILSCALE_MISSING_BIN_NOTE_LINES, } from "../gateway/gateway-config-prompts.shared.js"; -import { findTailscaleBinary } from "../infra/tailscale.js"; +import { findTailscaleBinary, getTailnetHostname } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import { note } from "../terminal/note.js"; @@ -111,8 +113,10 @@ export async function promptGatewayConfig( ); // Detect Tailscale binary before proceeding with serve/funnel setup. + // Persist the path so getTailnetHostname can reuse it for origin injection. + let tailscaleBin: string | null = null; if (tailscaleMode !== "off") { - const tailscaleBin = await findTailscaleBinary(); + tailscaleBin = await findTailscaleBinary(); if (!tailscaleBin) { note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning"); } @@ -285,5 +289,27 @@ export async function promptGatewayConfig( }, }; + // Auto-add Tailscale origin to controlUi.allowedOrigins so the Control UI + // is accessible via the Tailscale hostname without manual config. + if (tailscaleMode === "serve" || tailscaleMode === "funnel") { + const tsOrigin = await getTailnetHostname(undefined, tailscaleBin ?? undefined) + .then((host) => buildTailnetHttpsOrigin(host)) + .catch(() => null); + if (tsOrigin) { + const existing = next.gateway?.controlUi?.allowedOrigins ?? []; + const updatedOrigins = appendAllowedOrigin(existing, tsOrigin); + next = { + ...next, + gateway: { + ...next.gateway, + controlUi: { + ...next.gateway?.controlUi, + allowedOrigins: updatedOrigins, + }, + }, + }; + } + } + return { config: next, port, token: gatewayToken }; } diff --git a/src/config/config.gateway-tailscale-bind.test.ts b/src/config/config.gateway-tailscale-bind.test.ts new file mode 100644 index 00000000000..457af67717d --- /dev/null +++ b/src/config/config.gateway-tailscale-bind.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("gateway tailscale bind validation", () => { + it("accepts loopback bind when tailscale serve/funnel is enabled", () => { + const serveRes = validateConfigObject({ + gateway: { + bind: "loopback", + tailscale: { mode: "serve" }, + }, + }); + expect(serveRes.ok).toBe(true); + + const funnelRes = validateConfigObject({ + gateway: { + bind: "loopback", + tailscale: { mode: "funnel" }, + }, + }); + expect(funnelRes.ok).toBe(true); + }); + + it("accepts custom loopback bind host with tailscale serve/funnel", () => { + const res = validateConfigObject({ + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + tailscale: { mode: "serve" }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects IPv6 custom bind host for tailscale serve/funnel", () => { + const res = validateConfigObject({ + gateway: { + bind: "custom", + customBindHost: "::1", + tailscale: { mode: "serve" }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((issue) => issue.path === "gateway.bind")).toBe(true); + } + }); + + it("rejects non-loopback bind when tailscale serve/funnel is enabled", () => { + const lanRes = validateConfigObject({ + gateway: { + bind: "lan", + tailscale: { mode: "serve" }, + }, + }); + expect(lanRes.ok).toBe(false); + if (!lanRes.ok) { + expect(lanRes.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "gateway.bind", + message: expect.stringContaining("gateway.bind must resolve to loopback"), + }), + ]), + ); + } + + const customRes = validateConfigObject({ + gateway: { + bind: "custom", + customBindHost: "10.0.0.5", + tailscale: { mode: "funnel" }, + }, + }); + expect(customRes.ok).toBe(false); + if (!customRes.ok) { + expect(customRes.issues.some((issue) => issue.path === "gateway.bind")).toBe(true); + } + }); +}); diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 45f51e6a2c7..6e89928a043 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -365,7 +365,11 @@ describe("legacy config detection", () => { gateway: { bind: "tailnet" as const }, }); expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'."); - expect(res.config).toBeNull(); + expect(res.config?.gateway?.bind).toBe("tailnet"); + expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([ + "http://localhost:18789", + "http://127.0.0.1:18789", + ]); const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } }); expect(validated.ok).toBe(true); diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index 63d93c2951e..89c1977e9cc 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -104,3 +104,113 @@ describe("legacy migrate mention routing", () => { ).toBeUndefined(); }); }); + +describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { + it("seeds allowedOrigins for bind=lan with no existing controlUi config", () => { + const res = migrateLegacyConfig({ + gateway: { + bind: "lan", + auth: { mode: "token", token: "tok" }, + }, + }); + expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([ + "http://localhost:18789", + "http://127.0.0.1:18789", + ]); + expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true); + expect(res.changes.some((c) => c.includes("bind=lan"))).toBe(true); + }); + + it("seeds allowedOrigins using configured port", () => { + const res = migrateLegacyConfig({ + gateway: { + bind: "lan", + port: 9000, + auth: { mode: "token", token: "tok" }, + }, + }); + expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([ + "http://localhost:9000", + "http://127.0.0.1:9000", + ]); + }); + + it("seeds allowedOrigins including custom bind host for bind=custom", () => { + const res = migrateLegacyConfig({ + gateway: { + bind: "custom", + customBindHost: "192.168.1.100", + auth: { mode: "token", token: "tok" }, + }, + }); + expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://192.168.1.100:18789"); + expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://localhost:18789"); + }); + + it("does not overwrite existing allowedOrigins — returns null (no migration needed)", () => { + // When allowedOrigins already exists, the migration is a no-op. + // applyLegacyMigrations returns next=null when changes.length===0, so config is null. + const res = migrateLegacyConfig({ + gateway: { + bind: "lan", + auth: { mode: "token", token: "tok" }, + controlUi: { allowedOrigins: ["https://control.example.com"] }, + }, + }); + expect(res.config).toBeNull(); + expect(res.changes).toHaveLength(0); + }); + + it("does not migrate when dangerouslyAllowHostHeaderOriginFallback is set — returns null", () => { + const res = migrateLegacyConfig({ + gateway: { + bind: "lan", + auth: { mode: "token", token: "tok" }, + controlUi: { dangerouslyAllowHostHeaderOriginFallback: true }, + }, + }); + expect(res.config).toBeNull(); + expect(res.changes).toHaveLength(0); + }); + + it("seeds allowedOrigins when existing entries are blank strings", () => { + const res = migrateLegacyConfig({ + gateway: { + bind: "lan", + auth: { mode: "token", token: "tok" }, + controlUi: { allowedOrigins: ["", " "] }, + }, + }); + expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([ + "http://localhost:18789", + "http://127.0.0.1:18789", + ]); + expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true); + }); + + it("does not migrate loopback bind — returns null", () => { + const res = migrateLegacyConfig({ + gateway: { + bind: "loopback", + auth: { mode: "token", token: "tok" }, + }, + }); + expect(res.config).toBeNull(); + expect(res.changes).toHaveLength(0); + }); + + it("preserves existing controlUi fields when seeding allowedOrigins", () => { + const res = migrateLegacyConfig({ + gateway: { + bind: "lan", + auth: { mode: "token", token: "tok" }, + controlUi: { basePath: "/app" }, + }, + }); + expect(res.config?.gateway?.controlUi?.basePath).toBe("/app"); + expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([ + "http://localhost:18789", + "http://127.0.0.1:18789", + ]); + }); +}); diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index 18db0da19cd..ca663926cde 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -8,12 +8,61 @@ import { mergeMissing, resolveDefaultAgentIdFromRaw, } from "./legacy.shared.js"; +import { DEFAULT_GATEWAY_PORT } from "./paths.js"; // NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed. // tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod). export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ + { + // v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the + // host-header fallback flag) for any non-loopback bind. The onboarding wizard was updated + // to seed this for new installs, but existing bind=lan/bind=custom installs that upgrade + // crash-loop immediately on next startup with no recovery path (issue #29385). + // + // This migration runs on every gateway start via migrateLegacyConfig → applyLegacyMigrations + // and writes the seeded origins to disk before the startup guard fires, preventing the loop. + id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback", + describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs", + apply: (raw, changes) => { + const gateway = getRecord(raw.gateway); + if (!gateway) { + return; + } + const bind = gateway.bind; + if (bind !== "lan" && bind !== "tailnet" && bind !== "custom") { + return; + } + const controlUi = getRecord(gateway.controlUi) ?? {}; + const existingOrigins = controlUi.allowedOrigins; + const hasConfiguredOrigins = + Array.isArray(existingOrigins) && + existingOrigins.some((origin) => typeof origin === "string" && origin.trim().length > 0); + if (hasConfiguredOrigins) { + return; // already configured + } + if (controlUi.dangerouslyAllowHostHeaderOriginFallback === true) { + return; // already opted into fallback + } + const port = + typeof gateway.port === "number" && gateway.port > 0 ? gateway.port : DEFAULT_GATEWAY_PORT; + const origins = new Set([`http://localhost:${port}`, `http://127.0.0.1:${port}`]); + if ( + bind === "custom" && + typeof gateway.customBindHost === "string" && + gateway.customBindHost.trim() + ) { + origins.add(`http://${gateway.customBindHost.trim()}:${port}`); + } + gateway.controlUi = { ...controlUi, allowedOrigins: [...origins] }; + raw.gateway = gateway; + changes.push( + `Seeded gateway.controlUi.allowedOrigins ${JSON.stringify([...origins])} for bind=${String(bind)}. ` + + "Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.", + ); + }, + }, { id: "memorySearch->agents.defaults.memorySearch", describe: "Move top-level memorySearch to agents.defaults.memorySearch", diff --git a/src/config/validation.ts b/src/config/validation.ts index fab6351254c..b9e37734fc7 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -15,6 +15,7 @@ import { isPathWithinRoot, isWindowsAbsolutePath, } from "../shared/avatar-policy.js"; +import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js"; import { isRecord } from "../utils.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; @@ -80,6 +81,33 @@ function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] return issues; } +function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationIssue[] { + const tailscaleMode = config.gateway?.tailscale?.mode ?? "off"; + if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") { + return []; + } + const bindMode = config.gateway?.bind ?? "loopback"; + if (bindMode === "loopback") { + return []; + } + const customBindHost = config.gateway?.customBindHost; + if ( + bindMode === "custom" && + isCanonicalDottedDecimalIPv4(customBindHost) && + isLoopbackIpAddress(customBindHost) + ) { + return []; + } + return [ + { + path: "gateway.bind", + message: + `gateway.bind must resolve to loopback when gateway.tailscale.mode=${tailscaleMode} ` + + '(use gateway.bind="loopback" or gateway.bind="custom" with gateway.customBindHost="127.0.0.1")', + }, + ]; +} + /** * Validates config without applying runtime defaults. * Use this when you need the raw validated config (e.g., for writing back to file). @@ -123,6 +151,10 @@ export function validateConfigObjectRaw( if (avatarIssues.length > 0) { return { ok: false, issues: avatarIssues }; } + const gatewayTailscaleBindIssues = validateGatewayTailscaleBind(validated.data as OpenClawConfig); + if (gatewayTailscaleBindIssues.length > 0) { + return { ok: false, issues: gatewayTailscaleBindIssues }; + } return { ok: true, config: validated.data as OpenClawConfig, diff --git a/src/gateway/gateway-config-prompts.shared.ts b/src/gateway/gateway-config-prompts.shared.ts index e32d7ec0be8..fdc73d908a7 100644 --- a/src/gateway/gateway-config-prompts.shared.ts +++ b/src/gateway/gateway-config-prompts.shared.ts @@ -1,3 +1,5 @@ +import { isIpv6Address, parseCanonicalIpAddress } from "../shared/net/ip.js"; + export const TAILSCALE_EXPOSURE_OPTIONS = [ { value: "off", label: "Off", hint: "No Tailscale exposure" }, { @@ -25,3 +27,36 @@ export const TAILSCALE_DOCS_LINES = [ "https://docs.openclaw.ai/gateway/tailscale", "https://docs.openclaw.ai/web", ] as const; + +function normalizeTailnetHostForUrl(rawHost: string): string | null { + const trimmed = rawHost.trim().replace(/\.$/, ""); + if (!trimmed) { + return null; + } + const parsed = parseCanonicalIpAddress(trimmed); + if (parsed && isIpv6Address(parsed)) { + return `[${parsed.toString().toLowerCase()}]`; + } + return trimmed; +} + +export function buildTailnetHttpsOrigin(rawHost: string): string | null { + const normalizedHost = normalizeTailnetHostForUrl(rawHost); + if (!normalizedHost) { + return null; + } + try { + return new URL(`https://${normalizedHost}`).origin; + } catch { + return null; + } +} + +export function appendAllowedOrigin(existing: string[] | undefined, origin: string): string[] { + const current = existing ?? []; + const normalized = origin.toLowerCase(); + if (current.some((entry) => entry.toLowerCase() === normalized)) { + return current; + } + return [...current, origin]; +} diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 0af1120d21f..c8f968a0a6f 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -172,7 +172,6 @@ async function authorizeCanvasRequest(params: { } async function enforcePluginRouteGatewayAuth(params: { - requestPath: string; req: IncomingMessage; res: ServerResponse; auth: ResolvedGatewayAuth; @@ -180,9 +179,6 @@ async function enforcePluginRouteGatewayAuth(params: { allowRealIpFallback: boolean; rateLimiter?: AuthRateLimiter; }): Promise { - if (!isProtectedPluginRoutePath(params.requestPath)) { - return true; - } const token = getBearerToken(params.req); const authResult = await authorizeHttpGatewayConnect({ auth: params.auth, @@ -460,6 +456,7 @@ export function createGatewayHttpServer(opts: { strictTransportSecurityHeader?: string; handleHooksRequest: HooksRequestHandler; handlePluginRequest?: HooksRequestHandler; + shouldEnforcePluginGatewayAuth?: (requestPath: string) => boolean; resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; @@ -477,6 +474,7 @@ export function createGatewayHttpServer(opts: { strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, + shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, } = opts; @@ -527,26 +525,6 @@ export function createGatewayHttpServer(opts: { if (await handleSlackHttpRequest(req, res)) { return; } - if (handlePluginRequest) { - // Protected plugin route prefixes are gateway-auth protected by default. - // Non-protected plugin routes remain plugin-owned and must enforce - // their own auth when exposing sensitive functionality. - const pluginAuthOk = await enforcePluginRouteGatewayAuth({ - requestPath, - req, - res, - auth: resolvedAuth, - trustedProxies, - allowRealIpFallback, - rateLimiter, - }); - if (!pluginAuthOk) { - return; - } - if (await handlePluginRequest(req, res)) { - return; - } - } if (openResponsesEnabled) { if ( await handleOpenResponsesHttpRequest(req, res, { @@ -615,6 +593,25 @@ export function createGatewayHttpServer(opts: { return; } } + // Plugins run last so built-in gateway routes keep precedence on overlapping paths. + if (handlePluginRequest) { + if ((shouldEnforcePluginGatewayAuth ?? isProtectedPluginRoutePath)(requestPath)) { + const pluginAuthOk = await enforcePluginRouteGatewayAuth({ + req, + res, + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }); + if (!pluginAuthOk) { + return; + } + } + if (await handlePluginRequest(req, res)) { + return; + } + } res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index c9fa334222e..a2d142c6cc3 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -12,6 +12,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js"; import type { ControlUiRootState } from "./control-ui.js"; import type { HooksConfigResolved } from "./hooks.js"; import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js"; +import { isProtectedPluginRoutePath } from "./security-path.js"; import { createGatewayBroadcaster, type GatewayBroadcastFn, @@ -27,7 +28,10 @@ import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-h import type { DedupeEntry } from "./server-shared.js"; import { createGatewayHooksRequestHandler } from "./server/hooks.js"; import { listenGatewayHttpServer } from "./server/http-listen.js"; -import { createGatewayPluginRequestHandler } from "./server/plugins-http.js"; +import { + createGatewayPluginRequestHandler, + isRegisteredPluginHttpRoutePath, +} from "./server/plugins-http.js"; import type { GatewayTlsRuntime } from "./server/tls.js"; import type { GatewayWsClient } from "./server/ws-types.js"; @@ -115,6 +119,12 @@ export async function createGatewayRuntimeState(params: { registry: params.pluginRegistry, log: params.logPlugins, }); + const shouldEnforcePluginGatewayAuth = (requestPath: string): boolean => { + if (isProtectedPluginRoutePath(requestPath)) { + return true; + } + return isRegisteredPluginHttpRoutePath(params.pluginRegistry, requestPath); + }; const bindHosts = await resolveGatewayListenHosts(params.bindHost); if (!isLoopbackHost(params.bindHost)) { @@ -138,6 +148,7 @@ export async function createGatewayRuntimeState(params: { strictTransportSecurityHeader: params.strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, + shouldEnforcePluginGatewayAuth, resolvedAuth: params.resolvedAuth, rateLimiter: params.rateLimiter, tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index e26e878ca70..12984d261b3 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -54,6 +54,25 @@ describe("gateway config methods", () => { expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("raw must be an object"); }); + + it("rejects config.patch when tailscale serve/funnel is paired with non-loopback bind", async () => { + const res = await rpcReq<{ + ok?: boolean; + error?: { details?: { issues?: Array<{ path?: string }> } }; + }>(requireWs(), "config.patch", { + raw: JSON.stringify({ + gateway: { + bind: "lan", + tailscale: { mode: "serve" }, + }, + }), + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("invalid config"); + const issues = (res.error as { details?: { issues?: Array<{ path?: string }> } } | undefined) + ?.details?.issues; + expect(issues?.some((issue) => issue.path === "gateway.bind")).toBe(true); + }); }); describe("gateway server sessions", () => { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 4ae6016a46a..e2bc0d0bca4 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -18,6 +18,7 @@ import { readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; +import { DEFAULT_GATEWAY_PORT } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; @@ -377,6 +378,55 @@ export async function startGatewayServer( setPreRestartDeferralCheck( () => getTotalQueueSize() + getTotalPendingReplies() + getActiveEmbeddedRunCount(), ); + // Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing + // bind=lan/custom installs that upgraded to v2026.2.26+ without the required origins set. + // This runs regardless of whether legacy-key issues exist — the affected config is + // schema-valid (no legacy keys), so it is never caught by the legacyIssues gate above. + // Without this guard the gateway would proceed to resolveGatewayRuntimeConfig and throw, + // causing a systemd crash-loop with no recovery path (issue #29385). + const controlUiBind = cfgAtStart.gateway?.bind; + const isNonLoopbackBind = + controlUiBind === "lan" || controlUiBind === "tailnet" || controlUiBind === "custom"; + const hasControlUiOrigins = (cfgAtStart.gateway?.controlUi?.allowedOrigins ?? []).some( + (origin) => typeof origin === "string" && origin.trim().length > 0, + ); + const hasControlUiFallback = + cfgAtStart.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; + if (isNonLoopbackBind && !hasControlUiOrigins && !hasControlUiFallback) { + const bindPort = + typeof cfgAtStart.gateway?.port === "number" && cfgAtStart.gateway.port > 0 + ? cfgAtStart.gateway.port + : DEFAULT_GATEWAY_PORT; + const seededOrigins = new Set([ + `http://localhost:${bindPort}`, + `http://127.0.0.1:${bindPort}`, + ]); + const customBindHost = cfgAtStart.gateway?.customBindHost?.trim(); + if (controlUiBind === "custom" && customBindHost) { + seededOrigins.add(`http://${customBindHost}:${bindPort}`); + } + cfgAtStart = { + ...cfgAtStart, + gateway: { + ...cfgAtStart.gateway, + controlUi: { + ...cfgAtStart.gateway?.controlUi, + allowedOrigins: [...seededOrigins], + }, + }, + }; + try { + await writeConfigFile(cfgAtStart); + log.info( + `gateway: seeded gateway.controlUi.allowedOrigins ${JSON.stringify([...seededOrigins])} for bind=${controlUiBind} (required since v2026.2.26; see issue #29385). Add other origins to gateway.controlUi.allowedOrigins if needed.`, + ); + } catch (err) { + log.warn( + `gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`, + ); + } + } + initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index b6a75ea008b..ff0a03b094d 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import type { HooksConfigResolved } from "./hooks.js"; -import { canonicalizePathVariant } from "./security-path.js"; +import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js"; import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js"; import { withTempConfig } from "./test-temp-config.js"; @@ -243,7 +243,7 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); - test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => { + test("requires gateway auth for protected plugin route space and allows authenticated pass-through", async () => { const resolvedAuth: ResolvedGatewayAuth = { mode: "token", token: "test-token", @@ -287,6 +287,8 @@ describe("gateway plugin HTTP auth boundary", () => { openResponsesEnabled: false, handleHooksRequest: async () => false, handlePluginRequest, + shouldEnforcePluginGatewayAuth: (requestPath) => + isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public", resolvedAuth, }); @@ -328,10 +330,168 @@ describe("gateway plugin HTTP auth boundary", () => { createRequest({ path: "/plugin/public" }), unauthenticatedPublic.res, ); - expect(unauthenticatedPublic.res.statusCode).toBe(200); - expect(unauthenticatedPublic.getBody()).toContain('"route":"public"'); + expect(unauthenticatedPublic.res.statusCode).toBe(401); + expect(unauthenticatedPublic.getBody()).toContain("Unauthorized"); - expect(handlePluginRequest).toHaveBeenCalledTimes(2); + expect(handlePluginRequest).toHaveBeenCalledTimes(1); + }, + }); + }); + + test("keeps wildcard plugin handlers ungated when auth enforcement predicate excludes their paths", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-auth-wildcard-handler-test-", + run: async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/plugin/routed") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "routed" })); + return true; + } + if (pathname === "/googlechat") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "wildcard-handler" })); + return true; + } + return false; + }); + + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + handlePluginRequest, + shouldEnforcePluginGatewayAuth: (requestPath) => + requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed", + resolvedAuth, + }); + + const unauthenticatedRouted = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/plugin/routed" }), + unauthenticatedRouted.res, + ); + expect(unauthenticatedRouted.res.statusCode).toBe(401); + expect(unauthenticatedRouted.getBody()).toContain("Unauthorized"); + + const unauthenticatedWildcard = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/googlechat" }), + unauthenticatedWildcard.res, + ); + expect(unauthenticatedWildcard.res.statusCode).toBe(200); + expect(unauthenticatedWildcard.getBody()).toContain('"route":"wildcard-handler"'); + + const authenticatedRouted = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/plugin/routed", + authorization: "Bearer test-token", + }), + authenticatedRouted.res, + ); + expect(authenticatedRouted.res.statusCode).toBe(200); + expect(authenticatedRouted.getBody()).toContain('"route":"routed"'); + }, + }); + }); + + test("uses /api/channels auth by default while keeping wildcard handlers ungated with no predicate", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-auth-wildcard-default-test-", + run: async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-default" })); + return true; + } + if (pathname === "/googlechat") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "wildcard-default" })); + return true; + } + return false; + }); + + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + handlePluginRequest, + resolvedAuth, + }); + + const unauthenticated = createResponse(); + await dispatchRequest(server, createRequest({ path: "/googlechat" }), unauthenticated.res); + expect(unauthenticated.res.statusCode).toBe(200); + expect(unauthenticated.getBody()).toContain('"route":"wildcard-default"'); + + const unauthenticatedChannel = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels/nostr/default/profile" }), + unauthenticatedChannel.res, + ); + expect(unauthenticatedChannel.res.statusCode).toBe(401); + expect(unauthenticatedChannel.getBody()).toContain("Unauthorized"); + + const authenticated = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/googlechat", + authorization: "Bearer test-token", + }), + authenticated.res, + ); + expect(authenticated.res.statusCode).toBe(200); + expect(authenticated.getBody()).toContain('"route":"wildcard-default"'); + + const authenticatedChannel = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/api/channels/nostr/default/profile", + authorization: "Bearer test-token", + }), + authenticatedChannel.res, + ); + expect(authenticatedChannel.res.statusCode).toBe(200); + expect(authenticatedChannel.getBody()).toContain('"route":"channel-default"'); }, }); }); @@ -369,6 +529,7 @@ describe("gateway plugin HTTP auth boundary", () => { openResponsesEnabled: false, handleHooksRequest: async () => false, handlePluginRequest, + shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, resolvedAuth, }); @@ -418,6 +579,7 @@ describe("gateway plugin HTTP auth boundary", () => { openResponsesEnabled: false, handleHooksRequest: async () => false, handlePluginRequest, + shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, resolvedAuth, }); diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 8ac4fc45cd0..3e2fe65c1dc 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -2,7 +2,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, expect, it, vi } from "vitest"; import { makeMockHttpResponse } from "../test-http-response.js"; import { createTestRegistry } from "./__tests__/test-utils.js"; -import { createGatewayPluginRequestHandler } from "./plugins-http.js"; +import { + createGatewayPluginRequestHandler, + isRegisteredPluginHttpRoutePath, +} from "./plugins-http.js"; describe("createGatewayPluginRequestHandler", () => { it("returns false when no handlers are registered", async () => { @@ -97,3 +100,36 @@ describe("createGatewayPluginRequestHandler", () => { expect(end).toHaveBeenCalledWith("Internal Server Error"); }); }); + +describe("plugin HTTP registry helpers", () => { + it("detects registered route paths", () => { + const registry = createTestRegistry({ + httpRoutes: [ + { + pluginId: "route", + path: "/demo", + handler: () => {}, + source: "route", + }, + ], + }); + expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true); + expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false); + }); + + it("matches canonicalized variants of registered route paths", () => { + const registry = createTestRegistry({ + httpRoutes: [ + { + pluginId: "route", + path: "/api/demo", + handler: () => {}, + source: "route", + }, + ], + }); + expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true); + expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true); + expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true); + }); +}); diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 8140be67d99..2d83dd999a4 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginRegistry } from "../../plugins/registry.js"; +import { canonicalizePathVariant } from "../security-path.js"; type SubsystemLogger = ReturnType; @@ -9,6 +10,18 @@ export type PluginHttpRequestHandler = ( res: ServerResponse, ) => Promise; +// Only checks specific routes registered via registerHttpRoute, not wildcard handlers +// registered via registerHttpHandler. Wildcard handlers (e.g., webhooks) implement +// their own signature-based auth and are handled separately in the auth enforcement logic. +export function isRegisteredPluginHttpRoutePath( + registry: PluginRegistry, + pathname: string, +): boolean { + const canonicalPath = canonicalizePathVariant(pathname); + const routes = registry.httpRoutes ?? []; + return routes.some((entry) => canonicalizePathVariant(entry.path) === canonicalPath); +} + export function createGatewayPluginRequestHandler(params: { registry: PluginRegistry; log: SubsystemLogger; diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index 1bbe3a82f15..8163747289c 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -5,6 +5,7 @@ import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; const mocks = vi.hoisted(() => ({ randomToken: vi.fn(), + getTailnetHostname: vi.fn(), })); vi.mock("../commands/onboard-helpers.js", async (importActual) => { @@ -17,6 +18,7 @@ vi.mock("../commands/onboard-helpers.js", async (importActual) => { vi.mock("../infra/tailscale.js", () => ({ findTailscaleBinary: vi.fn(async () => undefined), + getTailnetHostname: mocks.getTailnetHostname, })); import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; @@ -136,4 +138,110 @@ describe("configureGatewayForOnboarding", () => { "http://127.0.0.1:18789", ]); }); + + it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => { + mocks.randomToken.mockReturnValue("generated-token"); + mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); + + const prompter = createPrompter({ + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", undefined], + }); + const runtime = createRuntime(); + + const result = await configureGatewayForOnboarding({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: 18789, + quickstartGateway: createQuickstartGateway("token"), + prompter, + runtime, + }); + + expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain( + "https://my-host.tail1234.ts.net", + ); + }); + + it("does not add Tailscale origin when getTailnetHostname fails", async () => { + mocks.randomToken.mockReturnValue("generated-token"); + mocks.getTailnetHostname.mockRejectedValue(new Error("not found")); + + const prompter = createPrompter({ + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", undefined], + }); + const runtime = createRuntime(); + + const result = await configureGatewayForOnboarding({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: 18789, + quickstartGateway: createQuickstartGateway("token"), + prompter, + runtime, + }); + + expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined(); + }); + + it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => { + mocks.randomToken.mockReturnValue("generated-token"); + mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99"); + + const prompter = createPrompter({ + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", undefined], + }); + const runtime = createRuntime(); + + const result = await configureGatewayForOnboarding({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: 18789, + quickstartGateway: createQuickstartGateway("token"), + prompter, + runtime, + }); + + expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain( + "https://[fd7a:115c:a1e0::99]", + ); + }); + + it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => { + mocks.randomToken.mockReturnValue("generated-token"); + mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net"); + + const prompter = createPrompter({ + selectQueue: ["loopback", "token", "serve"], + textQueue: ["18789", undefined], + }); + const runtime = createRuntime(); + + const result = await configureGatewayForOnboarding({ + flow: "advanced", + baseConfig: {}, + nextConfig: { + gateway: { + controlUi: { + allowedOrigins: ["HTTPS://MY-HOST.TAIL1234.TS.NET"], + }, + }, + }, + localPort: 18789, + quickstartGateway: createQuickstartGateway("token"), + prompter, + runtime, + }); + + const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? []; + const tsOriginCount = origins.filter( + (origin) => origin.toLowerCase() === "https://my-host.tail1234.ts.net", + ).length; + expect(tsOriginCount).toBe(1); + }); }); diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index 6aba767b401..cbccc5568a0 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -6,11 +6,13 @@ import { import type { GatewayAuthChoice } from "../commands/onboard-types.js"; import type { GatewayBindMode, GatewayTailscaleMode, OpenClawConfig } from "../config/config.js"; import { + appendAllowedOrigin, + buildTailnetHttpsOrigin, TAILSCALE_DOCS_LINES, TAILSCALE_EXPOSURE_OPTIONS, TAILSCALE_MISSING_BIN_NOTE_LINES, } from "../gateway/gateway-config-prompts.shared.js"; -import { findTailscaleBinary } from "../infra/tailscale.js"; +import { findTailscaleBinary, getTailnetHostname } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import type { @@ -137,8 +139,10 @@ export async function configureGatewayForOnboarding( }); // Detect Tailscale binary before proceeding with serve/funnel setup. + // Persist the path so getTailnetHostname can reuse it for origin injection. + let tailscaleBin: string | null = null; if (tailscaleMode !== "off") { - const tailscaleBin = await findTailscaleBinary(); + tailscaleBin = await findTailscaleBinary(); if (!tailscaleBin) { await prompter.note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning"); } @@ -253,6 +257,28 @@ export async function configureGatewayForOnboarding( }; } + // Auto-add Tailscale origin to controlUi.allowedOrigins so the Control UI + // is accessible via the Tailscale hostname without manual config. + if (tailscaleMode === "serve" || tailscaleMode === "funnel") { + const tsOrigin = await getTailnetHostname(undefined, tailscaleBin ?? undefined) + .then((host) => buildTailnetHttpsOrigin(host)) + .catch(() => null); + if (tsOrigin) { + const existing = nextConfig.gateway?.controlUi?.allowedOrigins ?? []; + const updatedOrigins = appendAllowedOrigin(existing, tsOrigin); + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + controlUi: { + ...nextConfig.gateway?.controlUi, + allowedOrigins: updatedOrigins, + }, + }, + }; + } + } + // If this is a new gateway setup (no existing gateway settings), start with a // denylist for high-risk node commands. Users can arm these temporarily via // /phone arm ... (phone-control plugin).