Merge e69215f2a58da3f4864fd1003762cac159bee772 into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
eea3f8d648
87
src/gateway/server-methods/config.tailscale-compat.test.ts
Normal file
87
src/gateway/server-methods/config.tailscale-compat.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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(
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user