fix(tunnel): detect system-installed Tailscale via dual-socket probe
Tailscale installed via apt/snap/brew was reported "Not Installed/Logged In" because 9Router only probed its custom userspace socket. Probe the system socket (/var/run/tailscale/tailscaled.sock) as fallback, add /usr/sbin and /snap/bin to candidate paths and EXTENDED_PATH, and report separate customDaemonRunning/systemDaemonRunning flags. Caching/non-blocking behavior preserved. Co-authored-by: Stefan Pirker <stefan.pirker86@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,11 +2,11 @@ import os from "os";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel";
|
||||
import { isTailscaleInstalled, isTailscaleLoggedIn, isSystemDaemonRunning, getTailscaleBin, TAILSCALE_SOCKET } from "@/lib/tunnel";
|
||||
import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`;
|
||||
const PROBE_TIMEOUT_MS = 1500;
|
||||
|
||||
async function hasBrew() {
|
||||
@@ -16,9 +16,11 @@ async function hasBrew() {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function isDaemonRunning() {
|
||||
async function isCustomDaemonRunning() {
|
||||
const bin = getTailscaleBin();
|
||||
if (!bin) return false;
|
||||
try {
|
||||
await execAsync(`tailscale --socket ${TAILSCALE_SOCKET} status --json`, {
|
||||
await execAsync(`"${bin}" --socket ${TAILSCALE_SOCKET} status --json`, {
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
timeout: PROBE_TIMEOUT_MS
|
||||
@@ -37,13 +39,15 @@ export async function GET() {
|
||||
const installed = isTailscaleInstalled();
|
||||
const platform = os.platform();
|
||||
// Run independent probes in parallel — none blocks the event loop
|
||||
const [brewAvailable, daemonRunning] = await Promise.all([
|
||||
const [brewAvailable, customDaemonRunning, systemDaemonRunning] = await Promise.all([
|
||||
platform === "darwin" ? hasBrew() : Promise.resolve(false),
|
||||
installed ? isDaemonRunning() : Promise.resolve(false),
|
||||
installed ? isCustomDaemonRunning() : Promise.resolve(false),
|
||||
installed ? Promise.resolve(isSystemDaemonRunning()) : Promise.resolve(false),
|
||||
]);
|
||||
const daemonRunning = customDaemonRunning || systemDaemonRunning;
|
||||
const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
|
||||
const hasCachedPassword = !!(getCachedPassword() || await loadEncryptedPassword());
|
||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, hasCachedPassword });
|
||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, customDaemonRunning, systemDaemonRunning, hasCachedPassword });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export {
|
||||
isTailscaleRunningStrict,
|
||||
isTailscaleLoggedIn,
|
||||
isTailscaleLoggedInStrict,
|
||||
isSystemDaemonRunning,
|
||||
getTailscaleBin,
|
||||
installTailscale,
|
||||
startLogin,
|
||||
startDaemonWithPassword,
|
||||
|
||||
@@ -20,6 +20,10 @@ const TAILSCALE_DIR = path.join(DATA_DIR, "tailscale");
|
||||
export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock");
|
||||
const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET];
|
||||
|
||||
// System daemon socket (sudo install: apt/snap/systemd) — read-only status detection
|
||||
const SYSTEM_TAILSCALE_SOCKET = IS_WINDOWS ? null : "/var/run/tailscale/tailscaled.sock";
|
||||
const SYSTEM_SOCKET_FLAG = SYSTEM_TAILSCALE_SOCKET ? ["--socket", SYSTEM_TAILSCALE_SOCKET] : [];
|
||||
|
||||
// Well-known Windows install path
|
||||
const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe";
|
||||
|
||||
@@ -27,7 +31,9 @@ const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe";
|
||||
const UNIX_TAILSCALE_CANDIDATES = [
|
||||
"/usr/local/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/usr/sbin/tailscale", // apt package on Debian/Ubuntu
|
||||
"/usr/bin/tailscale",
|
||||
"/snap/bin/tailscale", // Snap package
|
||||
];
|
||||
|
||||
// ─── Cache + background refresh (avoid blocking event loop on dead daemon) ──
|
||||
@@ -50,7 +56,7 @@ function bgRefreshBin() {
|
||||
if (binCache.refreshing) return;
|
||||
binCache.refreshing = true;
|
||||
const cmd = IS_WINDOWS ? "where tailscale 2>nul" : "which tailscale 2>/dev/null";
|
||||
execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS })
|
||||
execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS, env: { ...process.env, PATH: EXTENDED_PATH } })
|
||||
.then(({ stdout }) => {
|
||||
const sys = stdout.trim();
|
||||
binCache.value = sys || fallbackBin();
|
||||
@@ -63,7 +69,7 @@ function bgRefreshBin() {
|
||||
}
|
||||
|
||||
// Sync getter: returns cached value, triggers background refresh if stale
|
||||
function getTailscaleBin() {
|
||||
export function getTailscaleBin() {
|
||||
if (Date.now() - binCache.fetchedAt > PROBE_TTL_MS) bgRefreshBin();
|
||||
// First call: synchronously probe common install paths (no exec, no event-loop block)
|
||||
if (binCache.value === undefined) {
|
||||
@@ -116,12 +122,10 @@ function bgRefreshLoggedIn() {
|
||||
return;
|
||||
}
|
||||
loggedInCache.refreshing = true;
|
||||
execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS })
|
||||
.then(({ stdout }) => {
|
||||
try {
|
||||
const json = JSON.parse(stdout);
|
||||
loggedInCache.value = json.BackendState === "Running" && json.Self?.Online === true;
|
||||
} catch { loggedInCache.value = false; }
|
||||
// Dual-socket aware: probe custom socket first, then system socket
|
||||
probeStatusAsync(bin)
|
||||
.then((json) => {
|
||||
loggedInCache.value = !!json && json.BackendState === "Running" && json.Self?.Online === true;
|
||||
})
|
||||
.catch(() => { loggedInCache.value = false; })
|
||||
.finally(() => {
|
||||
@@ -130,6 +134,19 @@ function bgRefreshLoggedIn() {
|
||||
});
|
||||
}
|
||||
|
||||
// Probe `status --json` over custom then system socket. Resolves parsed JSON or null. Never blocks event loop.
|
||||
async function probeStatusAsync(bin) {
|
||||
for (const socketArgs of [SOCKET_FLAG, SYSTEM_SOCKET_FLAG]) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`"${bin}" ${socketArgs.join(" ")} status --json`, {
|
||||
windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
} catch { /* try next socket */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sync getter: never blocks; returns last known state, refreshes in background
|
||||
export function isTailscaleLoggedIn() {
|
||||
if (Date.now() - loggedInCache.fetchedAt > PROBE_TTL_MS) bgRefreshLoggedIn();
|
||||
@@ -185,6 +202,21 @@ export async function isTailscaleRunningStrict() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a system-level tailscaled is running (uses system socket, not 9Router's custom one).
|
||||
export function isSystemDaemonRunning() {
|
||||
if (IS_WINDOWS || !SYSTEM_TAILSCALE_SOCKET || !fs.existsSync(SYSTEM_TAILSCALE_SOCKET)) return false;
|
||||
const bin = getTailscaleBin();
|
||||
if (!bin) return false;
|
||||
try {
|
||||
const out = execSync(`"${bin}" ${SYSTEM_SOCKET_FLAG.join(" ")} status --json`, {
|
||||
encoding: "utf8", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return JSON.parse(out).BackendState === "Running";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function bgRefreshFunnelUrl(port) {
|
||||
if (funnelUrlCache.refreshing) return;
|
||||
const bin = getTailscaleBin();
|
||||
@@ -253,7 +285,7 @@ export async function installTailscale(sudoPassword, hostname, onProgress) {
|
||||
return startLogin(hostname);
|
||||
}
|
||||
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`;
|
||||
|
||||
function hasBrew() {
|
||||
try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; }
|
||||
|
||||
Reference in New Issue
Block a user