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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user