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 <noreply@anthropic.com>
This commit is contained in:
Aaron Aronchick 2026-03-05 00:45:14 +00:00
parent 809f9513ac
commit b521b02d57

View File

@ -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(