mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 13:27:27 +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:
@@ -127,6 +127,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Control UI API routing: when `gateway.controlUi.basePath` is unset (default), stop serving Control UI SPA HTML for `/api` and `/api/*` so API paths fall through to normal gateway handlers/404 responses instead of `index.html`. (#30333) Fixes #30295. thanks @Sid-Qin.
|
||||
- Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks @hugenshen.
|
||||
- Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks @MoerAI.
|
||||
- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #28960 by @Glucksberg. Thanks @Glucksberg.
|
||||
- Gateway/Upgrade migration for Control UI origins: seed `gateway.controlUi.allowedOrigins` on startup for legacy non-loopback configs (`lan`/`tailnet`/`custom`) when origins are missing or blank, preventing post-upgrade crash loops while preserving explicit existing policy. Landed from contributor PR #29394 by @synchronic1. Thanks @synchronic1.
|
||||
- Gateway/Plugin HTTP auth hardening: require gateway auth for protected plugin paths and explicit `registerHttpRoute` paths (while preserving wildcard-handler behavior for signature-auth webhooks), and run plugin handlers after built-in handlers for deterministic route precedence. Landed from contributor PR #29198 by @Mariana-Codebase. Thanks @Mariana-Codebase.
|
||||
- Gateway/Config patch guard: reject `config.patch` updates that set non-loopback `gateway.bind` while `gateway.tailscale.mode` is `serve`/`funnel`, preventing restart crash loops from invalid bind/tailscale combinations. Landed from contributor PR #30910 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
|
||||
- Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97.
|
||||
- Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97.
|
||||
- File tools/tilde paths: expand `~/...` against the user home directory before workspace-root checks in host file read/write/edit paths, while preserving root-boundary enforcement so outside-root targets remain blocked. (#29779) Thanks @Glucksberg.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
79
src/config/config.gateway-tailscale-bind.test.ts
Normal file
79
src/config/config.gateway-tailscale-bind.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("gateway tailscale bind validation", () => {
|
||||
it("accepts loopback bind when tailscale serve/funnel is enabled", () => {
|
||||
const serveRes = validateConfigObject({
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
tailscale: { mode: "serve" },
|
||||
},
|
||||
});
|
||||
expect(serveRes.ok).toBe(true);
|
||||
|
||||
const funnelRes = validateConfigObject({
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
tailscale: { mode: "funnel" },
|
||||
},
|
||||
});
|
||||
expect(funnelRes.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts custom loopback bind host with tailscale serve/funnel", () => {
|
||||
const res = validateConfigObject({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "127.0.0.1",
|
||||
tailscale: { mode: "serve" },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects IPv6 custom bind host for tailscale serve/funnel", () => {
|
||||
const res = validateConfigObject({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "::1",
|
||||
tailscale: { mode: "serve" },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((issue) => issue.path === "gateway.bind")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-loopback bind when tailscale serve/funnel is enabled", () => {
|
||||
const lanRes = validateConfigObject({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
tailscale: { mode: "serve" },
|
||||
},
|
||||
});
|
||||
expect(lanRes.ok).toBe(false);
|
||||
if (!lanRes.ok) {
|
||||
expect(lanRes.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "gateway.bind",
|
||||
message: expect.stringContaining("gateway.bind must resolve to loopback"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const customRes = validateConfigObject({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "10.0.0.5",
|
||||
tailscale: { mode: "funnel" },
|
||||
},
|
||||
});
|
||||
expect(customRes.ok).toBe(false);
|
||||
if (!customRes.ok) {
|
||||
expect(customRes.issues.some((issue) => issue.path === "gateway.bind")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -365,7 +365,11 @@ describe("legacy config detection", () => {
|
||||
gateway: { bind: "tailnet" as const },
|
||||
});
|
||||
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
|
||||
expect(res.config).toBeNull();
|
||||
expect(res.config?.gateway?.bind).toBe("tailnet");
|
||||
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
||||
"http://localhost:18789",
|
||||
"http://127.0.0.1:18789",
|
||||
]);
|
||||
|
||||
const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } });
|
||||
expect(validated.ok).toBe(true);
|
||||
|
||||
@@ -104,3 +104,113 @@ describe("legacy migrate mention routing", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => {
|
||||
it("seeds allowedOrigins for bind=lan with no existing controlUi config", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
||||
"http://localhost:18789",
|
||||
"http://127.0.0.1:18789",
|
||||
]);
|
||||
expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true);
|
||||
expect(res.changes.some((c) => c.includes("bind=lan"))).toBe(true);
|
||||
});
|
||||
|
||||
it("seeds allowedOrigins using configured port", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
port: 9000,
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
||||
"http://localhost:9000",
|
||||
"http://127.0.0.1:9000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds allowedOrigins including custom bind host for bind=custom", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "192.168.1.100",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://192.168.1.100:18789");
|
||||
expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://localhost:18789");
|
||||
});
|
||||
|
||||
it("does not overwrite existing allowedOrigins — returns null (no migration needed)", () => {
|
||||
// When allowedOrigins already exists, the migration is a no-op.
|
||||
// applyLegacyMigrations returns next=null when changes.length===0, so config is null.
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
controlUi: { allowedOrigins: ["https://control.example.com"] },
|
||||
},
|
||||
});
|
||||
expect(res.config).toBeNull();
|
||||
expect(res.changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not migrate when dangerouslyAllowHostHeaderOriginFallback is set — returns null", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
controlUi: { dangerouslyAllowHostHeaderOriginFallback: true },
|
||||
},
|
||||
});
|
||||
expect(res.config).toBeNull();
|
||||
expect(res.changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("seeds allowedOrigins when existing entries are blank strings", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
controlUi: { allowedOrigins: ["", " "] },
|
||||
},
|
||||
});
|
||||
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
||||
"http://localhost:18789",
|
||||
"http://127.0.0.1:18789",
|
||||
]);
|
||||
expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not migrate loopback bind — returns null", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
expect(res.config).toBeNull();
|
||||
expect(res.changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves existing controlUi fields when seeding allowedOrigins", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
controlUi: { basePath: "/app" },
|
||||
},
|
||||
});
|
||||
expect(res.config?.gateway?.controlUi?.basePath).toBe("/app");
|
||||
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
|
||||
"http://localhost:18789",
|
||||
"http://127.0.0.1:18789",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,61 @@ import {
|
||||
mergeMissing,
|
||||
resolveDefaultAgentIdFromRaw,
|
||||
} from "./legacy.shared.js";
|
||||
import { DEFAULT_GATEWAY_PORT } from "./paths.js";
|
||||
|
||||
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
|
||||
|
||||
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
|
||||
|
||||
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
{
|
||||
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
|
||||
// host-header fallback flag) for any non-loopback bind. The onboarding wizard was updated
|
||||
// to seed this for new installs, but existing bind=lan/bind=custom installs that upgrade
|
||||
// crash-loop immediately on next startup with no recovery path (issue #29385).
|
||||
//
|
||||
// This migration runs on every gateway start via migrateLegacyConfig → applyLegacyMigrations
|
||||
// and writes the seeded origins to disk before the startup guard fires, preventing the loop.
|
||||
id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback",
|
||||
describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs",
|
||||
apply: (raw, changes) => {
|
||||
const gateway = getRecord(raw.gateway);
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
const bind = gateway.bind;
|
||||
if (bind !== "lan" && bind !== "tailnet" && bind !== "custom") {
|
||||
return;
|
||||
}
|
||||
const controlUi = getRecord(gateway.controlUi) ?? {};
|
||||
const existingOrigins = controlUi.allowedOrigins;
|
||||
const hasConfiguredOrigins =
|
||||
Array.isArray(existingOrigins) &&
|
||||
existingOrigins.some((origin) => typeof origin === "string" && origin.trim().length > 0);
|
||||
if (hasConfiguredOrigins) {
|
||||
return; // already configured
|
||||
}
|
||||
if (controlUi.dangerouslyAllowHostHeaderOriginFallback === true) {
|
||||
return; // already opted into fallback
|
||||
}
|
||||
const port =
|
||||
typeof gateway.port === "number" && gateway.port > 0 ? gateway.port : DEFAULT_GATEWAY_PORT;
|
||||
const origins = new Set<string>([`http://localhost:${port}`, `http://127.0.0.1:${port}`]);
|
||||
if (
|
||||
bind === "custom" &&
|
||||
typeof gateway.customBindHost === "string" &&
|
||||
gateway.customBindHost.trim()
|
||||
) {
|
||||
origins.add(`http://${gateway.customBindHost.trim()}:${port}`);
|
||||
}
|
||||
gateway.controlUi = { ...controlUi, allowedOrigins: [...origins] };
|
||||
raw.gateway = gateway;
|
||||
changes.push(
|
||||
`Seeded gateway.controlUi.allowedOrigins ${JSON.stringify([...origins])} for bind=${String(bind)}. ` +
|
||||
"Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memorySearch->agents.defaults.memorySearch",
|
||||
describe: "Move top-level memorySearch to agents.defaults.memorySearch",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
isPathWithinRoot,
|
||||
isWindowsAbsolutePath,
|
||||
} from "../shared/avatar-policy.js";
|
||||
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
|
||||
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
|
||||
@@ -80,6 +81,33 @@ function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[]
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationIssue[] {
|
||||
const tailscaleMode = config.gateway?.tailscale?.mode ?? "off";
|
||||
if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") {
|
||||
return [];
|
||||
}
|
||||
const bindMode = config.gateway?.bind ?? "loopback";
|
||||
if (bindMode === "loopback") {
|
||||
return [];
|
||||
}
|
||||
const customBindHost = config.gateway?.customBindHost;
|
||||
if (
|
||||
bindMode === "custom" &&
|
||||
isCanonicalDottedDecimalIPv4(customBindHost) &&
|
||||
isLoopbackIpAddress(customBindHost)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
path: "gateway.bind",
|
||||
message:
|
||||
`gateway.bind must resolve to loopback when gateway.tailscale.mode=${tailscaleMode} ` +
|
||||
'(use gateway.bind="loopback" or gateway.bind="custom" with gateway.customBindHost="127.0.0.1")',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates config without applying runtime defaults.
|
||||
* Use this when you need the raw validated config (e.g., for writing back to file).
|
||||
@@ -123,6 +151,10 @@ export function validateConfigObjectRaw(
|
||||
if (avatarIssues.length > 0) {
|
||||
return { ok: false, issues: avatarIssues };
|
||||
}
|
||||
const gatewayTailscaleBindIssues = validateGatewayTailscaleBind(validated.data as OpenClawConfig);
|
||||
if (gatewayTailscaleBindIssues.length > 0) {
|
||||
return { ok: false, issues: gatewayTailscaleBindIssues };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: validated.data as OpenClawConfig,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isIpv6Address, parseCanonicalIpAddress } from "../shared/net/ip.js";
|
||||
|
||||
export const TAILSCALE_EXPOSURE_OPTIONS = [
|
||||
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
|
||||
{
|
||||
@@ -25,3 +27,36 @@ export const TAILSCALE_DOCS_LINES = [
|
||||
"https://docs.openclaw.ai/gateway/tailscale",
|
||||
"https://docs.openclaw.ai/web",
|
||||
] as const;
|
||||
|
||||
function normalizeTailnetHostForUrl(rawHost: string): string | null {
|
||||
const trimmed = rawHost.trim().replace(/\.$/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseCanonicalIpAddress(trimmed);
|
||||
if (parsed && isIpv6Address(parsed)) {
|
||||
return `[${parsed.toString().toLowerCase()}]`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function buildTailnetHttpsOrigin(rawHost: string): string | null {
|
||||
const normalizedHost = normalizeTailnetHostForUrl(rawHost);
|
||||
if (!normalizedHost) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(`https://${normalizedHost}`).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function appendAllowedOrigin(existing: string[] | undefined, origin: string): string[] {
|
||||
const current = existing ?? [];
|
||||
const normalized = origin.toLowerCase();
|
||||
if (current.some((entry) => entry.toLowerCase() === normalized)) {
|
||||
return current;
|
||||
}
|
||||
return [...current, origin];
|
||||
}
|
||||
|
||||
@@ -172,7 +172,6 @@ async function authorizeCanvasRequest(params: {
|
||||
}
|
||||
|
||||
async function enforcePluginRouteGatewayAuth(params: {
|
||||
requestPath: string;
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
auth: ResolvedGatewayAuth;
|
||||
@@ -180,9 +179,6 @@ async function enforcePluginRouteGatewayAuth(params: {
|
||||
allowRealIpFallback: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
}): Promise<boolean> {
|
||||
if (!isProtectedPluginRoutePath(params.requestPath)) {
|
||||
return true;
|
||||
}
|
||||
const token = getBearerToken(params.req);
|
||||
const authResult = await authorizeHttpGatewayConnect({
|
||||
auth: params.auth,
|
||||
@@ -460,6 +456,7 @@ export function createGatewayHttpServer(opts: {
|
||||
strictTransportSecurityHeader?: string;
|
||||
handleHooksRequest: HooksRequestHandler;
|
||||
handlePluginRequest?: HooksRequestHandler;
|
||||
shouldEnforcePluginGatewayAuth?: (requestPath: string) => boolean;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
@@ -477,6 +474,7 @@ export function createGatewayHttpServer(opts: {
|
||||
strictTransportSecurityHeader,
|
||||
handleHooksRequest,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth,
|
||||
resolvedAuth,
|
||||
rateLimiter,
|
||||
} = opts;
|
||||
@@ -527,26 +525,6 @@ export function createGatewayHttpServer(opts: {
|
||||
if (await handleSlackHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (handlePluginRequest) {
|
||||
// Protected plugin route prefixes are gateway-auth protected by default.
|
||||
// Non-protected plugin routes remain plugin-owned and must enforce
|
||||
// their own auth when exposing sensitive functionality.
|
||||
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
|
||||
requestPath,
|
||||
req,
|
||||
res,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!pluginAuthOk) {
|
||||
return;
|
||||
}
|
||||
if (await handlePluginRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (openResponsesEnabled) {
|
||||
if (
|
||||
await handleOpenResponsesHttpRequest(req, res, {
|
||||
@@ -615,6 +593,25 @@ export function createGatewayHttpServer(opts: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Plugins run last so built-in gateway routes keep precedence on overlapping paths.
|
||||
if (handlePluginRequest) {
|
||||
if ((shouldEnforcePluginGatewayAuth ?? isProtectedPluginRoutePath)(requestPath)) {
|
||||
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
|
||||
req,
|
||||
res,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!pluginAuthOk) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (await handlePluginRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
import type { HooksConfigResolved } from "./hooks.js";
|
||||
import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js";
|
||||
import { isProtectedPluginRoutePath } from "./security-path.js";
|
||||
import {
|
||||
createGatewayBroadcaster,
|
||||
type GatewayBroadcastFn,
|
||||
@@ -27,7 +28,10 @@ import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-h
|
||||
import type { DedupeEntry } from "./server-shared.js";
|
||||
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
||||
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
||||
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
||||
import {
|
||||
createGatewayPluginRequestHandler,
|
||||
isRegisteredPluginHttpRoutePath,
|
||||
} from "./server/plugins-http.js";
|
||||
import type { GatewayTlsRuntime } from "./server/tls.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
|
||||
@@ -115,6 +119,12 @@ export async function createGatewayRuntimeState(params: {
|
||||
registry: params.pluginRegistry,
|
||||
log: params.logPlugins,
|
||||
});
|
||||
const shouldEnforcePluginGatewayAuth = (requestPath: string): boolean => {
|
||||
if (isProtectedPluginRoutePath(requestPath)) {
|
||||
return true;
|
||||
}
|
||||
return isRegisteredPluginHttpRoutePath(params.pluginRegistry, requestPath);
|
||||
};
|
||||
|
||||
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
|
||||
if (!isLoopbackHost(params.bindHost)) {
|
||||
@@ -138,6 +148,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
strictTransportSecurityHeader: params.strictTransportSecurityHeader,
|
||||
handleHooksRequest,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
rateLimiter: params.rateLimiter,
|
||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
|
||||
@@ -54,6 +54,25 @@ describe("gateway config methods", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("raw must be an object");
|
||||
});
|
||||
|
||||
it("rejects config.patch when tailscale serve/funnel is paired with non-loopback bind", async () => {
|
||||
const res = await rpcReq<{
|
||||
ok?: boolean;
|
||||
error?: { details?: { issues?: Array<{ path?: string }> } };
|
||||
}>(requireWs(), "config.patch", {
|
||||
raw: JSON.stringify({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
tailscale: { mode: "serve" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("invalid config");
|
||||
const issues = (res.error as { details?: { issues?: Array<{ path?: string }> } } | undefined)
|
||||
?.details?.issues;
|
||||
expect(issues?.some((issue) => issue.path === "gateway.bind")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway server sessions", () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { DEFAULT_GATEWAY_PORT } from "../config/paths.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
||||
@@ -377,6 +378,55 @@ export async function startGatewayServer(
|
||||
setPreRestartDeferralCheck(
|
||||
() => getTotalQueueSize() + getTotalPendingReplies() + getActiveEmbeddedRunCount(),
|
||||
);
|
||||
// Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing
|
||||
// bind=lan/custom installs that upgraded to v2026.2.26+ without the required origins set.
|
||||
// This runs regardless of whether legacy-key issues exist — the affected config is
|
||||
// schema-valid (no legacy keys), so it is never caught by the legacyIssues gate above.
|
||||
// Without this guard the gateway would proceed to resolveGatewayRuntimeConfig and throw,
|
||||
// causing a systemd crash-loop with no recovery path (issue #29385).
|
||||
const controlUiBind = cfgAtStart.gateway?.bind;
|
||||
const isNonLoopbackBind =
|
||||
controlUiBind === "lan" || controlUiBind === "tailnet" || controlUiBind === "custom";
|
||||
const hasControlUiOrigins = (cfgAtStart.gateway?.controlUi?.allowedOrigins ?? []).some(
|
||||
(origin) => typeof origin === "string" && origin.trim().length > 0,
|
||||
);
|
||||
const hasControlUiFallback =
|
||||
cfgAtStart.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
|
||||
if (isNonLoopbackBind && !hasControlUiOrigins && !hasControlUiFallback) {
|
||||
const bindPort =
|
||||
typeof cfgAtStart.gateway?.port === "number" && cfgAtStart.gateway.port > 0
|
||||
? cfgAtStart.gateway.port
|
||||
: DEFAULT_GATEWAY_PORT;
|
||||
const seededOrigins = new Set<string>([
|
||||
`http://localhost:${bindPort}`,
|
||||
`http://127.0.0.1:${bindPort}`,
|
||||
]);
|
||||
const customBindHost = cfgAtStart.gateway?.customBindHost?.trim();
|
||||
if (controlUiBind === "custom" && customBindHost) {
|
||||
seededOrigins.add(`http://${customBindHost}:${bindPort}`);
|
||||
}
|
||||
cfgAtStart = {
|
||||
...cfgAtStart,
|
||||
gateway: {
|
||||
...cfgAtStart.gateway,
|
||||
controlUi: {
|
||||
...cfgAtStart.gateway?.controlUi,
|
||||
allowedOrigins: [...seededOrigins],
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await writeConfigFile(cfgAtStart);
|
||||
log.info(
|
||||
`gateway: seeded gateway.controlUi.allowedOrigins ${JSON.stringify([...seededOrigins])} for bind=${controlUiBind} (required since v2026.2.26; see issue #29385). Add other origins to gateway.controlUi.allowedOrigins if needed.`,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
initSubagentRegistry();
|
||||
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
||||
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import type { HooksConfigResolved } from "./hooks.js";
|
||||
import { canonicalizePathVariant } from "./security-path.js";
|
||||
import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js";
|
||||
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
|
||||
import { withTempConfig } from "./test-temp-config.js";
|
||||
|
||||
@@ -243,7 +243,7 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => {
|
||||
test("requires gateway auth for protected plugin route space and allows authenticated pass-through", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
@@ -287,6 +287,8 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (requestPath) =>
|
||||
isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public",
|
||||
resolvedAuth,
|
||||
});
|
||||
|
||||
@@ -328,10 +330,168 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
createRequest({ path: "/plugin/public" }),
|
||||
unauthenticatedPublic.res,
|
||||
);
|
||||
expect(unauthenticatedPublic.res.statusCode).toBe(200);
|
||||
expect(unauthenticatedPublic.getBody()).toContain('"route":"public"');
|
||||
expect(unauthenticatedPublic.res.statusCode).toBe(401);
|
||||
expect(unauthenticatedPublic.getBody()).toContain("Unauthorized");
|
||||
|
||||
expect(handlePluginRequest).toHaveBeenCalledTimes(2);
|
||||
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps wildcard plugin handlers ungated when auth enforcement predicate excludes their paths", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
await withTempConfig({
|
||||
cfg: { gateway: { trustedProxies: [] } },
|
||||
prefix: "openclaw-plugin-http-auth-wildcard-handler-test-",
|
||||
run: async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/plugin/routed") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "routed" }));
|
||||
return true;
|
||||
}
|
||||
if (pathname === "/googlechat") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "wildcard-handler" }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const server = createGatewayHttpServer({
|
||||
canvasHost: null,
|
||||
clients: new Set(),
|
||||
controlUiEnabled: false,
|
||||
controlUiBasePath: "/__control__",
|
||||
openAiChatCompletionsEnabled: false,
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (requestPath) =>
|
||||
requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed",
|
||||
resolvedAuth,
|
||||
});
|
||||
|
||||
const unauthenticatedRouted = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/plugin/routed" }),
|
||||
unauthenticatedRouted.res,
|
||||
);
|
||||
expect(unauthenticatedRouted.res.statusCode).toBe(401);
|
||||
expect(unauthenticatedRouted.getBody()).toContain("Unauthorized");
|
||||
|
||||
const unauthenticatedWildcard = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/googlechat" }),
|
||||
unauthenticatedWildcard.res,
|
||||
);
|
||||
expect(unauthenticatedWildcard.res.statusCode).toBe(200);
|
||||
expect(unauthenticatedWildcard.getBody()).toContain('"route":"wildcard-handler"');
|
||||
|
||||
const authenticatedRouted = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({
|
||||
path: "/plugin/routed",
|
||||
authorization: "Bearer test-token",
|
||||
}),
|
||||
authenticatedRouted.res,
|
||||
);
|
||||
expect(authenticatedRouted.res.statusCode).toBe(200);
|
||||
expect(authenticatedRouted.getBody()).toContain('"route":"routed"');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("uses /api/channels auth by default while keeping wildcard handlers ungated with no predicate", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
await withTempConfig({
|
||||
cfg: { gateway: { trustedProxies: [] } },
|
||||
prefix: "openclaw-plugin-http-auth-wildcard-default-test-",
|
||||
run: async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/api/channels/nostr/default/profile") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "channel-default" }));
|
||||
return true;
|
||||
}
|
||||
if (pathname === "/googlechat") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "wildcard-default" }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const server = createGatewayHttpServer({
|
||||
canvasHost: null,
|
||||
clients: new Set(),
|
||||
controlUiEnabled: false,
|
||||
controlUiBasePath: "/__control__",
|
||||
openAiChatCompletionsEnabled: false,
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
handlePluginRequest,
|
||||
resolvedAuth,
|
||||
});
|
||||
|
||||
const unauthenticated = createResponse();
|
||||
await dispatchRequest(server, createRequest({ path: "/googlechat" }), unauthenticated.res);
|
||||
expect(unauthenticated.res.statusCode).toBe(200);
|
||||
expect(unauthenticated.getBody()).toContain('"route":"wildcard-default"');
|
||||
|
||||
const unauthenticatedChannel = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/api/channels/nostr/default/profile" }),
|
||||
unauthenticatedChannel.res,
|
||||
);
|
||||
expect(unauthenticatedChannel.res.statusCode).toBe(401);
|
||||
expect(unauthenticatedChannel.getBody()).toContain("Unauthorized");
|
||||
|
||||
const authenticated = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({
|
||||
path: "/googlechat",
|
||||
authorization: "Bearer test-token",
|
||||
}),
|
||||
authenticated.res,
|
||||
);
|
||||
expect(authenticated.res.statusCode).toBe(200);
|
||||
expect(authenticated.getBody()).toContain('"route":"wildcard-default"');
|
||||
|
||||
const authenticatedChannel = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({
|
||||
path: "/api/channels/nostr/default/profile",
|
||||
authorization: "Bearer test-token",
|
||||
}),
|
||||
authenticatedChannel.res,
|
||||
);
|
||||
expect(authenticatedChannel.res.statusCode).toBe(200);
|
||||
expect(authenticatedChannel.getBody()).toContain('"route":"channel-default"');
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -369,6 +529,7 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
|
||||
resolvedAuth,
|
||||
});
|
||||
|
||||
@@ -418,6 +579,7 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
|
||||
resolvedAuth,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { makeMockHttpResponse } from "../test-http-response.js";
|
||||
import { createTestRegistry } from "./__tests__/test-utils.js";
|
||||
import { createGatewayPluginRequestHandler } from "./plugins-http.js";
|
||||
import {
|
||||
createGatewayPluginRequestHandler,
|
||||
isRegisteredPluginHttpRoutePath,
|
||||
} from "./plugins-http.js";
|
||||
|
||||
describe("createGatewayPluginRequestHandler", () => {
|
||||
it("returns false when no handlers are registered", async () => {
|
||||
@@ -97,3 +100,36 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
expect(end).toHaveBeenCalledWith("Internal Server Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin HTTP registry helpers", () => {
|
||||
it("detects registered route paths", () => {
|
||||
const registry = createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "route",
|
||||
path: "/demo",
|
||||
handler: () => {},
|
||||
source: "route",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true);
|
||||
expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches canonicalized variants of registered route paths", () => {
|
||||
const registry = createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "route",
|
||||
path: "/api/demo",
|
||||
handler: () => {},
|
||||
source: "route",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true);
|
||||
expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true);
|
||||
expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { PluginRegistry } from "../../plugins/registry.js";
|
||||
import { canonicalizePathVariant } from "../security-path.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
@@ -9,6 +10,18 @@ export type PluginHttpRequestHandler = (
|
||||
res: ServerResponse,
|
||||
) => Promise<boolean>;
|
||||
|
||||
// Only checks specific routes registered via registerHttpRoute, not wildcard handlers
|
||||
// registered via registerHttpHandler. Wildcard handlers (e.g., webhooks) implement
|
||||
// their own signature-based auth and are handled separately in the auth enforcement logic.
|
||||
export function isRegisteredPluginHttpRoutePath(
|
||||
registry: PluginRegistry,
|
||||
pathname: string,
|
||||
): boolean {
|
||||
const canonicalPath = canonicalizePathVariant(pathname);
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
return routes.some((entry) => canonicalizePathVariant(entry.path) === canonicalPath);
|
||||
}
|
||||
|
||||
export function createGatewayPluginRequestHandler(params: {
|
||||
registry: PluginRegistry;
|
||||
log: SubsystemLogger;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WizardPrompter, WizardSelectParams } from "./prompts.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
randomToken: vi.fn(),
|
||||
getTailnetHostname: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", async (importActual) => {
|
||||
@@ -17,6 +18,7 @@ vi.mock("../commands/onboard-helpers.js", async (importActual) => {
|
||||
|
||||
vi.mock("../infra/tailscale.js", () => ({
|
||||
findTailscaleBinary: vi.fn(async () => undefined),
|
||||
getTailnetHostname: mocks.getTailnetHostname,
|
||||
}));
|
||||
|
||||
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
||||
@@ -136,4 +138,110 @@ describe("configureGatewayForOnboarding", () => {
|
||||
"http://127.0.0.1:18789",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
||||
"https://my-host.tail1234.ts.net",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined();
|
||||
});
|
||||
|
||||
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99");
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
||||
"https://[fd7a:115c:a1e0::99]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||
|
||||
const prompter = createPrompter({
|
||||
selectQueue: ["loopback", "token", "serve"],
|
||||
textQueue: ["18789", undefined],
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureGatewayForOnboarding({
|
||||
flow: "advanced",
|
||||
baseConfig: {},
|
||||
nextConfig: {
|
||||
gateway: {
|
||||
controlUi: {
|
||||
allowedOrigins: ["HTTPS://MY-HOST.TAIL1234.TS.NET"],
|
||||
},
|
||||
},
|
||||
},
|
||||
localPort: 18789,
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? [];
|
||||
const tsOriginCount = origins.filter(
|
||||
(origin) => origin.toLowerCase() === "https://my-host.tail1234.ts.net",
|
||||
).length;
|
||||
expect(tsOriginCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
import type { GatewayAuthChoice } from "../commands/onboard-types.js";
|
||||
import type { GatewayBindMode, GatewayTailscaleMode, OpenClawConfig } 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 type {
|
||||
@@ -137,8 +139,10 @@ export async function configureGatewayForOnboarding(
|
||||
});
|
||||
|
||||
// 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) {
|
||||
await prompter.note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning");
|
||||
}
|
||||
@@ -253,6 +257,28 @@ export async function configureGatewayForOnboarding(
|
||||
};
|
||||
}
|
||||
|
||||
// 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 = nextConfig.gateway?.controlUi?.allowedOrigins ?? [];
|
||||
const updatedOrigins = appendAllowedOrigin(existing, tsOrigin);
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
controlUi: {
|
||||
...nextConfig.gateway?.controlUi,
|
||||
allowedOrigins: updatedOrigins,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a new gateway setup (no existing gateway settings), start with a
|
||||
// denylist for high-risk node commands. Users can arm these temporarily via
|
||||
// /phone arm ... (phone-control plugin).
|
||||
|
||||
Reference in New Issue
Block a user