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:
Stefan Pirker
2026-06-08 10:14:59 +07:00
committed by decolua
parent 8e31b5ff2f
commit 24a4f0863f
3 changed files with 54 additions and 16 deletions

View File

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

View File

@@ -30,6 +30,8 @@ export {
isTailscaleRunningStrict,
isTailscaleLoggedIn,
isTailscaleLoggedInStrict,
isSystemDaemonRunning,
getTailscaleBin,
installTailscale,
startLogin,
startDaemonWithPassword,

View File

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