fix: add explicit tailnet gateway bind
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user