feat: add Tailscale binary detection, IP binding modes, and health probe password fix

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
This commit is contained in:
Jefferson Warrior
2026-01-11 14:13:13 -06:00
committed by Peter Steinberger
parent f94ad21f1e
commit c851bdd47a
22 changed files with 587 additions and 98 deletions

View File

@@ -417,6 +417,85 @@ type EmbeddedPiQueueHandle = {
const log = createSubsystemLogger("agent/embedded"); const log = createSubsystemLogger("agent/embedded");
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap"; 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<string, unknown>;
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) => { registerUnhandledRejectionHandler((reason) => {
const message = describeUnknownError(reason); const message = describeUnknownError(reason);
@@ -1178,6 +1257,7 @@ export async function compactEmbeddedPiSession(params: {
modelAuthMode: resolveModelAuthMode(model.provider, params.config), modelAuthMode: resolveModelAuthMode(model.provider, params.config),
// No currentChannelId/currentThreadTs for compaction - not in message context // No currentChannelId/currentThreadTs for compaction - not in message context
}); });
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
const runtimeProvider = normalizeMessageProvider( const runtimeProvider = normalizeMessageProvider(
params.messageProvider, params.messageProvider,
@@ -1620,6 +1700,7 @@ export async function runEmbeddedPiAgent(params: {
replyToMode: params.replyToMode, replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef, hasRepliedRef: params.hasRepliedRef,
}); });
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
const runtimeInfo = { const runtimeInfo = {
host: machineName, host: machineName,

View File

@@ -71,7 +71,8 @@ type ConfigSummary = {
type GatewayStatusSummary = { type GatewayStatusSummary = {
bindMode: BridgeBindMode; bindMode: BridgeBindMode;
bindHost: string | null; bindHost: string;
customBindHost?: string;
port: number; port: number;
portSource: "service args" | "env/config"; portSource: "service args" | "env/config";
probeUrl: string; probeUrl: string;
@@ -190,8 +191,11 @@ function parsePortFromArgs(
function pickProbeHostForBind( function pickProbeHostForBind(
bindMode: string, bindMode: string,
tailnetIPv4: string | undefined, 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"; if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1";
return "127.0.0.1"; return "127.0.0.1";
} }
@@ -429,11 +433,15 @@ async function gatherDaemonStatus(opts: {
const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as
| "auto" | "auto"
| "lan" | "lan"
| "tailnet" | "loopback"
| "loopback"; | "custom";
const bindHost = resolveGatewayBindHost(bindMode); const customBindHost = daemonCfg.gateway?.customBindHost;
const bindHost = await resolveGatewayBindHost(
bindMode,
customBindHost,
);
const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv4 = pickPrimaryTailnetIPv4();
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4); const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
const probeUrlOverride = const probeUrlOverride =
typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0 typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0
? opts.rpc.url.trim() ? opts.rpc.url.trim()
@@ -523,6 +531,7 @@ async function gatherDaemonStatus(opts: {
gateway: { gateway: {
bindMode, bindMode,
bindHost, bindHost,
customBindHost,
port: daemonPort, port: daemonPort,
portSource, portSource,
probeUrl, probeUrl,
@@ -651,6 +660,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
const links = resolveControlUiLinks({ const links = resolveControlUiLinks({
port: status.gateway.port, port: status.gateway.port,
bind: status.gateway.bindMode, bind: status.gateway.bindMode,
customBindHost: status.gateway.customBindHost,
basePath: status.config?.daemon?.controlUi?.basePath, basePath: status.config?.daemon?.controlUi?.basePath,
}); });
defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`); 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)}`, `${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(); spacer();
} }
const runtimeLine = formatRuntimeStatus(service.runtime); const runtimeLine = formatRuntimeStatus(service.runtime);

View File

@@ -678,14 +678,14 @@ async function runGatewayCommand(
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback"; const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
const bind = const bind =
bindRaw === "loopback" || bindRaw === "loopback" ||
bindRaw === "tailnet" ||
bindRaw === "lan" || bindRaw === "lan" ||
bindRaw === "auto" bindRaw === "auto" ||
bindRaw === "custom"
? bindRaw ? bindRaw
: null; : null;
if (!bind) { if (!bind) {
defaultRuntime.error( defaultRuntime.error(
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")', 'Invalid --bind (use "loopback", "lan", "auto", or "custom")',
); );
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;

View File

@@ -292,7 +292,7 @@ export function buildProgram() {
.option("--synthetic-api-key <key>", "Synthetic API key") .option("--synthetic-api-key <key>", "Synthetic API key")
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key") .option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
.option("--gateway-port <port>", "Gateway port") .option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto") .option("--gateway-bind <mode>", "Gateway bind: loopback|lan|auto|custom")
.option("--gateway-auth <mode>", "Gateway auth: off|token|password") .option("--gateway-auth <mode>", "Gateway auth: off|token|password")
.option("--gateway-token <token>", "Gateway token (token auth)") .option("--gateway-token <token>", "Gateway token (token auth)")
.option("--gateway-password <password>", "Gateway password (password auth)") .option("--gateway-password <password>", "Gateway password (password auth)")
@@ -369,8 +369,8 @@ export function buildProgram() {
gatewayBind: opts.gatewayBind as gatewayBind: opts.gatewayBind as
| "loopback" | "loopback"
| "lan" | "lan"
| "tailnet"
| "auto" | "auto"
| "custom"
| undefined, | undefined,
gatewayAuth: opts.gatewayAuth as gatewayAuth: opts.gatewayAuth as
| "off" | "off"

View File

@@ -25,6 +25,7 @@ import {
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import { listChatProviders } from "../providers/registry.js"; import { listChatProviders } from "../providers/registry.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -220,16 +221,59 @@ async function promptGatewayConfig(
let bind = guardCancel( let bind = guardCancel(
await select({ await select({
message: "Gateway bind", message: "Gateway bind mode",
options: [ options: [
{ value: "loopback", label: "Loopback (127.0.0.1)" }, {
{ value: "lan", label: "LAN" }, value: "auto",
{ value: "tailnet", label: "Tailnet" }, label: "Auto (Tailnet → LAN)",
{ value: "auto", label: "Auto" }, 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, 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( let authMode = guardCancel(
await select({ await select({
@@ -268,6 +312,23 @@ async function promptGatewayConfig(
runtime, runtime,
) 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) {
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; let tailscaleResetOnExit = false;
if (tailscaleMode !== "off") { if (tailscaleMode !== "off") {
note( note(
@@ -348,6 +409,7 @@ async function promptGatewayConfig(
port, port,
bind, bind,
auth: authConfig, auth: authConfig,
...(customBindHost && { customBindHost }),
tailscale: { tailscale: {
...next.gateway?.tailscale, ...next.gateway?.tailscale,
mode: tailscaleMode, mode: tailscaleMode,
@@ -943,16 +1005,32 @@ export async function runConfigureWizard(
const links = resolveControlUiLinks({ const links = resolveControlUiLinks({
bind, bind,
port: gatewayPort, port: gatewayPort,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: nextConfig.gateway?.controlUi?.basePath, 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, url: links.wsUrl,
token: token,
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, password: newPassword,
password:
nextConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD,
}); });
// 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 const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable" ? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;

View File

@@ -78,6 +78,7 @@ describe("dashboardCommand", () => {
expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({
port: 18789, port: 18789,
bind: "loopback", bind: "loopback",
customBindHost: undefined,
basePath: undefined, basePath: undefined,
}); });
expect(mocks.copyToClipboard).toHaveBeenCalledWith( expect(mocks.copyToClipboard).toHaveBeenCalledWith(

View File

@@ -25,10 +25,16 @@ export async function dashboardCommand(
const port = resolveGatewayPort(cfg); const port = resolveGatewayPort(cfg);
const bind = cfg.gateway?.bind ?? "loopback"; const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath; const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost;
const token = const token =
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_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 const authedUrl = token
? `${links.httpUrl}?token=${encodeURIComponent(token)}` ? `${links.httpUrl}?token=${encodeURIComponent(token)}`
: links.httpUrl; : links.httpUrl;

View File

@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest"; 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(() => ({ const mocks = vi.hoisted(() => ({
runCommandWithTimeout: vi.fn(async () => ({ runCommandWithTimeout: vi.fn(async () => ({
@@ -10,12 +14,17 @@ const mocks = vi.hoisted(() => ({
signal: null, signal: null,
killed: false, killed: false,
})), })),
pickPrimaryTailnetIPv4: vi.fn(() => undefined),
})); }));
vi.mock("../process/exec.js", () => ({ vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: mocks.runCommandWithTimeout, runCommandWithTimeout: mocks.runCommandWithTimeout,
})); }));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
}));
describe("openUrl", () => { describe("openUrl", () => {
it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => { it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32"); vi.spyOn(process, "platform", "get").mockReturnValue("win32");
@@ -45,3 +54,25 @@ describe("resolveBrowserOpenCommand", () => {
expect(resolved.quoteUrl).toBe(true); 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");
});
});

View File

@@ -410,16 +410,21 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
export function resolveControlUiLinks(params: { export function resolveControlUiLinks(params: {
port: number; port: number;
bind?: "auto" | "lan" | "tailnet" | "loopback"; bind?: "auto" | "lan" | "loopback" | "custom";
customBindHost?: string;
basePath?: string; basePath?: string;
}): { httpUrl: string; wsUrl: string } { }): { httpUrl: string; wsUrl: string } {
const port = params.port; const port = params.port;
const bind = params.bind ?? "loopback"; const bind = params.bind ?? "loopback";
const customBindHost = params.customBindHost?.trim();
const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv4 = pickPrimaryTailnetIPv4();
const host = const host = (() => {
bind === "tailnet" || (bind === "auto" && tailnetIPv4) if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
? (tailnetIPv4 ?? "127.0.0.1") return customBindHost;
: "127.0.0.1"; }
if (bind === "auto" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1";
return "127.0.0.1";
})();
const basePath = normalizeControlUiBasePath(params.basePath); const basePath = normalizeControlUiBasePath(params.basePath);
const uiPath = basePath ? `${basePath}/` : "/"; const uiPath = basePath ? `${basePath}/` : "/";
const wsPath = basePath ? basePath : ""; const wsPath = basePath ? basePath : "";
@@ -428,3 +433,12 @@ export function resolveControlUiLinks(params: {
wsUrl: `ws://${host}:${port}${wsPath}`, 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);
});
}

View File

@@ -28,7 +28,7 @@ export type AuthChoice =
| "skip"; | "skip";
export type GatewayAuthChoice = "off" | "token" | "password"; export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full"; 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 TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ProviderChoice = ChatProviderId; export type ProviderChoice = ChatProviderId;

View File

@@ -271,6 +271,7 @@ export async function statusAllCommand(
? resolveControlUiLinks({ ? resolveControlUiLinks({
port, port,
bind: cfg.gateway?.bind, bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
basePath: cfg.gateway?.controlUi?.basePath, basePath: cfg.gateway?.controlUi?.basePath,
}).httpUrl }).httpUrl
: null; : null;

View File

@@ -818,6 +818,7 @@ export async function statusCommand(
const links = resolveControlUiLinks({ const links = resolveControlUiLinks({
port: resolveGatewayPort(cfg), port: resolveGatewayPort(cfg),
bind: cfg.gateway?.bind, bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
basePath: cfg.gateway?.controlUi?.basePath, basePath: cfg.gateway?.controlUi?.basePath,
}); });
return links.httpUrl; return links.httpUrl;

View File

@@ -1247,6 +1247,23 @@ describe("legacy config detection", () => {
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined(); 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 () => { it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules(); vi.resetModules();
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");

View File

@@ -891,6 +891,29 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
delete raw.identity; delete raw.identity;
}, },
}, },
{
id: "bind-tailnet->auto",
describe: "Remap gateway/bridge 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");
const bridge = getRecord(raw.bridge);
migrateBind(bridge, "bridge");
},
},
]; ];
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {

View File

@@ -1244,17 +1244,17 @@ export type ProviderCommandsConfig = {
native?: NativeCommandsSetting; native?: NativeCommandsSetting;
}; };
export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom";
export type BridgeConfig = { export type BridgeConfig = {
enabled?: boolean; enabled?: boolean;
port?: number; port?: number;
/** /**
* Bind address policy for the node bridge server. * Bind address policy for the node bridge server.
* - auto: prefer tailnet IP when present, else LAN (0.0.0.0) * - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
* - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces) * - lan: 0.0.0.0 (all interfaces, no fallback)
* - tailnet: bind to the Tailscale interface IP (100.64.0.0/10) plus loopback * - loopback: 127.0.0.1 (local-only)
* - loopback: 127.0.0.1 * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
*/ */
bind?: BridgeBindMode; bind?: BridgeBindMode;
}; };
@@ -1369,9 +1369,15 @@ export type GatewayConfig = {
mode?: "local" | "remote"; mode?: "local" | "remote";
/** /**
* Bind address policy for the Gateway WebSocket + Control UI HTTP server. * 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). * Default: loopback (127.0.0.1).
*/ */
bind?: BridgeBindMode; bind?: BridgeBindMode;
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
customBindHost?: string;
controlUi?: GatewayControlUiConfig; controlUi?: GatewayControlUiConfig;
auth?: GatewayAuthConfig; auth?: GatewayAuthConfig;
tailscale?: GatewayTailscaleConfig; tailscale?: GatewayTailscaleConfig;

View File

@@ -74,8 +74,8 @@ describe("callGateway url resolution", () => {
closeReason = ""; closeReason = "";
}); });
it("uses tailnet IP when local bind is tailnet", async () => { it("uses tailnet IP when local bind is auto and tailnet is present", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } }); loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
resolveGatewayPort.mockReturnValue(18800); resolveGatewayPort.mockReturnValue(18800);
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
@@ -84,16 +84,6 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800"); 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 () => { it("falls back to loopback when local bind is auto without tailnet IP", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
resolveGatewayPort.mockReturnValue(18800); resolveGatewayPort.mockReturnValue(18800);

View File

@@ -59,8 +59,7 @@ export function buildGatewayConnectionDetails(
const localPort = resolveGatewayPort(config); const localPort = resolveGatewayPort(config);
const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv4 = pickPrimaryTailnetIPv4();
const bindMode = config.gateway?.bind ?? "loopback"; const bindMode = config.gateway?.bind ?? "loopback";
const preferTailnet = const preferTailnet = bindMode === "auto" && !!tailnetIPv4;
bindMode === "tailnet" || (bindMode === "auto" && !!tailnetIPv4);
const localUrl = const localUrl =
preferTailnet && tailnetIPv4 preferTailnet && tailnetIPv4
? `ws://${tailnetIPv4}:${localPort}` ? `ws://${tailnetIPv4}:${localPort}`

View File

@@ -1,3 +1,5 @@
import net from "node:net";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
export function isLoopbackAddress(ip: string | undefined): boolean { export function isLoopbackAddress(ip: string | undefined): boolean {
@@ -9,15 +11,86 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
return false; 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, bind: import("../config/config.js").BridgeBindMode | undefined,
): string | null { customHost?: string,
): Promise<string> {
const mode = bind ?? "loopback"; const mode = bind ?? "loopback";
if (mode === "loopback") return "127.0.0.1";
if (mode === "lan") return "0.0.0.0"; if (mode === "loopback") {
if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null; // 127.0.0.1 rarely fails, but handle gracefully
if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0"; if (await canBindTo("127.0.0.1")) return "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<boolean> {
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 { export function isLoopbackHost(host: string): boolean {

View File

@@ -490,12 +490,9 @@ export async function startGatewayServer(
} }
let pluginServices: PluginServicesHandle | null = null; let pluginServices: PluginServicesHandle | null = null;
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); const customBindHost = cfgAtStart.gateway?.customBindHost;
if (!bindHost) { const bindHost =
throw new Error( opts.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
"gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway",
);
}
const controlUiEnabled = const controlUiEnabled =
opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true;
const openAiChatCompletionsEnabled = const openAiChatCompletionsEnabled =
@@ -960,18 +957,20 @@ export async function startGatewayServer(
} }
const bind = const bind =
cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "tailnet" : "lan"); cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
if (bind === "loopback") return "127.0.0.1"; if (bind === "loopback") return "127.0.0.1";
if (bind === "lan") return "0.0.0.0"; if (bind === "lan") return "0.0.0.0";
const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv4 = pickPrimaryTailnetIPv4();
const tailnetIPv6 = pickPrimaryTailnetIPv6(); const tailnetIPv6 = pickPrimaryTailnetIPv6();
if (bind === "tailnet") {
return tailnetIPv4 ?? tailnetIPv6 ?? null;
}
if (bind === "auto") { if (bind === "auto") {
return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0"; 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"; return "0.0.0.0";
})(); })();

View File

@@ -22,18 +22,111 @@ function parsePossiblyNoisyJsonObject(stdout: string): Record<string, unknown> {
return JSON.parse(trimmed) as Record<string, unknown>; return JSON.parse(trimmed) as Record<string, unknown>;
} }
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<string | null> {
// Helper to check if a binary exists and is executable
const checkBinary = async (path: string): Promise<boolean> => {
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<never>((_, 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. // Derive tailnet hostname (or IP fallback) from tailscale status JSON.
const candidates = [ const candidates = detectedBinary
"tailscale", ? [detectedBinary]
"/Applications/Tailscale.app/Contents/MacOS/Tailscale", : ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
];
let lastError: unknown; let lastError: unknown;
for (const candidate of candidates) { for (const candidate of candidates) {
if (candidate.startsWith("/") && !existsSync(candidate)) continue; if (candidate.startsWith("/") && !existsSync(candidate)) continue;
try { 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 parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
const self = const self =
typeof parsed.Self === "object" && parsed.Self !== null typeof parsed.Self === "object" && parsed.Self !== null
@@ -44,7 +137,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) {
? (self.DNSName as string) ? (self.DNSName as string)
: undefined; : undefined;
const ips = Array.isArray(self?.TailscaleIPs) 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 (dns && dns.length > 0) return dns.replace(/\.$/, "");
if (ips.length > 0) return ips[0]; 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"); 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<string> {
if (cachedTailscaleBinary) return cachedTailscaleBinary;
cachedTailscaleBinary = await findTailscaleBinary();
return cachedTailscaleBinary ?? "tailscale";
}
export async function readTailscaleStatusJson( export async function readTailscaleStatusJson(
exec: typeof runExec = runExec, exec: typeof runExec = runExec,
opts?: { timeoutMs?: number }, opts?: { timeoutMs?: number },
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const { stdout } = await exec("tailscale", ["status", "--json"], { const tailscaleBin = await getTailscaleBinary();
const { stdout } = await exec(tailscaleBin, ["status", "--json"], {
timeoutMs: opts?.timeoutMs ?? 5000, timeoutMs: opts?.timeoutMs ?? 5000,
maxBuffer: 400_000, maxBuffer: 400_000,
}); });
@@ -123,8 +229,9 @@ export async function ensureFunnel(
) { ) {
// Ensure Funnel is enabled and publish the webhook port. // Ensure Funnel is enabled and publish the webhook port.
try { try {
const tailscaleBin = await getTailscaleBinary();
const statusOut = ( const statusOut = (
await exec("tailscale", ["funnel", "status", "--json"]) await exec(tailscaleBin, ["funnel", "status", "--json"])
).stdout.trim(); ).stdout.trim();
const parsed = statusOut const parsed = statusOut
? (JSON.parse(statusOut) as Record<string, unknown>) ? (JSON.parse(statusOut) as Record<string, unknown>)
@@ -155,7 +262,7 @@ export async function ensureFunnel(
logVerbose(`Enabling funnel on port ${port}`); logVerbose(`Enabling funnel on port ${port}`);
const { stdout } = await exec( const { stdout } = await exec(
"tailscale", tailscaleBin,
["funnel", "--yes", "--bg", `${port}`], ["funnel", "--yes", "--bg", `${port}`],
{ {
maxBuffer: 200_000, maxBuffer: 200_000,
@@ -216,14 +323,16 @@ export async function enableTailscaleServe(
port: number, port: number,
exec: typeof runExec = runExec, 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, maxBuffer: 200_000,
timeoutMs: 15_000, timeoutMs: 15_000,
}); });
} }
export async function disableTailscaleServe(exec: typeof runExec = runExec) { 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, maxBuffer: 200_000,
timeoutMs: 15_000, timeoutMs: 15_000,
}); });
@@ -233,14 +342,16 @@ export async function enableTailscaleFunnel(
port: number, port: number,
exec: typeof runExec = runExec, 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, maxBuffer: 200_000,
timeoutMs: 15_000, timeoutMs: 15_000,
}); });
} }
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { 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, maxBuffer: 200_000,
timeoutMs: 15_000, timeoutMs: 15_000,
}); });

View File

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

View File

@@ -62,6 +62,7 @@ import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildServiceEnvironment } from "../daemon/service-env.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import { listProviderPlugins } from "../providers/plugins/index.js"; import { listProviderPlugins } from "../providers/plugins/index.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -173,14 +174,15 @@ export async function runOnboardingWizard(
baseConfig.gateway?.auth?.mode !== undefined || baseConfig.gateway?.auth?.mode !== undefined ||
baseConfig.gateway?.auth?.token !== undefined || baseConfig.gateway?.auth?.token !== undefined ||
baseConfig.gateway?.auth?.password !== undefined || baseConfig.gateway?.auth?.password !== undefined ||
baseConfig.gateway?.customBindHost !== undefined ||
baseConfig.gateway?.tailscale?.mode !== undefined; baseConfig.gateway?.tailscale?.mode !== undefined;
const bindRaw = baseConfig.gateway?.bind; const bindRaw = baseConfig.gateway?.bind;
const bind = const bind =
bindRaw === "loopback" || bindRaw === "loopback" ||
bindRaw === "lan" || bindRaw === "lan" ||
bindRaw === "tailnet" || bindRaw === "auto" ||
bindRaw === "auto" bindRaw === "custom"
? bindRaw ? bindRaw
: "loopback"; : "loopback";
@@ -212,15 +214,16 @@ export async function runOnboardingWizard(
tailscaleMode, tailscaleMode,
token: baseConfig.gateway?.auth?.token, token: baseConfig.gateway?.auth?.token,
password: baseConfig.gateway?.auth?.password, password: baseConfig.gateway?.auth?.password,
customBindHost: baseConfig.gateway?.customBindHost,
tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false, tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false,
}; };
})(); })();
if (flow === "quickstart") { 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 === "loopback") return "Loopback (127.0.0.1)";
if (value === "lan") return "LAN"; if (value === "lan") return "LAN";
if (value === "tailnet") return "Tailnet"; if (value === "custom") return "Custom IP";
return "Auto"; return "Auto";
}; };
const formatAuth = (value: GatewayAuthChoice) => { const formatAuth = (value: GatewayAuthChoice) => {
@@ -238,6 +241,10 @@ export async function runOnboardingWizard(
"Keeping your current gateway settings:", "Keeping your current gateway settings:",
`Gateway port: ${quickstartGateway.port}`, `Gateway port: ${quickstartGateway.port}`,
`Gateway bind: ${formatBind(quickstartGateway.bind)}`, `Gateway bind: ${formatBind(quickstartGateway.bind)}`,
...(quickstartGateway.bind === "custom" &&
quickstartGateway.customBindHost
? [`Gateway custom IP: ${quickstartGateway.customBindHost}`]
: []),
`Gateway auth: ${formatAuth(quickstartGateway.authMode)}`, `Gateway auth: ${formatAuth(quickstartGateway.authMode)}`,
`Tailscale exposure: ${formatTailscale( `Tailscale exposure: ${formatTailscale(
quickstartGateway.tailscaleMode, quickstartGateway.tailscaleMode,
@@ -396,11 +403,39 @@ export async function runOnboardingWizard(
options: [ options: [
{ value: "loopback", label: "Loopback (127.0.0.1)" }, { value: "loopback", label: "Loopback (127.0.0.1)" },
{ value: "lan", label: "LAN" }, { value: "lan", label: "LAN" },
{ value: "tailnet", label: "Tailnet" },
{ value: "auto", label: "Auto" }, { value: "auto", label: "Auto" },
{ value: "custom", label: "Custom IP" },
], ],
})) as "loopback" | "lan" | "tailnet" | "auto") })) as "loopback" | "lan" | "auto" | "custom")
) as "loopback" | "lan" | "tailnet" | "auto"; ) 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 = ( let authMode = (
flow === "quickstart" flow === "quickstart"
@@ -445,6 +480,23 @@ export async function runOnboardingWizard(
})) as "off" | "serve" | "funnel") })) as "off" | "serve" | "funnel")
) 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 = let tailscaleResetOnExit =
flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false; flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false;
if (tailscaleMode !== "off" && flow !== "quickstart") { if (tailscaleMode !== "off" && flow !== "quickstart") {
@@ -470,6 +522,7 @@ export async function runOnboardingWizard(
"Note", "Note",
); );
bind = "loopback"; bind = "loopback";
customBindHost = undefined;
} }
if (authMode === "off" && bind !== "loopback") { if (authMode === "off" && bind !== "loopback") {
@@ -538,6 +591,7 @@ export async function runOnboardingWizard(
...nextConfig.gateway, ...nextConfig.gateway,
port, port,
bind, bind,
...(bind === "custom" && customBindHost ? { customBindHost } : {}),
tailscale: { tailscale: {
...nextConfig.gateway?.tailscale, ...nextConfig.gateway?.tailscale,
mode: tailscaleMode, mode: tailscaleMode,
@@ -747,6 +801,7 @@ export async function runOnboardingWizard(
const links = resolveControlUiLinks({ const links = resolveControlUiLinks({
bind, bind,
port, port,
customBindHost,
basePath: controlUiBasePath, basePath: controlUiBasePath,
}); });
const tokenParam = const tokenParam =