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

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