fix(gateway): preserve external Tailscale Funnel routes in serve mode

Adds opt-in `gateway.tailscale.preserveFunnel`. When `tailscale.mode = "serve"`
and an externally configured Tailscale Funnel route already covers the gateway
port, OpenClaw checks `tailscale funnel status --json` before re-applying
`tailscale serve` and skips both Serve and the `resetOnExit` teardown for that
run, preserving operator-managed Funnel exposure across gateway restarts.

The Funnel-status parser handles every documented Tailscale target scheme
(http, https, https+insecure) via an RFC 3986 scheme strip, plus loopback
hostnames (127.0.0.1, localhost, ::1) and bare-port forms. AllowFunnel-disabled
hosts and other-port routes are ignored.

Closes #57241.
This commit is contained in:
RenzoMXD
2026-05-07 19:39:02 +02:00
committed by Peter Steinberger
parent 067ceb38b7
commit 60f1b1f8d9
16 changed files with 353 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -109,6 +109,8 @@ export const FIELD_HELP: Record<string, string> = {
'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":

View File

@@ -129,6 +129,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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 = {

View File

@@ -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(),

View File

@@ -916,6 +916,7 @@ function createPostAttachParams(overrides: Partial<PostAttachParams> = {}): Post
broadcast: vi.fn(),
tailscaleMode: "off",
resetOnExit: false,
preserveFunnel: false,
controlUiBasePath: "/",
logTailscale: {
info: vi.fn(),

View File

@@ -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,

View File

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

View File

@@ -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<void>) | 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);

View File

@@ -1393,6 +1393,7 @@ export async function startGatewayServer(
broadcast,
tailscaleMode,
resetOnExit: tailscaleConfig.resetOnExit ?? false,
preserveFunnel: tailscaleConfig.preserveFunnel ?? false,
controlUiBasePath,
logTailscale,
gatewayPluginConfigAtStart,

View File

@@ -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({

View File

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

View File

@@ -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<string, { Proxy?: unknown }>) {
const host = "device.tailnet.ts.net:443";
return {
AllowFunnel: { [host]: true },
Web: {
[host]: { Handlers: handlers },
},
} as Record<string, unknown>;
}
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<string, unknown>;
expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false);
});
it("returns false on an empty status payload", () => {
expect(tailscaleFunnelStatusCoversPort({}, 18789)).toBe(false);
});
});

View File

@@ -402,6 +402,97 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec =
});
}
export async function hasTailscaleFunnelRouteForPort(
port: number,
exec: typeof runExec = runExec,
): Promise<boolean> {
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<string, unknown>,
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<string, unknown>): Set<string> {
const backends = new Set<string>();
const allowFunnel = (status as { AllowFunnel?: Record<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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"], {