Merge e69215f2a58da3f4864fd1003762cac159bee772 into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
David Aronchick 2026-03-21 06:01:37 +03:00 committed by GitHub
commit eea3f8d648
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 182 additions and 0 deletions

View File

@ -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["gateway"]>): 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();
});
});

View File

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

View File

@ -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 = [
{