mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 07:00:44 +00:00
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:
committed by
Peter Steinberger
parent
067ceb38b7
commit
60f1b1f8d9
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -916,6 +916,7 @@ function createPostAttachParams(overrides: Partial<PostAttachParams> = {}): Post
|
||||
broadcast: vi.fn(),
|
||||
tailscaleMode: "off",
|
||||
resetOnExit: false,
|
||||
preserveFunnel: false,
|
||||
controlUiBasePath: "/",
|
||||
logTailscale: {
|
||||
info: vi.fn(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
115
src/gateway/server-tailscale.test.ts
Normal file
115
src/gateway/server-tailscale.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -1393,6 +1393,7 @@ export async function startGatewayServer(
|
||||
broadcast,
|
||||
tailscaleMode,
|
||||
resetOnExit: tailscaleConfig.resetOnExit ?? false,
|
||||
preserveFunnel: tailscaleConfig.preserveFunnel ?? false,
|
||||
controlUiBasePath,
|
||||
logTailscale,
|
||||
gatewayPluginConfigAtStart,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
Reference in New Issue
Block a user