Feat : Tailscale

This commit is contained in:
decolua
2026-04-11 11:36:33 +07:00
parent 838d9a7a04
commit ed17a8ffac
18 changed files with 1433 additions and 388 deletions
+3
View File
@@ -55,6 +55,8 @@ const defaultData = {
cloudEnabled: false,
tunnelEnabled: false,
tunnelUrl: "",
tailscaleEnabled: false,
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
providerStrategies: {},
comboStrategy: "fallback",
@@ -91,6 +93,7 @@ function cloneDefaultData() {
cloudEnabled: false,
tunnelEnabled: false,
tunnelUrl: "",
tunnelProvider: "cloudflare",
stickyRoundRobinLimit: 3,
providerStrategies: {},
comboStrategy: "fallback",
+39 -5
View File
@@ -4,7 +4,8 @@ import os from "os";
const TUNNEL_DIR = path.join(os.homedir(), ".9router", "tunnel");
const STATE_FILE = path.join(TUNNEL_DIR, "state.json");
const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
const CLOUDFLARED_PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
const TAILSCALE_PID_FILE = path.join(TUNNEL_DIR, "tailscale.pid");
function ensureDir() {
if (!fs.existsSync(TUNNEL_DIR)) {
@@ -32,15 +33,16 @@ export function clearState() {
} catch (e) { /* ignore */ }
}
// Cloudflare-specific PID
export function savePid(pid) {
ensureDir();
fs.writeFileSync(PID_FILE, pid.toString());
fs.writeFileSync(CLOUDFLARED_PID_FILE, pid.toString());
}
export function loadPid() {
try {
if (fs.existsSync(PID_FILE)) {
return parseInt(fs.readFileSync(PID_FILE, "utf8"));
if (fs.existsSync(CLOUDFLARED_PID_FILE)) {
return parseInt(fs.readFileSync(CLOUDFLARED_PID_FILE, "utf8"));
}
} catch (e) { /* ignore */ }
return null;
@@ -48,6 +50,38 @@ export function loadPid() {
export function clearPid() {
try {
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
if (fs.existsSync(CLOUDFLARED_PID_FILE)) fs.unlinkSync(CLOUDFLARED_PID_FILE);
} catch (e) { /* ignore */ }
}
// Tailscale-specific PID
export function saveTailscalePid(pid) {
ensureDir();
fs.writeFileSync(TAILSCALE_PID_FILE, pid.toString());
}
export function loadTailscalePid() {
try {
if (fs.existsSync(TAILSCALE_PID_FILE)) {
return parseInt(fs.readFileSync(TAILSCALE_PID_FILE, "utf8"));
}
} catch (e) { /* ignore */ }
return null;
}
export function clearTailscalePid() {
try {
if (fs.existsSync(TAILSCALE_PID_FILE)) fs.unlinkSync(TAILSCALE_PID_FILE);
} catch (e) { /* ignore */ }
}
const SHORT_ID_LENGTH = 6;
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
export function generateShortId() {
let result = "";
for (let i = 0; i < SHORT_ID_LENGTH; i++) {
result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
}
return result;
}
+442
View File
@@ -0,0 +1,442 @@
import fs from "fs";
import path from "path";
import os from "os";
import { execSync, spawn } from "child_process";
import { execWithPassword, executeElevatedPowerShell } from "@/mitm/dns/dnsConfig";
import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js";
const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
const IS_MAC = os.platform() === "darwin";
const IS_LINUX = os.platform() === "linux";
const IS_WINDOWS = os.platform() === "win32";
const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale");
// Custom socket for userspace-networking mode (no root required)
const TAILSCALE_DIR = path.join(os.homedir(), ".9router", "tailscale");
export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock");
const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET];
// Prefer system tailscale, fallback to local bin
function getTailscaleBin() {
try {
const systemPath = execSync("which tailscale 2>/dev/null || where tailscale 2>nul", { encoding: "utf8" }).trim();
if (systemPath) return systemPath;
} catch (e) { /* not in PATH */ }
if (fs.existsSync(TAILSCALE_BIN)) return TAILSCALE_BIN;
return null;
}
export function isTailscaleInstalled() {
return getTailscaleBin() !== null;
}
/** Build tailscale CLI args with custom socket (no root needed) */
function tsArgs(...args) {
return [...SOCKET_FLAG, ...args];
}
export function isTailscaleLoggedIn() {
const bin = getTailscaleBin();
if (!bin) return false;
try {
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
encoding: "utf8",
env: { ...process.env, PATH: EXTENDED_PATH },
timeout: 5000
});
const json = JSON.parse(out);
// BackendState "Running" means fully logged in and connected
return json.BackendState === "Running";
} catch (e) {
return false;
}
}
export function isTailscaleRunning() {
const bin = getTailscaleBin();
if (!bin) return false;
try {
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json 2>/dev/null`, { encoding: "utf8" });
const json = JSON.parse(out);
return Object.keys(json.AllowFunnel || {}).length > 0;
} catch (e) {
return false;
}
}
/** Get funnel URL from tailscale status */
export function getTailscaleFunnelUrl(port) {
const bin = getTailscaleBin();
if (!bin) return null;
try {
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { encoding: "utf8" });
const json = JSON.parse(out);
const dnsName = json.Self?.DNSName?.replace(/\.$/, "");
if (dnsName) return `https://${dnsName}`;
} catch (e) { /* ignore */ }
return null;
}
/**
* Install tailscale.
* - macOS + brew: brew install tailscale (no sudo needed)
* - macOS no brew: download .pkg then sudo installer -pkg
* - Linux: fetch install.sh, pipe to sudo -S sh via stdin
* - Windows: download MSI via UAC-elevated PowerShell
*/
export async function installTailscale(sudoPassword, hostname, onProgress) {
const log = onProgress || (() => {});
if (IS_WINDOWS) await installTailscaleWindows(log);
else if (IS_MAC) await installTailscaleMac(sudoPassword, log);
else await installTailscaleLinux(sudoPassword, log);
log("Starting daemon...");
await startDaemonWithPassword(sudoPassword);
log("Logging in...");
return startLogin(hostname);
}
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
function hasBrew() {
try { execSync("which brew", { stdio: "ignore", env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; }
}
async function installTailscaleMac(sudoPassword, log) {
if (hasBrew()) {
log("Installing via Homebrew...");
await new Promise((resolve, reject) => {
const child = spawn("brew", ["install", "tailscale"], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PATH: EXTENDED_PATH }
});
child.stdout.on("data", (d) => {
const line = d.toString().trim();
if (line) log(line);
});
child.stderr.on("data", (d) => {
const line = d.toString().trim();
if (line) log(line);
});
child.on("close", (c) => {
if (c === 0) resolve();
else reject(new Error(`brew install failed (code ${c})`));
});
child.on("error", reject);
});
return;
}
// No brew: download .pkg and install via sudo installer
const pkgUrl = "https://pkgs.tailscale.com/stable/tailscale-latest.pkg";
const pkgPath = path.join(os.tmpdir(), "tailscale.pkg");
log("Downloading Tailscale package...");
await new Promise((resolve, reject) => {
const child = spawn("curl", ["-fL", "--progress-bar", pkgUrl, "-o", pkgPath], {
stdio: ["ignore", "pipe", "pipe"]
});
child.stderr.on("data", (d) => {
const line = d.toString().trim();
if (line) log(line);
});
child.on("close", (c) => {
if (c === 0) resolve();
else reject(new Error("Download failed"));
});
child.on("error", reject);
});
log("Installing package...");
await new Promise((resolve, reject) => {
const child = spawn("sudo", ["-S", "installer", "-pkg", pkgPath, "-target", "/"], {
stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (d) => { stderr += d.toString(); });
child.stdout.on("data", (d) => {
const line = d.toString().trim();
if (line) log(line);
});
child.on("close", (c) => {
try { execSync(`rm -f ${pkgPath}`, { stdio: "ignore" }); } catch { /* ignore */ }
if (c === 0) resolve();
else {
const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry"))
? "Wrong sudo password"
: stderr || `Exit code ${c}`;
reject(new Error(msg));
}
});
child.on("error", reject);
child.stdin.write(`${sudoPassword}\n`);
child.stdin.end();
});
}
async function installTailscaleLinux(sudoPassword, log) {
log("Downloading install script...");
return new Promise((resolve, reject) => {
const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], {
stdio: ["ignore", "pipe", "pipe"]
});
let scriptContent = "";
let curlErr = "";
curlChild.stdout.on("data", (d) => { scriptContent += d.toString(); });
curlChild.stderr.on("data", (d) => { curlErr += d.toString(); });
curlChild.on("exit", (code) => {
if (code !== 0) return reject(new Error(`Failed to download install script: ${curlErr}`));
log("Running install script...");
const child = spawn("sudo", ["-S", "sh"], { stdio: ["pipe", "pipe", "pipe"] });
let stderr = "";
child.stdout.on("data", (d) => {
const line = d.toString().trim();
if (line) log(line);
});
child.stderr.on("data", (d) => { stderr += d.toString(); });
child.on("close", (c) => {
if (c === 0) resolve();
else {
const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry"))
? "Wrong sudo password"
: stderr || `Exit code ${c}`;
reject(new Error(msg));
}
});
child.on("error", reject);
child.stdin.write(`${sudoPassword}\n`);
child.stdin.write(scriptContent);
child.stdin.end();
});
curlChild.on("error", reject);
});
}
async function installTailscaleWindows(log) {
const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi";
const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi");
const psScriptPath = path.join(os.tmpdir(), `tailscale-install-${Date.now()}.ps1`);
log("Downloading Tailscale installer...");
const psScript = [
`Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'`,
`Start-Process msiexec.exe -ArgumentList '/i','${msiPath}','/quiet','/norestart' -Wait`,
`Remove-Item '${msiPath}' -Force -ErrorAction SilentlyContinue`,
].join("\n");
fs.writeFileSync(psScriptPath, psScript, "utf8");
log("Installing (UAC prompt may appear)...");
await executeElevatedPowerShell(psScriptPath, 120000);
}
/** Start tailscaled with sudo (TUN mode required for Funnel) */
export async function startDaemonWithPassword(sudoPassword) {
if (IS_WINDOWS) return;
// Check if daemon already responds
try {
const bin = getTailscaleBin() || "tailscale";
execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
stdio: "ignore",
env: { ...process.env, PATH: EXTENDED_PATH },
timeout: 3000
});
return; // Already running
} catch { /* not running, start it */ }
// Ensure config dir exists
if (!fs.existsSync(TAILSCALE_DIR)) fs.mkdirSync(TAILSCALE_DIR, { recursive: true });
// tailscaled requires root for TUN (needed for Funnel)
const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled";
const daemonCmd = `${tailscaledBin} --socket=${TAILSCALE_SOCKET} --statedir=${TAILSCALE_DIR}`;
// Start via sudo in background (nohup keeps it alive)
await execWithPassword(`nohup ${daemonCmd} > /dev/null 2>&1 &`, sudoPassword || "");
// Wait for daemon to be ready
await new Promise((r) => setTimeout(r, 3000));
}
/** Best-effort: ensure daemon running (used for login flow) */
function ensureDaemon() {
startDaemonWithPassword("").catch(() => {});
}
/**
* Run `tailscale up` and capture the auth URL for browser login.
* Resolves with { authUrl } or { alreadyLoggedIn: true }.
*/
export function startLogin(hostname) {
const bin = getTailscaleBin();
if (!bin) return Promise.reject(new Error("Tailscale not installed"));
return new Promise((resolve, reject) => {
// Ensure daemon is running (best-effort, no sudo)
ensureDaemon();
// Check if already logged in
if (isTailscaleLoggedIn()) {
resolve({ alreadyLoggedIn: true });
return;
}
// Spawn detached so process survives API request lifecycle
const args = tsArgs("up", "--accept-routes");
if (hostname) args.push(`--hostname=${hostname}`);
const child = spawn(bin, args, {
stdio: ["ignore", "pipe", "pipe"],
detached: true
});
let resolved = false;
let output = "";
const timeout = setTimeout(() => {
if (resolved) return;
resolved = true;
// Don't kill — let tailscale up keep waiting for auth
child.unref();
const url = parseAuthUrl(output);
if (url) resolve({ authUrl: url });
else reject(new Error("tailscale up timed out without auth URL"));
}, 15000);
const parseAuthUrl = (text) => {
const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9]+/);
return match ? match[0] : null;
};
const handleData = (data) => {
output += data.toString();
const url = parseAuthUrl(output);
if (url && !resolved) {
resolved = true;
clearTimeout(timeout);
// Keep process alive — unref so it doesn't block Node exit
child.unref();
resolve({ authUrl: url });
}
};
child.stdout.on("data", handleData);
child.stderr.on("data", handleData);
child.on("error", (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
reject(err);
});
child.on("exit", (code) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
const url = parseAuthUrl(output);
if (url) resolve({ authUrl: url });
else if (isTailscaleLoggedIn()) resolve({ alreadyLoggedIn: true });
else reject(new Error(`tailscale up exited with code ${code}`));
});
});
}
/** Start tailscale funnel for the given port */
export async function startFunnel(port) {
const bin = getTailscaleBin();
if (!bin) throw new Error("Tailscale not installed");
// Reset any existing funnel
try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore" }); } catch (e) { /* ignore */ }
return new Promise((resolve, reject) => {
const child = spawn(bin, tsArgs("funnel", "--bg", `${port}`), {
stdio: ["ignore", "pipe", "pipe"]
});
let resolved = false;
let output = "";
const timeout = setTimeout(() => {
if (resolved) return;
resolved = true;
// --bg exits after setup, try status
const url = getTailscaleFunnelUrl(port);
if (url) resolve({ tunnelUrl: url });
else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`));
}, 30000);
const parseFunnelUrl = (text) =>
(text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9.-]+\.ts\.net[^\s]*/i) || [])[0]?.replace(/\/$/, "") || null;
let funnelNotEnabled = false;
const handleData = (data) => {
output += data.toString();
if (output.includes("Funnel is not enabled")) funnelNotEnabled = true;
// Wait for the enable URL to arrive in a later chunk
if (funnelNotEnabled && !resolved) {
const enableMatch = output.match(/https:\/\/login\.tailscale\.com\/[^\s]+/);
if (enableMatch) {
resolved = true;
clearTimeout(timeout);
child.kill();
resolve({ funnelNotEnabled: true, enableUrl: enableMatch[0] });
return;
}
}
const url = parseFunnelUrl(output);
if (url && !resolved) {
resolved = true;
clearTimeout(timeout);
resolve({ tunnelUrl: url });
}
};
child.stdout.on("data", handleData);
child.stderr.on("data", handleData);
child.on("exit", (code) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
const url = parseFunnelUrl(output) || getTailscaleFunnelUrl(port);
if (url) resolve({ tunnelUrl: url });
else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`));
});
child.on("error", (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
reject(err);
});
});
}
/** Stop tailscale funnel */
export function stopFunnel() {
const bin = getTailscaleBin();
if (!bin) return;
try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore" }); } catch (e) { /* ignore */ }
}
/** Kill tailscaled daemon (runs as root, needs sudo) */
export async function stopDaemon(sudoPassword) {
// Try non-sudo first
try { execSync("pkill -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
// Check if still alive
try { execSync("pgrep -x tailscaled", { stdio: "ignore", timeout: 2000 }); } catch { return; } // Dead, done
// Kill with sudo password
if (!IS_WINDOWS) {
try { await execWithPassword("pkill -x tailscaled", sudoPassword || ""); } catch { /* ignore */ }
}
// Cleanup socket
try { if (fs.existsSync(TAILSCALE_SOCKET)) fs.unlinkSync(TAILSCALE_SOCKET); } catch { /* ignore */ }
}
+71 -33
View File
@@ -1,12 +1,14 @@
import crypto from "crypto";
import { loadState, saveState } from "./state.js";
import { loadState, saveState, generateShortId } from "./state.js";
import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js";
import { startFunnel, stopFunnel, stopDaemon, isTailscaleRunning, isTailscaleLoggedIn, startLogin, startDaemonWithPassword } from "./tailscale.js";
import { getSettings, updateSettings } from "@/lib/localDb";
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
initDbHooks(getSettings, updateSettings);
const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://9router.com";
const MACHINE_ID_SALT = "9router-tunnel-salt";
const SHORT_ID_LENGTH = 6;
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
const RECONNECT_DELAYS_MS = [5000, 10000, 20000, 30000, 60000];
const MAX_RECONNECT_ATTEMPTS = RECONNECT_DELAYS_MS.length;
@@ -23,14 +25,6 @@ export function isTunnelReconnecting() {
return isReconnecting;
}
function generateShortId() {
let result = "";
for (let i = 0; i < SHORT_ID_LENGTH; i++) {
result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
}
return result;
}
function getMachineId() {
try {
const { machineIdSync } = require("node-machine-id");
@@ -41,9 +35,8 @@ function getMachineId() {
}
}
/**
* Register quick tunnel URL to worker (called on start and URL change)
*/
// ─── Cloudflare Tunnel ───────────────────────────────────────────────────────
async function registerTunnelUrl(shortId, tunnelUrl) {
await fetch(`${WORKER_URL}/api/tunnel/register`, {
method: "POST",
@@ -54,10 +47,12 @@ async function registerTunnelUrl(shortId, tunnelUrl) {
export async function enableTunnel(localPort = 20128) {
manualDisabled = false;
if (isCloudflaredRunning()) {
const existing = loadState();
if (existing?.tunnelUrl) {
return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, alreadyRunning: true };
const publicUrl = `https://r${existing.shortId}.9router.com`;
return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true };
}
}
@@ -67,7 +62,7 @@ export async function enableTunnel(localPort = 20128) {
const existing = loadState();
const shortId = existing?.shortId || generateShortId();
// onUrlUpdate: only called when URL changes AFTER initial connect (not on first resolve)
// onUrlUpdate: called when URL changes AFTER initial connect
const onUrlUpdate = async (url) => {
if (manualDisabled) return;
await registerTunnelUrl(shortId, url);
@@ -75,15 +70,12 @@ export async function enableTunnel(localPort = 20128) {
await updateSettings({ tunnelEnabled: true, tunnelUrl: url });
};
// Spawn quick tunnel — resolve returns initial URL, onUrlUpdate handles subsequent changes
const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate);
// Register initial URL (exactly once)
await registerTunnelUrl(shortId, tunnelUrl);
saveState({ shortId, machineId, tunnelUrl });
await updateSettings({ tunnelEnabled: true, tunnelUrl });
// Set exit handler only once (not on every reconnect)
if (!exitHandlerRegistered) {
setUnexpectedExitHandler(() => {
if (!isReconnecting) scheduleReconnect(0);
@@ -105,23 +97,17 @@ async function scheduleReconnect(attempt) {
await new Promise((r) => { reconnectTimeoutId = setTimeout(r, delay); });
try {
if (manualDisabled) {
isReconnecting = false;
return;
}
if (manualDisabled) { isReconnecting = false; return; }
const settings = await getSettings();
if (!settings.tunnelEnabled) {
isReconnecting = false;
return;
}
if (!settings.tunnelEnabled) { isReconnecting = false; return; }
await enableTunnel();
console.log("[Tunnel] Reconnected successfully");
isReconnecting = false;
} catch (err) {
console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message);
isReconnecting = false;
const nextAttempt = attempt + 1;
if (nextAttempt < MAX_RECONNECT_ATTEMPTS) scheduleReconnect(nextAttempt);
const next = attempt + 1;
if (next < MAX_RECONNECT_ATTEMPTS) scheduleReconnect(next);
else {
console.log("[Tunnel] All reconnect attempts exhausted, disabling tunnel");
await updateSettings({ tunnelEnabled: false });
@@ -130,7 +116,6 @@ async function scheduleReconnect(attempt) {
}
export async function disableTunnel() {
// Block any reconnect attempts before killing the process
manualDisabled = true;
isReconnecting = true;
if (reconnectTimeoutId) {
@@ -148,10 +133,7 @@ export async function disableTunnel() {
}
await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
// Unblock reconnect lock — manualDisabled stays true to block Watchdog/NetworkMonitor
isReconnecting = false;
return { success: true };
}
@@ -170,3 +152,59 @@ export async function getTunnelStatus() {
running
};
}
// ─── Tailscale Funnel ─────────────────────────────────────────────────────────
export async function enableTailscale(localPort = 20128) {
// Ensure daemon is running (needs sudo for TUN mode)
const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
await startDaemonWithPassword(sudoPass);
// Generate hostname from machine ID (same as tunnel shortId prefix)
const existing = loadState();
const shortId = existing?.shortId || generateShortId();
const tsHostname = shortId;
// If not logged in, return auth URL for user to authenticate
if (!isTailscaleLoggedIn()) {
const loginResult = await startLogin(tsHostname);
if (loginResult.authUrl) {
return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
}
}
stopFunnel();
const result = await startFunnel(localPort);
// Funnel not enabled on tailnet — return enable URL
if (result.funnelNotEnabled) {
return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl };
}
// Verify device is actually connected (BackendState=Running + funnel active)
if (!isTailscaleLoggedIn() || !isTailscaleRunning()) {
stopFunnel();
return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." };
}
await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
return { success: true, tunnelUrl: result.tunnelUrl };
}
export async function disableTailscale() {
stopFunnel();
const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
await stopDaemon(sudoPass);
await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" });
return { success: true };
}
export async function getTailscaleStatus() {
const settings = await getSettings();
const running = isTailscaleRunning();
return {
enabled: settings.tailscaleEnabled === true && running,
tunnelUrl: settings.tailscaleUrl || "",
running
};
}