fix: add explicit tailnet gateway bind

This commit is contained in:
Peter Steinberger
2026-01-21 20:35:39 +00:00
parent 45c1ccdfcf
commit b5fd66c92d
30 changed files with 143 additions and 71 deletions

View File

@@ -46,7 +46,7 @@ export function pickProbeHostForBind(
if (bindMode === "custom" && customBindHost?.trim()) {
return customBindHost.trim();
}
if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1";
if (bindMode === "tailnet") return tailnetIPv4 ?? "127.0.0.1";
return "127.0.0.1";
}

View File

@@ -172,7 +172,8 @@ export async function gatherDaemonStatus(
| "auto"
| "lan"
| "loopback"
| "custom";
| "custom"
| "tailnet";
const customBindHost = daemonCfg.gateway?.customBindHost;
const bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
const tailnetIPv4 = pickPrimaryTailnetIPv4();

View File

@@ -174,11 +174,15 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
}
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
const bind =
bindRaw === "loopback" || bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom"
bindRaw === "loopback" ||
bindRaw === "lan" ||
bindRaw === "auto" ||
bindRaw === "custom" ||
bindRaw === "tailnet"
? bindRaw
: null;
if (!bind) {
defaultRuntime.error('Invalid --bind (use "loopback", "lan", "auto", or "custom")');
defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")');
defaultRuntime.exit(1);
return;
}
@@ -304,7 +308,7 @@ export function addGatewayRunCommand(cmd: Command): Command {
.option("--port <port>", "Port for the gateway WebSocket")
.option(
"--bind <mode>",
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
'Bind mode ("loopback"|"lan"|"tailnet"|"auto"|"custom"). Defaults to config gateway.bind (or loopback).',
)
.option(
"--token <token>",

View File

@@ -76,7 +76,7 @@ export function registerOnboardCommand(program: Command) {
.option("--synthetic-api-key <key>", "Synthetic API key")
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|auto|custom")
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
.option("--gateway-token <token>", "Gateway token (token auth)")
.option("--gateway-password <password>", "Gateway password (password auth)")

View File

@@ -31,21 +31,26 @@ export async function promptGatewayConfig(
await select({
message: "Gateway bind mode",
options: [
{
value: "loopback",
label: "Loopback (Local only)",
hint: "Bind to 127.0.0.1 - secure, local-only access",
},
{
value: "tailnet",
label: "Tailnet (Tailscale IP)",
hint: "Bind to your Tailscale IP only (100.x.x.x)",
},
{
value: "auto",
label: "Auto (Tailnet → LAN)",
hint: "Prefer Tailnet IP, fall back to all interfaces if unavailable",
label: "Auto (Loopback → LAN)",
hint: "Prefer loopback; fall back to all interfaces if unavailable",
},
{
value: "lan",
label: "LAN (All interfaces)",
hint: "Bind to 0.0.0.0 - accessible from anywhere on your network",
},
{
value: "loopback",
label: "Loopback (Local only)",
hint: "Bind to 127.0.0.1 - secure, local-only access",
},
{
value: "custom",
label: "Custom IP",
@@ -54,7 +59,7 @@ export async function promptGatewayConfig(
],
}),
runtime,
) as "auto" | "lan" | "loopback" | "custom";
) as "auto" | "lan" | "loopback" | "custom" | "tailnet";
let customBindHost: string | undefined;
if (bind === "custom") {

View File

@@ -71,4 +71,24 @@ describe("resolveControlUiLinks", () => {
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
it("uses tailnet IP for tailnet bind", () => {
mocks.pickPrimaryTailnetIPv4.mockReturnValueOnce("100.64.0.9");
const links = resolveControlUiLinks({
port: 18789,
bind: "tailnet",
});
expect(links.httpUrl).toBe("http://100.64.0.9:18789/");
expect(links.wsUrl).toBe("ws://100.64.0.9:18789");
});
it("keeps loopback for auto even when tailnet is present", () => {
mocks.pickPrimaryTailnetIPv4.mockReturnValueOnce("100.64.0.9");
const links = resolveControlUiLinks({
port: 18789,
bind: "auto",
});
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
});

View File

@@ -366,7 +366,7 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
export function resolveControlUiLinks(params: {
port: number;
bind?: "auto" | "lan" | "loopback" | "custom";
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
customBindHost?: string;
basePath?: string;
}): { httpUrl: string; wsUrl: string } {
@@ -378,7 +378,7 @@ export function resolveControlUiLinks(params: {
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
return customBindHost;
}
if (bind === "auto" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1";
if (bind === "tailnet" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1";
return "127.0.0.1";
})();
const basePath = normalizeControlUiBasePath(params.basePath);

View File

@@ -91,7 +91,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!opts.skipHealth) {
const links = resolveControlUiLinks({
bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom",
bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom" | "tailnet",
port: gatewayResult.port,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: undefined,

View File

@@ -33,7 +33,7 @@ export type AuthChoice =
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
export type TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ChannelChoice = ChannelId;

View File

@@ -218,14 +218,14 @@ describe("legacy config detection", () => {
expect(res.config?.gateway?.auth?.mode).toBe("token");
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
});
it("migrates gateway.bind from 'tailnet' to 'auto'", async () => {
it("keeps gateway.bind tailnet", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { bind: "tailnet" as const },
});
expect(res.changes).toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.config?.gateway?.bind).toBe("auto");
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.config?.gateway?.bind).toBe("tailnet");
});
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();

View File

@@ -143,21 +143,4 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
delete raw.identity;
},
},
{
id: "bind-tailnet->auto",
describe: "Remap gateway bind 'tailnet' to 'auto'",
apply: (raw, changes) => {
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
if (!obj) return;
const bind = obj.bind;
if (bind === "tailnet") {
obj.bind = "auto";
changes.push(`Migrated ${key}.bind from 'tailnet' to 'auto'.`);
}
};
const gateway = getRecord(raw.gateway);
migrateBind(gateway, "gateway");
},
},
];

View File

@@ -1,4 +1,4 @@
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom";
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet";
export type GatewayTlsConfig = {
/** Enable TLS for the gateway server. */
@@ -189,9 +189,10 @@ export type GatewayConfig = {
mode?: "local" | "remote";
/**
* Bind address policy for the Gateway WebSocket + Control UI HTTP server.
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
* - auto: Loopback (127.0.0.1) if available, else 0.0.0.0 (fallback to all interfaces)
* - lan: 0.0.0.0 (all interfaces, no fallback)
* - loopback: 127.0.0.1 (local-only)
* - tailnet: Tailnet IPv4 if available (100.64.0.0/10), else loopback
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
* Default: loopback (127.0.0.1).
*/

View File

@@ -270,7 +270,13 @@ export const ClawdbotSchema = z
port: z.number().int().positive().optional(),
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
bind: z
.union([z.literal("auto"), z.literal("lan"), z.literal("loopback"), z.literal("custom")])
.union([
z.literal("auto"),
z.literal("lan"),
z.literal("loopback"),
z.literal("custom"),
z.literal("tailnet"),
])
.optional(),
controlUi: z
.object({

View File

@@ -72,14 +72,14 @@ describe("callGateway url resolution", () => {
closeReason = "";
});
it("uses tailnet IP when local bind is auto and tailnet is present", async () => {
it("keeps loopback when local bind is auto even if tailnet is present", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
resolveGatewayPort.mockReturnValue(18800);
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
await callGateway({ method: "health" });
expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800");
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
});
it("falls back to loopback when local bind is auto without tailnet IP", async () => {
@@ -92,6 +92,16 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
});
it("uses tailnet IP when local bind is tailnet and tailnet is present", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } });
resolveGatewayPort.mockReturnValue(18800);
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
await callGateway({ method: "health" });
expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800");
});
it("uses url override in remote mode even when remote url is missing", async () => {
loadConfig.mockReturnValue({
gateway: { mode: "remote", bind: "loopback", remote: {} },

View File

@@ -63,7 +63,7 @@ export function buildGatewayConnectionDetails(
const localPort = resolveGatewayPort(config);
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const bindMode = config.gateway?.bind ?? "loopback";
const preferTailnet = bindMode === "auto" && !!tailnetIPv4;
const preferTailnet = bindMode === "tailnet" && !!tailnetIPv4;
const scheme = tlsEnabled ? "wss" : "ws";
const localUrl =
preferTailnet && tailnetIPv4

View File

@@ -33,7 +33,8 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean {
* Modes:
* - loopback: 127.0.0.1 (rarely fails, but handled gracefully)
* - lan: always 0.0.0.0 (no fallback)
* - auto: Tailnet IPv4 if available, else 0.0.0.0
* - tailnet: Tailnet IPv4 if available, else loopback
* - auto: Loopback if available, else 0.0.0.0
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable
*
* @returns The bind address to use (never null)
@@ -50,6 +51,13 @@ export async function resolveGatewayBindHost(
return "0.0.0.0"; // extreme fallback
}
if (mode === "tailnet") {
const tailnetIP = pickPrimaryTailnetIPv4();
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0";
}
if (mode === "lan") {
return "0.0.0.0";
}
@@ -64,8 +72,7 @@ export async function resolveGatewayBindHost(
}
if (mode === "auto") {
const tailnetIP = pickPrimaryTailnetIPv4();
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0";
}

View File

@@ -97,7 +97,7 @@ export type GatewayServerOptions = {
* - loopback: 127.0.0.1
* - lan: 0.0.0.0
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
* - auto: prefer tailnet, else LAN
* - auto: prefer loopback, else LAN
*/
bind?: import("../config/config.js").GatewayBindMode;
/**

View File

@@ -87,11 +87,15 @@ async function main() {
cfg.gateway?.bind ??
"loopback";
const bind =
bindRaw === "loopback" || bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom"
bindRaw === "loopback" ||
bindRaw === "lan" ||
bindRaw === "auto" ||
bindRaw === "custom" ||
bindRaw === "tailnet"
? bindRaw
: null;
if (!bind) {
defaultRuntime.error('Invalid --bind (use "loopback", "lan", "auto", or "custom")');
defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")');
process.exit(1);
}

View File

@@ -52,12 +52,13 @@ export async function configureGatewayForOnboarding(
message: "Gateway bind",
options: [
{ value: "loopback", label: "Loopback (127.0.0.1)" },
{ value: "lan", label: "LAN" },
{ value: "auto", label: "Auto" },
{ value: "lan", label: "LAN (0.0.0.0)" },
{ value: "tailnet", label: "Tailnet (Tailscale IP)" },
{ value: "auto", label: "Auto (Loopback → LAN)" },
{ value: "custom", label: "Custom IP" },
],
})) as "loopback" | "lan" | "auto" | "custom")
) as "loopback" | "lan" | "auto" | "custom";
})) as "loopback" | "lan" | "auto" | "custom" | "tailnet")
) as "loopback" | "lan" | "auto" | "custom" | "tailnet";
let customBindHost = quickstartGateway.customBindHost;
if (bind === "custom") {

View File

@@ -177,7 +177,11 @@ export async function runOnboardingWizard(
const bindRaw = baseConfig.gateway?.bind;
const bind =
bindRaw === "loopback" || bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom"
bindRaw === "loopback" ||
bindRaw === "lan" ||
bindRaw === "auto" ||
bindRaw === "custom" ||
bindRaw === "tailnet"
? bindRaw
: "loopback";
@@ -213,10 +217,11 @@ export async function runOnboardingWizard(
})();
if (flow === "quickstart") {
const formatBind = (value: "loopback" | "lan" | "auto" | "custom") => {
const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => {
if (value === "loopback") return "Loopback (127.0.0.1)";
if (value === "lan") return "LAN";
if (value === "custom") return "Custom IP";
if (value === "tailnet") return "Tailnet (Tailscale IP)";
return "Auto";
};
const formatAuth = (value: GatewayAuthChoice) => {

View File

@@ -5,7 +5,7 @@ export type WizardFlow = "quickstart" | "advanced";
export type QuickstartGatewayDefaults = {
hasExisting: boolean;
port: number;
bind: "loopback" | "lan" | "auto" | "custom";
bind: "loopback" | "lan" | "auto" | "custom" | "tailnet";
authMode: GatewayAuthChoice;
tailscaleMode: "off" | "serve" | "funnel";
token?: string;
@@ -16,7 +16,7 @@ export type QuickstartGatewayDefaults = {
export type GatewayWizardSettings = {
port: number;
bind: "loopback" | "lan" | "auto" | "custom";
bind: "loopback" | "lan" | "auto" | "custom" | "tailnet";
customBindHost?: string;
authMode: GatewayAuthChoice;
gatewayToken?: string;