From b521b02d57982a11f88645fb08bcfb811b973b0c Mon Sep 17 00:00:00 2001 From: Aaron Aronchick Date: Thu, 5 Mar 2026 00:45:14 +0000 Subject: [PATCH] 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(