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:
Peter Steinberger
2026-03-02 00:05:48 +00:00
parent 8e6b3ade3e
commit 53d10f8688
18 changed files with 876 additions and 37 deletions

View File

@@ -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]",
);
});
});

View File

@@ -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 };
}