Merge pull request #740 from jeffersonwarrior/main

feat: add Tailscale binary detection, custom gateway IP binding, and health probe auth fix
This commit is contained in:
Peter Steinberger
2026-01-13 05:22:48 +00:00
committed by GitHub
23 changed files with 589 additions and 98 deletions

View File

@@ -4,6 +4,7 @@
### Changes
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
## 2026.1.12-4

View File

@@ -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<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) => {
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,

View File

@@ -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,12 @@ 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 +528,7 @@ async function gatherDaemonStatus(opts: {
gateway: {
bindMode,
bindHost,
customBindHost,
port: daemonPort,
portSource,
probeUrl,
@@ -651,6 +657,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 +667,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);

View File

@@ -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;

View File

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

View File

@@ -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,61 @@ 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 +314,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 +411,7 @@ async function promptGatewayConfig(
port,
bind,
auth: authConfig,
...(customBindHost && { customBindHost }),
tailscale: {
...next.gateway?.tailscale,
mode: tailscaleMode,
@@ -943,16 +1007,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})` : ""}`;

View File

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

View File

@@ -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;

View File

@@ -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");
});
});

View File

@@ -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);
});
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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");

View File

@@ -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<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[] {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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}`

View File

@@ -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<string> {
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<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 {

View File

@@ -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";
})();

View File

@@ -22,18 +22,111 @@ function parsePossiblyNoisyJsonObject(stdout: string): 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.
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<string> {
if (cachedTailscaleBinary) return cachedTailscaleBinary;
cachedTailscaleBinary = await findTailscaleBinary();
return cachedTailscaleBinary ?? "tailscale";
}
export async function readTailscaleStatusJson(
exec: typeof runExec = runExec,
opts?: { timeoutMs?: number },
): 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,
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<string, unknown>)
@@ -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,
});

View File

@@ -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);
}

View File

@@ -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,41 @@ 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 +482,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 +524,7 @@ export async function runOnboardingWizard(
"Note",
);
bind = "loopback";
customBindHost = undefined;
}
if (authMode === "off" && bind !== "loopback") {
@@ -538,6 +593,7 @@ export async function runOnboardingWizard(
...nextConfig.gateway,
port,
bind,
...(bind === "custom" && customBindHost ? { customBindHost } : {}),
tailscale: {
...nextConfig.gateway?.tailscale,
mode: tailscaleMode,
@@ -747,6 +803,7 @@ export async function runOnboardingWizard(
const links = resolveControlUiLinks({
bind,
port,
customBindHost,
basePath: controlUiBasePath,
});
const tokenParam =