Files
openclaw/src/gateway/server-tailscale.ts
charles-openclaw a6f4de4a66 feat(gateway): support Tailscale Serve service names
Adds optional `gateway.tailscale.serviceName` support for Tailscale Serve so the Gateway Control UI can be exposed through a named Tailscale Service while existing hostname-based Serve and Funnel behavior stays unchanged.

The implementation validates `svc:<dns-label>`, passes the Service name to `tailscale serve`, clears named Service config with `tailscale serve clear <service>` when resetOnExit runs, and uses the derived Service hostname in startup logs, status output, and pairing URLs.

Verification:
- node scripts/run-vitest.mjs src/infra/tailscale.test.ts src/gateway/server-tailscale.test.ts src/config/config.gateway-tailscale-bind.test.ts src/gateway/startup-auth.test.ts src/commands/status.scan.shared.test.ts src/pairing/setup-code.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/infra/tailscale.test.ts src/gateway/server-tailscale.test.ts src/config/config.gateway-tailscale-bind.test.ts src/gateway/startup-auth.test.ts src/commands/status.scan.shared.test.ts src/pairing/setup-code.test.ts"
- git diff --check
- git merge-tree --write-tree origin/main origin/pr/88691

Closes #88629.
Co-authored-by: Charles OpenClaw <charles-openclaw@9bcfae.inboxapi.ai>
2026-05-31 20:05:02 +01:00

97 lines
3.2 KiB
TypeScript

import { formatErrorMessage } from "../infra/errors.js";
import {
disableTailscaleFunnel,
disableTailscaleServe,
enableTailscaleFunnel,
enableTailscaleServe,
getTailnetHostname,
hasTailscaleFunnelRouteForPort,
} from "../infra/tailscale.js";
import { resolveTailscalePublishedHost } from "../shared/tailscale-status.js";
export async function startGatewayTailscaleExposure(params: {
tailscaleMode: "off" | "serve" | "funnel";
resetOnExit?: boolean;
port: number;
preserveFunnel?: boolean;
serviceName?: string;
controlUiBasePath?: string;
logTailscale: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<(() => Promise<void>) | null> {
if (params.tailscaleMode === "off") {
return null;
}
const serviceName =
params.tailscaleMode === "serve" ? params.serviceName?.trim() || undefined : undefined;
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;
}
}
if (serviceName) {
await enableTailscaleServe(params.port, undefined, serviceName);
} else {
await enableTailscaleServe(params.port);
}
} else {
await enableTailscaleFunnel(params.port);
}
const host = await getTailnetHostname().catch(() => null);
if (host) {
const uiPath = params.controlUiBasePath ? `${params.controlUiBasePath}/` : "/";
const publicHost = resolveTailscalePublishedHost({
tailscaleMode: params.tailscaleMode,
tailnetHost: host,
serviceName,
});
if (publicHost) {
const serviceLabel = serviceName ? ` for ${serviceName}` : "";
params.logTailscale.info(
`${params.tailscaleMode} enabled${serviceLabel}: https://${publicHost}${uiPath} (WS via wss://${publicHost})`,
);
} else {
params.logTailscale.info(`${params.tailscaleMode} enabled`);
}
} else {
params.logTailscale.info(`${params.tailscaleMode} enabled`);
}
} catch (err) {
params.logTailscale.warn(`${params.tailscaleMode} failed: ${formatErrorMessage(err)}`);
}
if (!params.resetOnExit) {
return null;
}
return async () => {
try {
if (params.tailscaleMode === "serve") {
if (serviceName) {
await disableTailscaleServe(undefined, serviceName);
} else {
await disableTailscaleServe();
}
} else {
await disableTailscaleFunnel();
}
} catch (err) {
params.logTailscale.warn(
`${params.tailscaleMode} cleanup failed: ${formatErrorMessage(err)}`,
);
}
};
}