From c851bdd47a2f9c61e4586eaaf809aa15a00e35d2 Mon Sep 17 00:00:00 2001 From: Jefferson Warrior Date: Sun, 11 Jan 2026 14:13:13 -0600 Subject: [PATCH] feat: add Tailscale binary detection, IP binding modes, and health probe password fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR includes three main improvements: 1. Tailscale Binary Detection with Fallback Strategies - Added findTailscaleBinary() with multi-strategy detection: * PATH lookup via 'which' command * Known macOS app path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) * find /Applications for Tailscale.app * locate database lookup - Added getTailscaleBinary() with caching - Updated all Tailscale operations to use detected binary - Added TUI warning when Tailscale binary not found for serve/funnel modes 2. Custom Gateway IP Binding with Fallback - New bind mode "custom" allowing user-specified IP with fallback to 0.0.0.0 - Removed "tailnet" mode (folded into "auto") - All modes now support graceful fallback: custom (if fail → 0.0.0.0), loopback (127.0.0.1 → 0.0.0.0), auto (tailnet → 0.0.0.0), lan (0.0.0.0) - Added customBindHost config option for custom bind mode - Added canBindTo() helper to test IP availability before binding - Updated configure and onboarding wizards with new bind mode options 3. Health Probe Password Auth Fix - Gateway probe now tries both new and old passwords - Fixes issue where password change fails health check if gateway hasn't restarted yet - Uses nextConfig password first, falls back to baseConfig password if needed Files changed: - src/infra/tailscale.ts: Binary detection + caching - src/gateway/net.ts: IP binding with fallback logic - src/config/types.ts: BridgeBindMode type + customBindHost field - src/commands/configure.ts: Health probe dual-password try + Tailscale detection warning + bind mode UI - src/wizard/onboarding.ts: Tailscale detection warning + bind mode UI - src/gateway/server.ts: Use new resolveGatewayBindHost - src/gateway/call.ts: Updated preferTailnet logic (removed "tailnet" mode) - src/commands/onboard-types.ts: Updated GatewayBind type - src/commands/onboard-helpers.ts: resolveControlUiLinks updated - src/cli/*.ts: Updated bind mode casts - src/gateway/call.test.ts: Removed "tailnet" mode test --- src/agents/pi-embedded-runner.ts | 81 ++++++++++++++++ src/cli/daemon-cli.ts | 29 +++--- src/cli/gateway-cli.ts | 6 +- src/cli/program.ts | 4 +- src/commands/configure.ts | 102 +++++++++++++++++--- src/commands/dashboard.test.ts | 1 + src/commands/dashboard.ts | 8 +- src/commands/onboard-helpers.test.ts | 33 ++++++- src/commands/onboard-helpers.ts | 24 ++++- src/commands/onboard-types.ts | 2 +- src/commands/status-all.ts | 1 + src/commands/status.ts | 1 + src/config/config.test.ts | 17 ++++ src/config/legacy.ts | 23 +++++ src/config/types.ts | 16 ++- src/gateway/call.test.ts | 14 +-- src/gateway/call.ts | 3 +- src/gateway/net.ts | 87 +++++++++++++++-- src/gateway/server.ts | 19 ++-- src/infra/tailscale.ts | 139 ++++++++++++++++++++++++--- src/macos/gateway-daemon.ts | 6 +- src/wizard/onboarding.ts | 69 +++++++++++-- 22 files changed, 587 insertions(+), 98 deletions(-) diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 181be081c..fc1407ae0 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -417,6 +417,85 @@ type EmbeddedPiQueueHandle = { const log = createSubsystemLogger("agent/embedded"); const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap"; +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +function findUnsupportedSchemaKeywords( + schema: unknown, + path: string, +): string[] { + if (!schema || typeof schema !== "object") return []; + if (Array.isArray(schema)) { + return schema.flatMap((item, index) => + findUnsupportedSchemaKeywords(item, `${path}[${index}]`), + ); + } + const record = schema as Record; + const violations: string[] = []; + for (const [key, value] of Object.entries(record)) { + if (GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS.has(key)) { + violations.push(`${path}.${key}`); + } + if (value && typeof value === "object") { + violations.push( + ...findUnsupportedSchemaKeywords(value, `${path}.${key}`), + ); + } + } + return violations; +} + +function logToolSchemasForGoogle(params: { + tools: AgentTool[]; + provider: string; +}) { + if ( + params.provider !== "google-antigravity" && + params.provider !== "google-gemini-cli" + ) { + return; + } + const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`); + log.info("google tool schema snapshot", { + provider: params.provider, + toolCount: params.tools.length, + tools: toolNames, + }); + for (const [index, tool] of params.tools.entries()) { + const violations = findUnsupportedSchemaKeywords( + tool.parameters, + `${tool.name}.parameters`, + ); + if (violations.length > 0) { + log.warn("google tool schema has unsupported keywords", { + index, + tool: tool.name, + violations: violations.slice(0, 12), + violationCount: violations.length, + }); + } + } +} registerUnhandledRejectionHandler((reason) => { const message = describeUnknownError(reason); @@ -1178,6 +1257,7 @@ export async function compactEmbeddedPiSession(params: { modelAuthMode: resolveModelAuthMode(model.provider, params.config), // No currentChannelId/currentThreadTs for compaction - not in message context }); + logToolSchemasForGoogle({ tools, provider }); const machineName = await getMachineDisplayName(); const runtimeProvider = normalizeMessageProvider( params.messageProvider, @@ -1620,6 +1700,7 @@ export async function runEmbeddedPiAgent(params: { replyToMode: params.replyToMode, hasRepliedRef: params.hasRepliedRef, }); + logToolSchemasForGoogle({ tools, provider }); const machineName = await getMachineDisplayName(); const runtimeInfo = { host: machineName, diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 1a2b7fac9..e8bc3dbb6 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -71,7 +71,8 @@ type ConfigSummary = { type GatewayStatusSummary = { bindMode: BridgeBindMode; - bindHost: string | null; + bindHost: string; + customBindHost?: string; port: number; portSource: "service args" | "env/config"; probeUrl: string; @@ -190,8 +191,11 @@ function parsePortFromArgs( function pickProbeHostForBind( bindMode: string, tailnetIPv4: string | undefined, + customBindHost?: string, ) { - if (bindMode === "tailnet") return tailnetIPv4 ?? "127.0.0.1"; + if (bindMode === "custom" && customBindHost?.trim()) { + return customBindHost.trim(); + } if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1"; return "127.0.0.1"; } @@ -429,11 +433,15 @@ async function gatherDaemonStatus(opts: { const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as | "auto" | "lan" - | "tailnet" - | "loopback"; - const bindHost = resolveGatewayBindHost(bindMode); + | "loopback" + | "custom"; + const customBindHost = daemonCfg.gateway?.customBindHost; + const bindHost = await resolveGatewayBindHost( + bindMode, + customBindHost, + ); const tailnetIPv4 = pickPrimaryTailnetIPv4(); - const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4); + const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost); const probeUrlOverride = typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0 ? opts.rpc.url.trim() @@ -523,6 +531,7 @@ async function gatherDaemonStatus(opts: { gateway: { bindMode, bindHost, + customBindHost, port: daemonPort, portSource, probeUrl, @@ -651,6 +660,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { const links = resolveControlUiLinks({ port: status.gateway.port, bind: status.gateway.bindMode, + customBindHost: status.gateway.customBindHost, basePath: status.config?.daemon?.controlUi?.basePath, }); defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`); @@ -660,13 +670,6 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { `${label("Probe note:")} ${infoText(status.gateway.probeNote)}`, ); } - if (status.gateway.bindMode === "tailnet" && !status.gateway.bindHost) { - defaultRuntime.error( - errorText( - "Root cause: gateway bind=tailnet but no tailnet interface was found.", - ), - ); - } spacer(); } const runtimeLine = formatRuntimeStatus(service.runtime); diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index c86a8bf7b..767c70715 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -678,14 +678,14 @@ async function runGatewayCommand( const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback"; const bind = bindRaw === "loopback" || - bindRaw === "tailnet" || bindRaw === "lan" || - bindRaw === "auto" + bindRaw === "auto" || + bindRaw === "custom" ? bindRaw : null; if (!bind) { defaultRuntime.error( - 'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")', + 'Invalid --bind (use "loopback", "lan", "auto", or "custom")', ); defaultRuntime.exit(1); return; diff --git a/src/cli/program.ts b/src/cli/program.ts index 436857ade..1c6b78a00 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -292,7 +292,7 @@ export function buildProgram() { .option("--synthetic-api-key ", "Synthetic API key") .option("--opencode-zen-api-key ", "OpenCode Zen API key") .option("--gateway-port ", "Gateway port") - .option("--gateway-bind ", "Gateway bind: loopback|lan|tailnet|auto") + .option("--gateway-bind ", "Gateway bind: loopback|lan|auto|custom") .option("--gateway-auth ", "Gateway auth: off|token|password") .option("--gateway-token ", "Gateway token (token auth)") .option("--gateway-password ", "Gateway password (password auth)") @@ -369,8 +369,8 @@ export function buildProgram() { gatewayBind: opts.gatewayBind as | "loopback" | "lan" - | "tailnet" | "auto" + | "custom" | undefined, gatewayAuth: opts.gatewayAuth as | "off" diff --git a/src/commands/configure.ts b/src/commands/configure.ts index e9bc2b0f7..9d6af64cd 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -25,6 +25,7 @@ import { import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; +import { findTailscaleBinary } from "../infra/tailscale.js"; import { listChatProviders } from "../providers/registry.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -220,16 +221,59 @@ async function promptGatewayConfig( let bind = guardCancel( await select({ - message: "Gateway bind", + message: "Gateway bind mode", options: [ - { value: "loopback", label: "Loopback (127.0.0.1)" }, - { value: "lan", label: "LAN" }, - { value: "tailnet", label: "Tailnet" }, - { value: "auto", label: "Auto" }, + { + value: "auto", + label: "Auto (Tailnet → LAN)", + hint: "Prefer Tailnet IP, 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", + hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable", + }, ], }), runtime, - ) as "loopback" | "lan" | "tailnet" | "auto"; + ) as "auto" | "lan" | "loopback" | "custom"; + + let customBindHost: string | undefined; + if (bind === "custom") { + const input = guardCancel( + await text({ + message: "Custom IP address", + placeholder: "192.168.1.100", + validate: (value) => { + if (!value) return "IP address is required for custom bind mode"; + const trimmed = value.trim(); + const parts = trimmed.split("."); + if (parts.length !== 4) + return "Invalid IPv4 address (e.g., 192.168.1.100)"; + if ( + parts.every((part) => { + const n = parseInt(part, 10); + return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); + }) + ) + return undefined; + return "Invalid IPv4 address (each octet must be 0-255)"; + }, + }), + runtime, + ); + customBindHost = typeof input === "string" ? input : undefined; + } let authMode = guardCancel( await select({ @@ -268,6 +312,23 @@ async function promptGatewayConfig( runtime, ) as "off" | "serve" | "funnel"; + // Detect Tailscale binary before proceeding with serve/funnel setup + if (tailscaleMode !== "off") { + const tailscaleBin = await findTailscaleBinary(); + if (!tailscaleBin) { + note( + [ + "Tailscale binary not found in PATH or /Applications.", + "Ensure Tailscale is installed from:", + " https://tailscale.com/download/mac", + "", + "You can continue setup, but serve/funnel will fail at runtime.", + ].join("\n"), + "Tailscale Warning", + ); + } + } + let tailscaleResetOnExit = false; if (tailscaleMode !== "off") { note( @@ -348,6 +409,7 @@ async function promptGatewayConfig( port, bind, auth: authConfig, + ...(customBindHost && { customBindHost }), tailscale: { ...next.gateway?.tailscale, mode: tailscaleMode, @@ -943,16 +1005,32 @@ export async function runConfigureWizard( const links = resolveControlUiLinks({ bind, port: gatewayPort, + customBindHost: nextConfig.gateway?.customBindHost, basePath: nextConfig.gateway?.controlUi?.basePath, }); - const gatewayProbe = await probeGatewayReachable({ + // Try both new and old passwords since gateway may still have old config + const newPassword = + nextConfig.gateway?.auth?.password ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD; + const oldPassword = + baseConfig.gateway?.auth?.password ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD; + const token = + nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN; + + let gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, - token: - nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, - password: - nextConfig.gateway?.auth?.password ?? - process.env.CLAWDBOT_GATEWAY_PASSWORD, + token, + password: newPassword, }); + // If new password failed and it's different from old password, try old too + if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) { + gatewayProbe = await probeGatewayReachable({ + url: links.wsUrl, + token, + password: oldPassword, + }); + } const gatewayStatusLine = gatewayProbe.ok ? "Gateway: reachable" : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts index 6261703cd..b82f97b2b 100644 --- a/src/commands/dashboard.test.ts +++ b/src/commands/dashboard.test.ts @@ -78,6 +78,7 @@ describe("dashboardCommand", () => { expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ port: 18789, bind: "loopback", + customBindHost: undefined, basePath: undefined, }); expect(mocks.copyToClipboard).toHaveBeenCalledWith( diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 79f0046b5..3e3308e71 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -25,10 +25,16 @@ export async function dashboardCommand( const port = resolveGatewayPort(cfg); const bind = cfg.gateway?.bind ?? "loopback"; const basePath = cfg.gateway?.controlUi?.basePath; + const customBindHost = cfg.gateway?.customBindHost; const token = cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? ""; - const links = resolveControlUiLinks({ port, bind, basePath }); + const links = resolveControlUiLinks({ + port, + bind, + customBindHost, + basePath, + }); const authedUrl = token ? `${links.httpUrl}?token=${encodeURIComponent(token)}` : links.httpUrl; diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 334c6b51e..64d51dbf1 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { openUrl, resolveBrowserOpenCommand } from "./onboard-helpers.js"; +import { + openUrl, + resolveBrowserOpenCommand, + resolveControlUiLinks, +} from "./onboard-helpers.js"; const mocks = vi.hoisted(() => ({ runCommandWithTimeout: vi.fn(async () => ({ @@ -10,12 +14,17 @@ const mocks = vi.hoisted(() => ({ signal: null, killed: false, })), + pickPrimaryTailnetIPv4: vi.fn(() => undefined), })); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout, })); +vi.mock("../infra/tailnet.js", () => ({ + pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4, +})); + describe("openUrl", () => { it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("win32"); @@ -45,3 +54,25 @@ describe("resolveBrowserOpenCommand", () => { expect(resolved.quoteUrl).toBe(true); }); }); + +describe("resolveControlUiLinks", () => { + it("uses customBindHost for custom bind", () => { + const links = resolveControlUiLinks({ + port: 18789, + bind: "custom", + customBindHost: "192.168.1.100", + }); + expect(links.httpUrl).toBe("http://192.168.1.100:18789/"); + expect(links.wsUrl).toBe("ws://192.168.1.100:18789"); + }); + + it("falls back to loopback for invalid customBindHost", () => { + const links = resolveControlUiLinks({ + port: 18789, + bind: "custom", + customBindHost: "192.168.001.100", + }); + expect(links.httpUrl).toBe("http://127.0.0.1:18789/"); + expect(links.wsUrl).toBe("ws://127.0.0.1:18789"); + }); +}); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index bd04da0aa..23a14fb23 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -410,16 +410,21 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR; export function resolveControlUiLinks(params: { port: number; - bind?: "auto" | "lan" | "tailnet" | "loopback"; + bind?: "auto" | "lan" | "loopback" | "custom"; + customBindHost?: string; basePath?: string; }): { httpUrl: string; wsUrl: string } { const port = params.port; const bind = params.bind ?? "loopback"; + const customBindHost = params.customBindHost?.trim(); const tailnetIPv4 = pickPrimaryTailnetIPv4(); - const host = - bind === "tailnet" || (bind === "auto" && tailnetIPv4) - ? (tailnetIPv4 ?? "127.0.0.1") - : "127.0.0.1"; + const host = (() => { + if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) { + return customBindHost; + } + if (bind === "auto" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1"; + return "127.0.0.1"; + })(); const basePath = normalizeControlUiBasePath(params.basePath); const uiPath = basePath ? `${basePath}/` : "/"; const wsPath = basePath ? basePath : ""; @@ -428,3 +433,12 @@ export function resolveControlUiLinks(params: { wsUrl: `ws://${host}:${port}${wsPath}`, }; } + +function isValidIPv4(host: string): boolean { + const parts = host.split("."); + if (parts.length !== 4) return false; + return parts.every((part) => { + const n = Number.parseInt(part, 10); + return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); + }); +} diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index d6388a076..68fe6fb06 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -28,7 +28,7 @@ export type AuthChoice = | "skip"; export type GatewayAuthChoice = "off" | "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; -export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto"; +export type GatewayBind = "loopback" | "lan" | "auto" | "custom"; export type TailscaleMode = "off" | "serve" | "funnel"; export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type ProviderChoice = ChatProviderId; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 9073725c1..caf22958a 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -271,6 +271,7 @@ export async function statusAllCommand( ? resolveControlUiLinks({ port, bind: cfg.gateway?.bind, + customBindHost: cfg.gateway?.customBindHost, basePath: cfg.gateway?.controlUi?.basePath, }).httpUrl : null; diff --git a/src/commands/status.ts b/src/commands/status.ts index 865724ee3..b1ede586f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -818,6 +818,7 @@ export async function statusCommand( const links = resolveControlUiLinks({ port: resolveGatewayPort(cfg), bind: cfg.gateway?.bind, + customBindHost: cfg.gateway?.customBindHost, basePath: cfg.gateway?.controlUi?.basePath, }); return links.httpUrl; diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 9d49b5416..35932cfa8 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1247,6 +1247,23 @@ describe("legacy config detection", () => { expect((res.config?.gateway as { token?: string })?.token).toBeUndefined(); }); + it("migrates gateway.bind and bridge.bind from 'tailnet' to 'auto'", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + gateway: { bind: "tailnet" as const }, + bridge: { bind: "tailnet" as const }, + }); + expect(res.changes).toContain( + "Migrated gateway.bind from 'tailnet' to 'auto'.", + ); + expect(res.changes).toContain( + "Migrated bridge.bind from 'tailnet' to 'auto'.", + ); + expect(res.config?.gateway?.bind).toBe("auto"); + expect(res.config?.bridge?.bind).toBe("auto"); + }); + it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); diff --git a/src/config/legacy.ts b/src/config/legacy.ts index aca672848..bc6e3fbe0 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -891,6 +891,29 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ delete raw.identity; }, }, + { + id: "bind-tailnet->auto", + describe: "Remap gateway/bridge bind 'tailnet' to 'auto'", + apply: (raw, changes) => { + const migrateBind = ( + obj: Record | 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"); + + const bridge = getRecord(raw.bridge); + migrateBind(bridge, "bridge"); + }, + }, ]; export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { diff --git a/src/config/types.ts b/src/config/types.ts index 16c43578b..9869b0ab4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1244,17 +1244,17 @@ export type ProviderCommandsConfig = { native?: NativeCommandsSetting; }; -export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; +export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom"; export type BridgeConfig = { enabled?: boolean; port?: number; /** * Bind address policy for the node bridge server. - * - auto: prefer tailnet IP when present, else LAN (0.0.0.0) - * - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces) - * - tailnet: bind to the Tailscale interface IP (100.64.0.0/10) plus loopback - * - loopback: 127.0.0.1 + * - auto: Tailnet IPv4 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) + * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway) */ bind?: BridgeBindMode; }; @@ -1369,9 +1369,15 @@ 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) + * - lan: 0.0.0.0 (all interfaces, no fallback) + * - loopback: 127.0.0.1 (local-only) + * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost) * Default: loopback (127.0.0.1). */ bind?: BridgeBindMode; + /** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */ + customBindHost?: string; controlUi?: GatewayControlUiConfig; auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig; diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 60ab77c24..dbd03f805 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -74,8 +74,8 @@ describe("callGateway url resolution", () => { closeReason = ""; }); - it("uses tailnet IP when local bind is tailnet", async () => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } }); + it("uses tailnet IP when local bind is auto and tailnet is present", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); @@ -84,16 +84,6 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800"); }); - it("uses tailnet IP when local bind is auto and tailnet is present", async () => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); - resolveGatewayPort.mockReturnValue(18800); - pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.2"); - - await callGateway({ method: "health" }); - - expect(lastClientOptions?.url).toBe("ws://100.64.0.2:18800"); - }); - it("falls back to loopback when local bind is auto without tailnet IP", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); resolveGatewayPort.mockReturnValue(18800); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index dc8fa2314..dc66f4f03 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -59,8 +59,7 @@ export function buildGatewayConnectionDetails( const localPort = resolveGatewayPort(config); const tailnetIPv4 = pickPrimaryTailnetIPv4(); const bindMode = config.gateway?.bind ?? "loopback"; - const preferTailnet = - bindMode === "tailnet" || (bindMode === "auto" && !!tailnetIPv4); + const preferTailnet = bindMode === "auto" && !!tailnetIPv4; const localUrl = preferTailnet && tailnetIPv4 ? `ws://${tailnetIPv4}:${localPort}` diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 88568d98d..27e905392 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,3 +1,5 @@ +import net from "node:net"; + import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; export function isLoopbackAddress(ip: string | undefined): boolean { @@ -9,15 +11,86 @@ export function isLoopbackAddress(ip: string | undefined): boolean { return false; } -export function resolveGatewayBindHost( +/** + * Resolves gateway bind host with fallback strategy. + * + * 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 + * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable + * + * @returns The bind address to use (never null) + */ +export async function resolveGatewayBindHost( bind: import("../config/config.js").BridgeBindMode | undefined, -): string | null { + customHost?: string, +): Promise { const mode = bind ?? "loopback"; - if (mode === "loopback") return "127.0.0.1"; - if (mode === "lan") return "0.0.0.0"; - if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null; - if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0"; - return "127.0.0.1"; + + if (mode === "loopback") { + // 127.0.0.1 rarely fails, but handle gracefully + if (await canBindTo("127.0.0.1")) return "127.0.0.1"; + return "0.0.0.0"; // extreme fallback + } + + if (mode === "lan") { + return "0.0.0.0"; + } + + if (mode === "custom") { + const host = customHost?.trim(); + if (!host) return "0.0.0.0"; // invalid config → fall back to all + + if (isValidIPv4(host) && (await canBindTo(host))) return host; + // Custom IP failed → fall back to LAN + return "0.0.0.0"; + } + + if (mode === "auto") { + const tailnetIP = pickPrimaryTailnetIPv4(); + if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP; + return "0.0.0.0"; + } + + return "0.0.0.0"; +} + +/** + * Test if we can bind to a specific host address. + * Creates a temporary server, attempts to bind, then closes it. + * + * @param host - The host address to test + * @returns True if we can successfully bind to this address + */ +async function canBindTo(host: string): Promise { + return new Promise((resolve) => { + const testServer = net.createServer(); + testServer.once("error", () => { + resolve(false); + }); + testServer.once("listening", () => { + testServer.close(); + resolve(true); + }); + // Use port 0 to let OS pick an available port for testing + testServer.listen(0, host); + }); +} + +/** + * Validate if a string is a valid IPv4 address. + * + * @param host - The string to validate + * @returns True if valid IPv4 format + */ +function isValidIPv4(host: string): boolean { + const parts = host.split("."); + if (parts.length !== 4) return false; + return parts.every((part) => { + const n = parseInt(part, 10); + return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); + }); } export function isLoopbackHost(host: string): boolean { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5035e405c..73069f10e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -490,12 +490,9 @@ export async function startGatewayServer( } let pluginServices: PluginServicesHandle | null = null; const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; - const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); - if (!bindHost) { - throw new Error( - "gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway", - ); - } + const customBindHost = cfgAtStart.gateway?.customBindHost; + const bindHost = + opts.host ?? (await resolveGatewayBindHost(bindMode, customBindHost)); const controlUiEnabled = opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; const openAiChatCompletionsEnabled = @@ -960,18 +957,20 @@ export async function startGatewayServer( } const bind = - cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "tailnet" : "lan"); + cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan"); if (bind === "loopback") return "127.0.0.1"; if (bind === "lan") return "0.0.0.0"; const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv6 = pickPrimaryTailnetIPv6(); - if (bind === "tailnet") { - return tailnetIPv4 ?? tailnetIPv6 ?? null; - } if (bind === "auto") { return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0"; } + if (bind === "custom") { + // For bridge, customBindHost is not currently supported on GatewayConfig. + // This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig. + return "0.0.0.0"; + } return "0.0.0.0"; })(); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 43ebb0e6f..186d17ec8 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -22,18 +22,111 @@ function parsePossiblyNoisyJsonObject(stdout: string): Record { return JSON.parse(trimmed) as Record; } -export async function getTailnetHostname(exec: typeof runExec = runExec) { +/** + * Locate Tailscale binary using multiple strategies: + * 1. PATH lookup (via which command) + * 2. Known macOS app path + * 3. find /Applications for Tailscale.app + * 4. locate database (if available) + * + * @returns Path to Tailscale binary or null if not found + */ +export async function findTailscaleBinary(): Promise { + // Helper to check if a binary exists and is executable + const checkBinary = async (path: string): Promise => { + if (!path || !existsSync(path)) return false; + try { + // Use Promise.race with runExec to implement timeout + await Promise.race([ + runExec(path, ["--version"], { timeoutMs: 3000 }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), 3000), + ), + ]); + return true; + } catch { + return false; + } + }; + + // Strategy 1: which command + try { + const { stdout } = await runExec("which", ["tailscale"]); + const fromPath = stdout.trim(); + if (fromPath && (await checkBinary(fromPath))) { + return fromPath; + } + } catch { + // which failed, continue + } + + // Strategy 2: Known macOS app path + const macAppPath = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + if (await checkBinary(macAppPath)) { + return macAppPath; + } + + // Strategy 3: find command in /Applications + try { + const { stdout } = await runExec( + "find", + [ + "/Applications", + "-maxdepth", + "3", + "-name", + "Tailscale", + "-path", + "*/Tailscale.app/Contents/MacOS/Tailscale", + ], + { timeoutMs: 5000 }, + ); + const found = stdout.trim().split("\n")[0]; + if (found && (await checkBinary(found))) { + return found; + } + } catch { + // find failed, continue + } + + // Strategy 4: locate command + try { + const { stdout } = await runExec("locate", ["Tailscale.app"]); + const candidates = stdout + .trim() + .split("\n") + .filter((line) => + line.includes("/Tailscale.app/Contents/MacOS/Tailscale"), + ); + for (const candidate of candidates) { + if (await checkBinary(candidate)) { + return candidate; + } + } + } catch { + // locate failed, continue + } + + return null; +} + +export async function getTailnetHostname( + exec: typeof runExec = runExec, + detectedBinary?: string, +) { // Derive tailnet hostname (or IP fallback) from tailscale status JSON. - const candidates = [ - "tailscale", - "/Applications/Tailscale.app/Contents/MacOS/Tailscale", - ]; + const candidates = detectedBinary + ? [detectedBinary] + : ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; let lastError: unknown; for (const candidate of candidates) { if (candidate.startsWith("/") && !existsSync(candidate)) continue; try { - const { stdout } = await exec(candidate, ["status", "--json"]); + const { stdout } = await exec(candidate, ["status", "--json"], { + timeoutMs: 5000, + maxBuffer: 400_000, + }); const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; const self = typeof parsed.Self === "object" && parsed.Self !== null @@ -44,7 +137,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) { ? (self.DNSName as string) : undefined; const ips = Array.isArray(self?.TailscaleIPs) - ? (self.TailscaleIPs as string[]) + ? ((parsed.Self as { TailscaleIPs?: string[] }).TailscaleIPs ?? []) : []; if (dns && dns.length > 0) return dns.replace(/\.$/, ""); if (ips.length > 0) return ips[0]; @@ -57,11 +150,24 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) { throw lastError ?? new Error("Could not determine Tailscale DNS or IP"); } +/** + * Get the Tailscale binary command to use. + * Returns a cached detected binary or the default "tailscale" command. + */ +let cachedTailscaleBinary: string | null = null; + +export async function getTailscaleBinary(): Promise { + if (cachedTailscaleBinary) return cachedTailscaleBinary; + cachedTailscaleBinary = await findTailscaleBinary(); + return cachedTailscaleBinary ?? "tailscale"; +} + export async function readTailscaleStatusJson( exec: typeof runExec = runExec, opts?: { timeoutMs?: number }, ): Promise> { - const { stdout } = await exec("tailscale", ["status", "--json"], { + const tailscaleBin = await getTailscaleBinary(); + const { stdout } = await exec(tailscaleBin, ["status", "--json"], { timeoutMs: opts?.timeoutMs ?? 5000, maxBuffer: 400_000, }); @@ -123,8 +229,9 @@ export async function ensureFunnel( ) { // Ensure Funnel is enabled and publish the webhook port. try { + const tailscaleBin = await getTailscaleBinary(); const statusOut = ( - await exec("tailscale", ["funnel", "status", "--json"]) + await exec(tailscaleBin, ["funnel", "status", "--json"]) ).stdout.trim(); const parsed = statusOut ? (JSON.parse(statusOut) as Record) @@ -155,7 +262,7 @@ export async function ensureFunnel( logVerbose(`Enabling funnel on port ${port}…`); const { stdout } = await exec( - "tailscale", + tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], { maxBuffer: 200_000, @@ -216,14 +323,16 @@ export async function enableTailscaleServe( port: number, exec: typeof runExec = runExec, ) { - await exec("tailscale", ["serve", "--bg", "--yes", `${port}`], { + const tailscaleBin = await getTailscaleBinary(); + await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); } export async function disableTailscaleServe(exec: typeof runExec = runExec) { - await exec("tailscale", ["serve", "reset"], { + const tailscaleBin = await getTailscaleBinary(); + await exec(tailscaleBin, ["serve", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -233,14 +342,16 @@ export async function enableTailscaleFunnel( port: number, exec: typeof runExec = runExec, ) { - await exec("tailscale", ["funnel", "--bg", "--yes", `${port}`], { + const tailscaleBin = await getTailscaleBinary(); + await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); } export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { - await exec("tailscale", ["funnel", "reset"], { + const tailscaleBin = await getTailscaleBinary(); + await exec(tailscaleBin, ["funnel", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts index 912f6ff6e..1c83d7ab1 100644 --- a/src/macos/gateway-daemon.ts +++ b/src/macos/gateway-daemon.ts @@ -82,14 +82,14 @@ async function main() { "loopback"; const bind = bindRaw === "loopback" || - bindRaw === "tailnet" || bindRaw === "lan" || - bindRaw === "auto" + bindRaw === "auto" || + bindRaw === "custom" ? bindRaw : null; if (!bind) { defaultRuntime.error( - 'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")', + 'Invalid --bind (use "loopback", "lan", "auto", or "custom")', ); process.exit(1); } diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index b24022d9d..f0b5b6af2 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -62,6 +62,7 @@ import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; +import { findTailscaleBinary } from "../infra/tailscale.js"; import { listProviderPlugins } from "../providers/plugins/index.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -173,14 +174,15 @@ export async function runOnboardingWizard( baseConfig.gateway?.auth?.mode !== undefined || baseConfig.gateway?.auth?.token !== undefined || baseConfig.gateway?.auth?.password !== undefined || + baseConfig.gateway?.customBindHost !== undefined || baseConfig.gateway?.tailscale?.mode !== undefined; const bindRaw = baseConfig.gateway?.bind; const bind = bindRaw === "loopback" || bindRaw === "lan" || - bindRaw === "tailnet" || - bindRaw === "auto" + bindRaw === "auto" || + bindRaw === "custom" ? bindRaw : "loopback"; @@ -212,15 +214,16 @@ export async function runOnboardingWizard( tailscaleMode, token: baseConfig.gateway?.auth?.token, password: baseConfig.gateway?.auth?.password, + customBindHost: baseConfig.gateway?.customBindHost, tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false, }; })(); if (flow === "quickstart") { - const formatBind = (value: "loopback" | "lan" | "tailnet" | "auto") => { + const formatBind = (value: "loopback" | "lan" | "auto" | "custom") => { if (value === "loopback") return "Loopback (127.0.0.1)"; if (value === "lan") return "LAN"; - if (value === "tailnet") return "Tailnet"; + if (value === "custom") return "Custom IP"; return "Auto"; }; const formatAuth = (value: GatewayAuthChoice) => { @@ -238,6 +241,10 @@ export async function runOnboardingWizard( "Keeping your current gateway settings:", `Gateway port: ${quickstartGateway.port}`, `Gateway bind: ${formatBind(quickstartGateway.bind)}`, + ...(quickstartGateway.bind === "custom" && + quickstartGateway.customBindHost + ? [`Gateway custom IP: ${quickstartGateway.customBindHost}`] + : []), `Gateway auth: ${formatAuth(quickstartGateway.authMode)}`, `Tailscale exposure: ${formatTailscale( quickstartGateway.tailscaleMode, @@ -396,11 +403,39 @@ export async function runOnboardingWizard( options: [ { value: "loopback", label: "Loopback (127.0.0.1)" }, { value: "lan", label: "LAN" }, - { value: "tailnet", label: "Tailnet" }, { value: "auto", label: "Auto" }, + { value: "custom", label: "Custom IP" }, ], - })) as "loopback" | "lan" | "tailnet" | "auto") - ) as "loopback" | "lan" | "tailnet" | "auto"; + })) as "loopback" | "lan" | "auto" | "custom") + ) as "loopback" | "lan" | "auto" | "custom"; + + let customBindHost = quickstartGateway.customBindHost; + if (bind === "custom") { + const needsPrompt = flow !== "quickstart" || !customBindHost; + if (needsPrompt) { + const input = await prompter.text({ + message: "Custom IP address", + placeholder: "192.168.1.100", + initialValue: customBindHost ?? "", + validate: (value) => { + if (!value) return "IP address is required for custom bind mode"; + const trimmed = value.trim(); + const parts = trimmed.split("."); + if (parts.length !== 4) + return "Invalid IPv4 address (e.g., 192.168.1.100)"; + if ( + parts.every((part) => { + const n = parseInt(part, 10); + return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); + }) + ) + return undefined; + return "Invalid IPv4 address (each octet must be 0-255)"; + }, + }); + customBindHost = typeof input === "string" ? input.trim() : undefined; + } + } let authMode = ( flow === "quickstart" @@ -445,6 +480,23 @@ export async function runOnboardingWizard( })) as "off" | "serve" | "funnel") ) as "off" | "serve" | "funnel"; + // Detect Tailscale binary before proceeding with serve/funnel setup + if (tailscaleMode !== "off") { + const tailscaleBin = await findTailscaleBinary(); + if (!tailscaleBin) { + await prompter.note( + [ + "Tailscale binary not found in PATH or /Applications.", + "Ensure Tailscale is installed from:", + " https://tailscale.com/download/mac", + "", + "You can continue setup, but serve/funnel will fail at runtime.", + ].join("\n"), + "Tailscale Warning", + ); + } + } + let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false; if (tailscaleMode !== "off" && flow !== "quickstart") { @@ -470,6 +522,7 @@ export async function runOnboardingWizard( "Note", ); bind = "loopback"; + customBindHost = undefined; } if (authMode === "off" && bind !== "loopback") { @@ -538,6 +591,7 @@ export async function runOnboardingWizard( ...nextConfig.gateway, port, bind, + ...(bind === "custom" && customBindHost ? { customBindHost } : {}), tailscale: { ...nextConfig.gateway?.tailscale, mode: tailscaleMode, @@ -747,6 +801,7 @@ export async function runOnboardingWizard( const links = resolveControlUiLinks({ bind, port, + customBindHost, basePath: controlUiBasePath, }); const tokenParam =