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:
committed by
Peter Steinberger
parent
f94ad21f1e
commit
c851bdd47a
@@ -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})` : ""}`;
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("dashboardCommand", () => {
|
||||
expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
customBindHost: undefined,
|
||||
basePath: undefined,
|
||||
});
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user