diff --git a/CHANGELOG.md b/CHANGELOG.md index b59d9a184a9..7a5a3e4ba58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD. +- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b2c722d8ca7..e5d869b3a05 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -510,6 +510,10 @@ See [Inferred commitments](/concepts/commitments). value, so repeated failures from one localhost origin do not automatically lock out a different origin. - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). +- `tailscale.preserveFunnel`: when `true` and `tailscale.mode = "serve"`, OpenClaw + checks `tailscale funnel status` before re-applying Serve at startup and skips + it if an externally configured Funnel route already covers the gateway port. + Default `false`. - `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins. - `controlUi.chatMessageMaxWidth`: optional max-width for grouped Control UI chat messages. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index a1a3a69240a..93c58ed5270 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -116,6 +116,11 @@ openclaw gateway --tailscale funnel --auth password - `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure. - Set `gateway.tailscale.resetOnExit` if you want OpenClaw to undo `tailscale serve` or `tailscale funnel` configuration on shutdown. +- Set `gateway.tailscale.preserveFunnel: true` to keep an externally configured + `tailscale funnel` route alive across gateway restarts. When enabled and the + gateway runs in `mode: "serve"`, OpenClaw checks `tailscale funnel status` + before re-applying Serve and skips it when a Funnel route already covers the + gateway port. The OpenClaw-managed Funnel password-only policy is unchanged. - `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel). - `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only. - Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index eded1129a48..a8139ab2916 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -109,6 +109,8 @@ export const FIELD_HELP: Record = { 'Tailscale publish mode: "off", "serve", or "funnel" for private or public exposure paths. Use "serve" for tailnet-only access and "funnel" only when public internet reachability is required.', "gateway.tailscale.resetOnExit": "Resets Tailscale Serve/Funnel state on gateway exit to avoid stale published routes after shutdown. Keep enabled unless another controller manages publish lifecycle outside the gateway.", + "gateway.tailscale.preserveFunnel": + "When mode='serve' and an externally configured Tailscale Funnel route already covers the gateway port, skip re-applying tailscale serve on startup. Lets operators keep Funnel exposure managed outside OpenClaw without losing it across gateway restarts.", "gateway.remote": "Remote gateway connection settings for direct or SSH transport when this instance proxies to another runtime host. Use remote mode only when split-host operation is intentionally configured.", "gateway.remote.transport": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 531f818795d..6a24d598275 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -129,6 +129,7 @@ export const FIELD_LABELS: Record = { "gateway.tailscale": "Gateway Tailscale", "gateway.tailscale.mode": "Gateway Tailscale Mode", "gateway.tailscale.resetOnExit": "Gateway Tailscale Reset on Exit", + "gateway.tailscale.preserveFunnel": "Gateway Tailscale Preserve External Funnel", "gateway.remote": "Remote Gateway", "gateway.remote.transport": "Remote Gateway Transport", "gateway.reload": "Config Reload", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 830fd49d795..fdbc89e97a5 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -196,6 +196,13 @@ export type GatewayTailscaleConfig = { mode?: GatewayTailscaleMode; /** Reset serve/funnel configuration on shutdown. */ resetOnExit?: boolean; + /** + * When `mode="serve"` and an externally configured Tailscale Funnel route + * already covers the gateway port, skip re-applying `tailscale serve` on + * startup. Lets operators manage Funnel exposure outside OpenClaw without + * losing it across gateway restarts. + */ + preserveFunnel?: boolean; }; export type GatewayRemoteConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 13326011a66..d39ca664192 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -911,6 +911,7 @@ export const OpenClawSchema = z .object({ mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(), resetOnExit: z.boolean().optional(), + preserveFunnel: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 15e1412f527..7c83a763222 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -916,6 +916,7 @@ function createPostAttachParams(overrides: Partial = {}): Post broadcast: vi.fn(), tailscaleMode: "off", resetOnExit: false, + preserveFunnel: false, controlUiBasePath: "/", logTailscale: { info: vi.fn(), diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 1a14ba681f7..db28e5ced4b 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -673,6 +673,7 @@ export async function startGatewayPostAttachRuntime( broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; tailscaleMode: GatewayTailscaleMode; resetOnExit: boolean; + preserveFunnel: boolean; controlUiBasePath: string; logTailscale: { info: (msg: string) => void; @@ -757,6 +758,7 @@ export async function startGatewayPostAttachRuntime( runtimeDeps.startGatewayTailscaleExposure({ tailscaleMode: params.tailscaleMode, resetOnExit: params.resetOnExit, + preserveFunnel: params.preserveFunnel, port: params.port, controlUiBasePath: params.controlUiBasePath, logTailscale: params.logTailscale, diff --git a/src/gateway/server-tailscale.test.ts b/src/gateway/server-tailscale.test.ts new file mode 100644 index 00000000000..2bb340602f4 --- /dev/null +++ b/src/gateway/server-tailscale.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + enableTailscaleServe: vi.fn(async (_port: number) => undefined), + disableTailscaleServe: vi.fn(async () => undefined), + enableTailscaleFunnel: vi.fn(async (_port: number) => undefined), + disableTailscaleFunnel: vi.fn(async () => undefined), + getTailnetHostname: vi.fn(async () => null), + hasTailscaleFunnelRouteForPort: vi.fn(async (_port: number) => false), +})); + +vi.mock("../infra/tailscale.js", () => ({ + enableTailscaleServe: mocks.enableTailscaleServe, + disableTailscaleServe: mocks.disableTailscaleServe, + enableTailscaleFunnel: mocks.enableTailscaleFunnel, + disableTailscaleFunnel: mocks.disableTailscaleFunnel, + getTailnetHostname: mocks.getTailnetHostname, + hasTailscaleFunnelRouteForPort: mocks.hasTailscaleFunnelRouteForPort, +})); + +import { startGatewayTailscaleExposure } from "./server-tailscale.js"; + +function createLogger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +afterEach(() => { + for (const fn of Object.values(mocks)) { + fn.mockReset(); + } + mocks.enableTailscaleServe.mockResolvedValue(undefined); + mocks.disableTailscaleServe.mockResolvedValue(undefined); + mocks.enableTailscaleFunnel.mockResolvedValue(undefined); + mocks.disableTailscaleFunnel.mockResolvedValue(undefined); + mocks.getTailnetHostname.mockResolvedValue(null); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(false); +}); + +describe("startGatewayTailscaleExposure preserveFunnel", () => { + it("calls enableTailscaleServe in serve mode when preserveFunnel is unset", async () => { + const logTailscale = createLogger(); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + logTailscale, + }); + + expect(mocks.enableTailscaleServe).toHaveBeenCalledWith(18789); + expect(mocks.hasTailscaleFunnelRouteForPort).not.toHaveBeenCalled(); + }); + + it("skips enableTailscaleServe when preserveFunnel is true and a Funnel route covers the port", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(true); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).toHaveBeenCalledWith(18789); + expect(mocks.enableTailscaleServe).not.toHaveBeenCalled(); + expect(logTailscale.info).toHaveBeenCalledWith(expect.stringMatching(/preserv/i)); + }); + + it("notes resetOnExit is a no-op when preserveFunnel skips Serve", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(true); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + resetOnExit: true, + logTailscale, + }); + + expect(mocks.enableTailscaleServe).not.toHaveBeenCalled(); + expect(logTailscale.info).toHaveBeenCalledWith( + expect.stringMatching(/resetOnExit is a no-op/i), + ); + }); + + it("falls back to enableTailscaleServe when preserveFunnel is true but no Funnel route exists for the port", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(false); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).toHaveBeenCalledWith(18789); + expect(mocks.enableTailscaleServe).toHaveBeenCalledWith(18789); + }); + + it("never consults the Funnel route helper when running in funnel mode", async () => { + const logTailscale = createLogger(); + + await startGatewayTailscaleExposure({ + tailscaleMode: "funnel", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).not.toHaveBeenCalled(); + expect(mocks.enableTailscaleFunnel).toHaveBeenCalledWith(18789); + }); +}); diff --git a/src/gateway/server-tailscale.ts b/src/gateway/server-tailscale.ts index 9d09f12c4f9..ebf0a6383c9 100644 --- a/src/gateway/server-tailscale.ts +++ b/src/gateway/server-tailscale.ts @@ -5,12 +5,14 @@ import { enableTailscaleFunnel, enableTailscaleServe, getTailnetHostname, + hasTailscaleFunnelRouteForPort, } from "../infra/tailscale.js"; export async function startGatewayTailscaleExposure(params: { tailscaleMode: "off" | "serve" | "funnel"; resetOnExit?: boolean; port: number; + preserveFunnel?: boolean; controlUiBasePath?: string; logTailscale: { info: (msg: string) => void; warn: (msg: string) => void }; }): Promise<(() => Promise) | null> { @@ -20,6 +22,21 @@ export async function startGatewayTailscaleExposure(params: { try { if (params.tailscaleMode === "serve") { + if (params.preserveFunnel === true) { + const funnelCovers = await hasTailscaleFunnelRouteForPort(params.port); + if (funnelCovers) { + const resetSuffix = params.resetOnExit + ? "; resetOnExit is a no-op because no Serve route was applied this run" + : ""; + params.logTailscale.info( + `serve skipped: preserving externally configured Tailscale Funnel for port ${params.port}${resetSuffix}`, + ); + // Skip the resetOnExit teardown deliberately: the Funnel route is + // owned by an external operator, so we must not run + // disableTailscaleServe on shutdown either. + return null; + } + } await enableTailscaleServe(params.port); } else { await enableTailscaleFunnel(params.port); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 8b3cc5214af..2d5f71d5463 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1393,6 +1393,7 @@ export async function startGatewayServer( broadcast, tailscaleMode, resetOnExit: tailscaleConfig.resetOnExit ?? false, + preserveFunnel: tailscaleConfig.preserveFunnel ?? false, controlUiBasePath, logTailscale, gatewayPluginConfigAtStart, diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 4524cbbbb9e..0470f00d922 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -5,6 +5,7 @@ import { assertGatewayAuthNotKnownWeak, assertHooksTokenSeparateFromGatewayAuth, ensureGatewayStartupAuth, + mergeGatewayTailscaleConfig, } from "./startup-auth.js"; const mocks = vi.hoisted(() => ({ @@ -23,6 +24,17 @@ vi.mock("../config/mutate.js", async () => { }; }); +describe("mergeGatewayTailscaleConfig", () => { + it("preserves explicit preserveFunnel overrides", () => { + expect( + mergeGatewayTailscaleConfig( + { mode: "serve", resetOnExit: false, preserveFunnel: false }, + { preserveFunnel: true }, + ), + ).toEqual({ mode: "serve", resetOnExit: false, preserveFunnel: true }); + }); +}); + describe("ensureGatewayStartupAuth", () => { async function expectEphemeralGeneratedTokenWhenOverridden(cfg: OpenClawConfig) { const result = await ensureGatewayStartupAuth({ diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index 46ee8556f92..b885b653108 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -61,6 +61,9 @@ export function mergeGatewayTailscaleConfig( if (override.resetOnExit !== undefined) { merged.resetOnExit = override.resetOnExit; } + if (override.preserveFunnel !== undefined) { + merged.preserveFunnel = override.preserveFunnel; + } return merged; } diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index 09839a6e881..b7480af1410 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -10,6 +10,7 @@ const { enableTailscaleServe, disableTailscaleServe, ensureFunnel, + tailscaleFunnelStatusCoversPort, } = tailscale; const tailscaleBin = expect.stringMatching(/tailscale$/i); @@ -236,3 +237,92 @@ describe("tailscale helpers", () => { expect(exec).toHaveBeenCalledTimes(2); }); }); + +describe("tailscaleFunnelStatusCoversPort", () => { + function buildFunnelStatus(handlers: Record) { + const host = "device.tailnet.ts.net:443"; + return { + AllowFunnel: { [host]: true }, + Web: { + [host]: { Handlers: handlers }, + }, + } as Record; + } + + it("matches a Funnel route whose Proxy is a full http URL", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches a Proxy URL with a trailing slash", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789/" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches a Proxy URL with a longer path", () => { + const status = buildFunnelStatus({ "/api": { Proxy: "http://127.0.0.1:18789/api" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches the localhost loopback alias", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://localhost:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches an IPv6 loopback Proxy", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://[::1]:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches the documented https+insecure target scheme", () => { + const status = buildFunnelStatus({ + "/": { Proxy: "https+insecure://localhost:18789" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches https+insecure with a trailing path", () => { + const status = buildFunnelStatus({ + "/api": { Proxy: "https+insecure://127.0.0.1:18789/api" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("does not match https+insecure on a non-loopback host", () => { + const status = buildFunnelStatus({ + "/": { Proxy: "https+insecure://10.0.0.5:18789" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("matches a bare port form", () => { + const status = buildFunnelStatus({ "/": { Proxy: "18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("does not match a Proxy on a different port", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:9000" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("does not match a non-loopback host on the right port", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://10.0.0.5:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("ignores Web entries whose host is not in AllowFunnel", () => { + const status = { + AllowFunnel: { "device.tailnet.ts.net:443": false }, + Web: { + "device.tailnet.ts.net:443": { + Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } }, + }, + }, + } as Record; + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("returns false on an empty status payload", () => { + expect(tailscaleFunnelStatusCoversPort({}, 18789)).toBe(false); + }); +}); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 8857ece2f88..60c894c1dd6 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -402,6 +402,97 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec = }); } +export async function hasTailscaleFunnelRouteForPort( + port: number, + exec: typeof runExec = runExec, +): Promise { + try { + const tailscaleBin = await getTailscaleBinary(); + const { stdout } = await exec(tailscaleBin, ["funnel", "status", "--json"], { + maxBuffer: 200_000, + timeoutMs: 5_000, + }); + const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; + return tailscaleFunnelStatusCoversPort(parsed, port); + } catch { + return false; + } +} + +const TAILSCALE_LOOPBACK_PROXY_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +export function tailscaleFunnelStatusCoversPort( + status: Record, + port: number, +): boolean { + for (const proxy of funnelStatusBackendsForPort(status)) { + if (tailscaleProxyMatchesLoopbackPort(proxy, port)) { + return true; + } + } + return false; +} + +function tailscaleProxyMatchesLoopbackPort(proxy: string, port: number): boolean { + // Tailscale stores the Proxy field as a full URL string (e.g. + // "http://127.0.0.1:18789", "http://127.0.0.1:18789/", + // "https+insecure://localhost:18789/api"), or as the bare forms accepted + // by `tailscale funnel/serve` ("localhost:18789", "18789"). Strip any + // RFC 3986 scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) "://") and + // any trailing path before host/port match — covers documented Tailscale + // target schemes such as `http`, `https`, and `https+insecure`. + const stripped = proxy.replace(/^[a-z][a-z0-9+\-.]*:\/\//i, "").replace(/\/.*$/, ""); + if (stripped === String(port)) { + return true; + } + const sep = stripped.lastIndexOf(":"); + if (sep < 0) { + return false; + } + const host = stripped.slice(0, sep); + const portStr = stripped.slice(sep + 1); + if (portStr !== String(port)) { + return false; + } + return TAILSCALE_LOOPBACK_PROXY_HOSTS.has(host); +} + +function funnelStatusBackendsForPort(status: Record): Set { + const backends = new Set(); + const allowFunnel = (status as { AllowFunnel?: Record }).AllowFunnel ?? {}; + const enabledHosts = new Set( + Object.entries(allowFunnel) + .filter(([, value]) => value === true) + .map(([host]) => host), + ); + if (enabledHosts.size === 0) { + return backends; + } + const web = (status as { Web?: Record }).Web; + if (!web || typeof web !== "object") { + return backends; + } + for (const [host, handlers] of Object.entries(web)) { + if (!enabledHosts.has(host)) { + continue; + } + if (!handlers || typeof handlers !== "object") { + continue; + } + const handlerEntries = (handlers as { Handlers?: Record }).Handlers; + if (!handlerEntries || typeof handlerEntries !== "object") { + continue; + } + for (const handler of Object.values(handlerEntries)) { + const proxy = (handler as { Proxy?: unknown })?.Proxy; + if (typeof proxy === "string" && proxy.length > 0) { + backends.add(proxy); + } + } + } + return backends; +} + export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], {