From b521b02d57982a11f88645fb08bcfb811b973b0c Mon Sep 17 00:00:00 2001 From: Aaron Aronchick Date: Thu, 5 Mar 2026 00:45:14 +0000 Subject: [PATCH 1/6] fix: validate tailscale/bind compatibility at config-write time When gateway.tailscale.mode is "serve" or "funnel" and gateway.bind is not "loopback", the gateway crashes on startup with an unrecoverable error. Combined with Restart=always in systemd, this causes an infinite crash loop (170+ restarts observed in production). Add cross-field validation in config.set, config.patch, and config.apply handlers to reject incompatible tailscale/bind combinations before writing the config file, preventing the crash loop entirely. Closes #35113 Co-Authored-By: Claude Opus 4.6 --- src/gateway/server-methods/config.ts | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index a0c9cad1955..a252efada1d 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -243,6 +243,20 @@ 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. + */ +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; + 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)) { @@ -270,6 +284,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, @@ -355,6 +374,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( @@ -415,6 +439,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( From 327f9158fa0b4f1b77a663c57ac0efd69b69c85d Mon Sep 17 00:00:00 2001 From: Aaron Aronchick Date: Thu, 5 Mar 2026 00:45:19 +0000 Subject: [PATCH 2/6] test: add tailscale/bind compatibility tests Cover the runtime validation that rejects tailscale serve/funnel when bind is not loopback, and verify the happy path (serve + loopback). Co-Authored-By: Claude Opus 4.6 --- src/gateway/server-runtime-config.test.ts | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 34cc4632670..86c62f9698b 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -232,6 +232,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", () => { it("resolves strict transport security header from config", async () => { const result = await resolveGatewayRuntimeConfig({ From 59ec3924d2b0703eef1c2e7b35f38e17c4d892ae Mon Sep 17 00:00:00 2001 From: Aaron Aronchick Date: Thu, 5 Mar 2026 01:01:56 +0000 Subject: [PATCH 3/6] fix: sort imports in i18n registry test to pass oxfmt --- src/i18n/registry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/registry.test.ts b/src/i18n/registry.test.ts index a2fa23a0d0b..c59ae03fa9a 100644 --- a/src/i18n/registry.test.ts +++ b/src/i18n/registry.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; -import type { TranslationMap } from "../../ui/src/i18n/lib/types.ts"; import { DEFAULT_LOCALE, SUPPORTED_LOCALES, loadLazyLocaleTranslation, resolveNavigatorLocale, } from "../../ui/src/i18n/lib/registry.ts"; +import type { TranslationMap } from "../../ui/src/i18n/lib/types.ts"; function getNestedTranslation(map: TranslationMap | null, ...path: string[]): string | undefined { let value: string | TranslationMap | undefined = map ?? undefined; From ece6dccd5014b7c13d21d2944fdd5bec1d6f033d Mon Sep 17 00:00:00 2001 From: Aaron Aronchick Date: Thu, 5 Mar 2026 01:17:53 +0000 Subject: [PATCH 4/6] fix: add braces to satisfy eslint curly rule Co-Authored-By: Claude Opus 4.6 --- src/gateway/server-methods/config.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index a252efada1d..1623a148509 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -251,9 +251,13 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { */ function validateTailscaleBindCompat(config: OpenClawConfig): string | null { const tailscaleMode = config.gateway?.tailscale?.mode; - if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") return null; + if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") { + return null; + } const bind = config.gateway?.bind ?? "loopback"; - if (bind === "loopback") return null; + if (bind === "loopback") { + 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".`; } From 45a525d374de39aeabe525d0a8a4e1cb07354d10 Mon Sep 17 00:00:00 2001 From: Aaron Aronchick Date: Fri, 6 Mar 2026 06:23:35 +0000 Subject: [PATCH 5/6] fix(config): allow custom bind with loopback IP for tailscale compat validator --- src/gateway/server-methods/config.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 1623a148509..b4ac8c32d32 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,4 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { isLoopbackHost } from "../net.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { CONFIG_PATH, @@ -249,7 +250,7 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { * which defaults to "loopback"). Rejecting at config-write time prevents the * gateway from entering an unrecoverable crash loop on next restart. */ -function validateTailscaleBindCompat(config: OpenClawConfig): string | null { +export function validateTailscaleBindCompat(config: OpenClawConfig): string | null { const tailscaleMode = config.gateway?.tailscale?.mode; if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") { return null; @@ -258,6 +259,15 @@ function validateTailscaleBindCompat(config: OpenClawConfig): string | null { 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".`; } From e69215f2a58da3f4864fd1003762cac159bee772 Mon Sep 17 00:00:00 2001 From: Aaron Aronchick Date: Fri, 6 Mar 2026 06:23:38 +0000 Subject: [PATCH 6/6] test(config): add validateTailscaleBindCompat unit tests covering all three handler paths --- .../config.tailscale-compat.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/gateway/server-methods/config.tailscale-compat.test.ts 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(); + }); +});