mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 20:34:31 +00:00
fix(gateway): land access/auth/config migration cluster
Land #28960 by @Glucksberg (Tailscale origin auto-allowlist). Land #29394 by @synchronic1 (allowedOrigins upgrade migration). Land #29198 by @Mariana-Codebase (plugin HTTP auth guard + route precedence). Land #30910 by @liuxiaopai-ai (tailscale bind/config.patch guard). Co-authored-by: Glucksberg <markuscontasul@gmail.com> Co-authored-by: synchronic1 <synchronic1@users.noreply.github.com> Co-authored-by: Mariana Sinisterra <mariana.data@outlook.com> Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
buildGatewayAuthConfig: vi.fn(),
|
||||
note: vi.fn(),
|
||||
randomToken: vi.fn(),
|
||||
getTailnetHostname: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importActual) => {
|
||||
@@ -35,6 +37,7 @@ vi.mock("./configure.gateway-auth.js", () => ({
|
||||
|
||||
vi.mock("../infra/tailscale.js", () => ({
|
||||
findTailscaleBinary: vi.fn(async () => undefined),
|
||||
getTailnetHostname: mocks.getTailnetHostname,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", async (importActual) => {
|
||||
@@ -58,6 +61,7 @@ function makeRuntime(): RuntimeEnv {
|
||||
async function runGatewayPrompt(params: {
|
||||
selectQueue: string[];
|
||||
textQueue: Array<string | undefined>;
|
||||
baseConfig?: OpenClawConfig;
|
||||
randomToken?: string;
|
||||
confirmResult?: boolean;
|
||||
authConfigFactory?: (input: Record<string, unknown>) => Record<string, unknown>;
|
||||
@@ -72,7 +76,7 @@ async function runGatewayPrompt(params: {
|
||||
params.authConfigFactory ? params.authConfigFactory(input as Record<string, unknown>) : input,
|
||||
);
|
||||
|
||||
const result = await promptGatewayConfig({}, makeRuntime());
|
||||
const result = await promptGatewayConfig(params.baseConfig ?? {}, makeRuntime());
|
||||
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
|
||||
return { result, call };
|
||||
}
|
||||
@@ -154,4 +158,78 @@ describe("promptGatewayConfig", () => {
|
||||
expect(result.config.gateway?.tailscale?.mode).toBe("off");
|
||||
expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false);
|
||||
});
|
||||
|
||||
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
|
||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||
const { result } = await runGatewayPrompt({
|
||||
// bind=loopback, auth=token, tailscale=serve
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", "my-token"],
|
||||
confirmResult: true,
|
||||
authConfigFactory: ({ mode, token }) => ({ mode, token }),
|
||||
});
|
||||
expect(result.config.gateway?.controlUi?.allowedOrigins).toContain(
|
||||
"https://my-host.tail1234.ts.net",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale funnel is enabled", async () => {
|
||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||
const { result } = await runGatewayPrompt({
|
||||
// bind=loopback, auth=password (funnel requires password), tailscale=funnel
|
||||
selectQueue: ["loopback", "password", "funnel"],
|
||||
textQueue: ["18789", "my-password"],
|
||||
confirmResult: true,
|
||||
authConfigFactory: ({ mode, password }) => ({ mode, password }),
|
||||
});
|
||||
expect(result.config.gateway?.controlUi?.allowedOrigins).toContain(
|
||||
"https://my-host.tail1234.ts.net",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
|
||||
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
|
||||
const { result } = await runGatewayPrompt({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", "my-token"],
|
||||
confirmResult: true,
|
||||
authConfigFactory: ({ mode, token }) => ({ mode, token }),
|
||||
});
|
||||
expect(result.config.gateway?.controlUi?.allowedOrigins).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not duplicate Tailscale origin if already present", async () => {
|
||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||
const { result } = await runGatewayPrompt({
|
||||
baseConfig: {
|
||||
gateway: {
|
||||
controlUi: {
|
||||
allowedOrigins: ["HTTPS://MY-HOST.TAIL1234.TS.NET"],
|
||||
},
|
||||
},
|
||||
},
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", "my-token"],
|
||||
confirmResult: true,
|
||||
authConfigFactory: ({ mode, token }) => ({ mode, token }),
|
||||
});
|
||||
const origins = result.config.gateway?.controlUi?.allowedOrigins ?? [];
|
||||
const tsOriginCount = origins.filter(
|
||||
(origin) => origin.toLowerCase() === "https://my-host.tail1234.ts.net",
|
||||
).length;
|
||||
expect(tsOriginCount).toBe(1);
|
||||
});
|
||||
|
||||
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
|
||||
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::12");
|
||||
const { result } = await runGatewayPrompt({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", "my-token"],
|
||||
confirmResult: true,
|
||||
authConfigFactory: ({ mode, token }) => ({ mode, token }),
|
||||
});
|
||||
expect(result.config.gateway?.controlUi?.allowedOrigins).toContain(
|
||||
"https://[fd7a:115c:a1e0::12]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import {
|
||||
appendAllowedOrigin,
|
||||
buildTailnetHttpsOrigin,
|
||||
TAILSCALE_DOCS_LINES,
|
||||
TAILSCALE_EXPOSURE_OPTIONS,
|
||||
TAILSCALE_MISSING_BIN_NOTE_LINES,
|
||||
} from "../gateway/gateway-config-prompts.shared.js";
|
||||
import { findTailscaleBinary } from "../infra/tailscale.js";
|
||||
import { findTailscaleBinary, getTailnetHostname } from "../infra/tailscale.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
@@ -111,8 +113,10 @@ export async function promptGatewayConfig(
|
||||
);
|
||||
|
||||
// Detect Tailscale binary before proceeding with serve/funnel setup.
|
||||
// Persist the path so getTailnetHostname can reuse it for origin injection.
|
||||
let tailscaleBin: string | null = null;
|
||||
if (tailscaleMode !== "off") {
|
||||
const tailscaleBin = await findTailscaleBinary();
|
||||
tailscaleBin = await findTailscaleBinary();
|
||||
if (!tailscaleBin) {
|
||||
note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning");
|
||||
}
|
||||
@@ -285,5 +289,27 @@ export async function promptGatewayConfig(
|
||||
},
|
||||
};
|
||||
|
||||
// Auto-add Tailscale origin to controlUi.allowedOrigins so the Control UI
|
||||
// is accessible via the Tailscale hostname without manual config.
|
||||
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
|
||||
const tsOrigin = await getTailnetHostname(undefined, tailscaleBin ?? undefined)
|
||||
.then((host) => buildTailnetHttpsOrigin(host))
|
||||
.catch(() => null);
|
||||
if (tsOrigin) {
|
||||
const existing = next.gateway?.controlUi?.allowedOrigins ?? [];
|
||||
const updatedOrigins = appendAllowedOrigin(existing, tsOrigin);
|
||||
next = {
|
||||
...next,
|
||||
gateway: {
|
||||
...next.gateway,
|
||||
controlUi: {
|
||||
...next.gateway?.controlUi,
|
||||
allowedOrigins: updatedOrigins,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { config: next, port, token: gatewayToken };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user