diff --git a/src/gateway/server-methods/config.tailscale-compat.test.ts b/src/gateway/server-methods/config.tailscale-compat.test.ts new file mode 100644 index 00000000000..1d2b1d98b0f --- /dev/null +++ b/src/gateway/server-methods/config.tailscale-compat.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { validateTailscaleBindCompat } from "./config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +function makeConfig(gateway: NonNullable): OpenClawConfig { + return { gateway } as OpenClawConfig; +} + +describe("validateTailscaleBindCompat", () => { + // ── Passes (returns null) ───────────────────────────────────────────────── + + it("passes when tailscale mode is off", () => { + expect(validateTailscaleBindCompat(makeConfig({ tailscale: { mode: "off" } }))).toBeNull(); + }); + + it("passes when tailscale mode is unset", () => { + expect(validateTailscaleBindCompat(makeConfig({}))).toBeNull(); + }); + + it("passes when tailscale=serve and bind=loopback", () => { + expect( + validateTailscaleBindCompat(makeConfig({ tailscale: { mode: "serve" }, bind: "loopback" })), + ).toBeNull(); + }); + + it("passes when tailscale=funnel and bind=loopback", () => { + expect( + validateTailscaleBindCompat(makeConfig({ tailscale: { mode: "funnel" }, bind: "loopback" })), + ).toBeNull(); + }); + + it("passes when tailscale=serve and bind=custom with loopback customBindHost (127.0.0.1)", () => { + // bind=custom + customBindHost=127.0.0.1 is loopback at runtime — must be allowed at write-time too. + expect( + validateTailscaleBindCompat( + makeConfig({ + tailscale: { mode: "serve" }, + bind: "custom", + customBindHost: "127.0.0.1", + }), + ), + ).toBeNull(); + }); + + it("passes when tailscale=funnel and bind=custom with customBindHost=::1", () => { + expect( + validateTailscaleBindCompat( + makeConfig({ tailscale: { mode: "funnel" }, bind: "custom", customBindHost: "::1" }), + ), + ).toBeNull(); + }); + + // ── Rejects (returns error string) ─────────────────────────────────────── + + it("rejects when tailscale=serve and bind=lan", () => { + const err = validateTailscaleBindCompat( + makeConfig({ tailscale: { mode: "serve" }, bind: "lan" }), + ); + expect(err).toMatch(/gateway\.tailscale\.mode="serve"/); + expect(err).toMatch(/gateway\.bind="loopback"/); + }); + + it("rejects when tailscale=funnel and bind=lan", () => { + const err = validateTailscaleBindCompat( + makeConfig({ tailscale: { mode: "funnel" }, bind: "lan" }), + ); + expect(err).toMatch(/gateway\.tailscale\.mode="funnel"/); + }); + + it("rejects when tailscale=serve, bind=custom, and customBindHost is a non-loopback IP", () => { + const err = validateTailscaleBindCompat( + makeConfig({ + tailscale: { mode: "serve" }, + bind: "custom", + customBindHost: "192.168.1.100", + }), + ); + expect(err).toMatch(/gateway\.bind="custom"/); + }); + + it("rejects when tailscale=serve, bind=custom, and customBindHost is empty", () => { + const err = validateTailscaleBindCompat( + makeConfig({ tailscale: { mode: "serve" }, bind: "custom", customBindHost: "" }), + ); + expect(err).not.toBeNull(); + }); +}); diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 977a59f00b5..3304d0d781f 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,5 +1,6 @@ import { exec } from "node:child_process"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { isLoopbackHost } from "../net.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { createConfigIO, @@ -280,6 +281,33 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { }); } +/** + * Validate that gateway.tailscale.mode and gateway.bind are compatible. + * When tailscale mode is "serve" or "funnel", bind must be "loopback" (or unset, + * which defaults to "loopback"). Rejecting at config-write time prevents the + * gateway from entering an unrecoverable crash loop on next restart. + */ +export function validateTailscaleBindCompat(config: OpenClawConfig): string | null { + const tailscaleMode = config.gateway?.tailscale?.mode; + if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") { + return null; + } + const bind = config.gateway?.bind ?? "loopback"; + if (bind === "loopback") { + return null; + } + // A custom bind with a loopback IP is equivalent to bind=loopback at runtime + // (server-runtime-config.ts uses isLoopbackHost on the resolved IP). Allow it + // at write-time too so we don't reject a valid config. + if (bind === "custom") { + const customBindHost = config.gateway?.customBindHost?.trim(); + if (customBindHost && isLoopbackHost(customBindHost)) { + return null; + } + } + return `gateway.tailscale.mode="${tailscaleMode}" requires gateway.bind="loopback", but gateway.bind="${bind}". Change gateway.bind to "loopback" or set gateway.tailscale.mode to "off".`; +} + export const configHandlers: GatewayRequestHandlers = { "config.get": async ({ params, respond }) => { if (!assertValidParams(params, validateConfigGetParams, "config.get", respond)) { @@ -340,6 +368,11 @@ export const configHandlers: GatewayRequestHandlers = { if (!parsed) { return; } + const compatError = validateTailscaleBindCompat(parsed.config); + if (compatError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, compatError)); + return; + } await writeConfigFile(parsed.config, writeOptions); respond( true, @@ -425,6 +458,11 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } + const patchCompatError = validateTailscaleBindCompat(validated.config); + if (patchCompatError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, patchCompatError)); + return; + } const changedPaths = diffConfigPaths(snapshot.config, validated.config); const actor = resolveControlPlaneActor(client); context?.logGateway?.info( @@ -485,6 +523,11 @@ export const configHandlers: GatewayRequestHandlers = { if (!parsed) { return; } + const applyCompatError = validateTailscaleBindCompat(parsed.config); + if (applyCompatError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, applyCompatError)); + return; + } const changedPaths = diffConfigPaths(snapshot.config, parsed.config); const actor = resolveControlPlaneActor(client); context?.logGateway?.info( diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 5c1354d7cd5..9cca2aa8bc2 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -250,6 +250,58 @@ describe("resolveGatewayRuntimeConfig", () => { }); }); + describe("tailscale/bind compatibility", () => { + it("rejects tailscale serve with non-loopback bind", async () => { + await expect( + resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + bind: "lan" as const, + auth: TOKEN_AUTH, + tailscale: { mode: "serve" as const }, + controlUi: { allowedOrigins: ["https://control.example.com"] }, + }, + }, + port: 18789, + }), + ).rejects.toThrow("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)"); + }); + + it("rejects tailscale funnel with non-loopback bind", async () => { + await expect( + resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + bind: "lan" as const, + auth: { + mode: "password" as const, + password: "test-password", + }, + tailscale: { mode: "funnel" as const }, + controlUi: { allowedOrigins: ["https://control.example.com"] }, + }, + }, + port: 18789, + }), + ).rejects.toThrow("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)"); + }); + + it("allows tailscale serve with loopback bind", async () => { + const result = await resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + bind: "loopback" as const, + auth: TOKEN_AUTH, + tailscale: { mode: "serve" as const }, + }, + }, + port: 18789, + }); + expect(result.tailscaleMode).toBe("serve"); + expect(result.bindHost).toBe("127.0.0.1"); + }); + }); + describe("HTTP security headers", () => { const cases = [ {