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

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