-
-
warning
-
-
- Warning
-
-
- The tunnel will be disconnected. Remote access will stop working.
-
+
The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.
+
+
+
+
+
+
+
+ {/* Tailscale Modal */}
+
{ if (!tsInstalling) { setShowTsModal(false); setTsSudoPassword(""); setTsStatus(null); } }}
+ >
+
+ {/* Checking state */}
+ {tsInstalled === null && (
+
+ progress_activity
+ Checking...
+
+ )}
+
+ {/* Not installed */}
+ {tsInstalled === false && !tsInstalling && (
+
+
Tailscale is not installed. Install it to enable Funnel.
+
+
+
-
+ )}
- Are you sure you want to disable the tunnel?
+ {/* Installing with progress log */}
+ {tsInstalling && (
+
+
+ progress_activity
+ Installing Tailscale...
+
+ {tsInstallLog.length > 0 && (
+
+ {tsInstallLog.map((line, i) => (
+
{line}
+ ))}
+
+ )}
+
+ )}
+ {/* Installed: show Connect button */}
+ {tsInstalled === true && !tsInstalling && (
+
+
+ check_circle
+ Tailscale installed
+
+
+
+
+
+
+ )}
+
+ {tsStatus && }
+
+
+
+ {/* Disable Tailscale Modal */}
+
!tsLoading && setShowDisableTsModal(false)}
+ >
+
+
Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.
-
-
@@ -787,6 +1020,49 @@ export default function APIPageClient({ machineId }) {
);
}
+/** Reusable endpoint row component */
+function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
+ return (
+
+ {label}
+
+ onCopy(url, copyId)}
+ className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
+ >
+ {copied === copyId ? "check" : "content_copy"}
+
+ {actions}
+
+ );
+}
+
+/** Reusable status alert */
+function StatusAlert({ status, className = "" }) {
+ // Render URLs in message as clickable links
+ const renderMessage = (msg) => {
+ const parts = msg.split(/(https?:\/\/[^\s]+)/g);
+ return parts.map((part, i) =>
+ /^https?:\/\//.test(part)
+ ?
{part}
+ : part
+ );
+ };
+
+ return (
+
+ {renderMessage(status.message)}
+
+ );
+}
+
APIPageClient.propTypes = {
machineId: PropTypes.string.isRequired,
};
diff --git a/src/app/api/health/route.js b/src/app/api/health/route.js
new file mode 100644
index 0000000..7f809dd
--- /dev/null
+++ b/src/app/api/health/route.js
@@ -0,0 +1,5 @@
+import { NextResponse } from "next/server";
+
+export async function GET() {
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js
index 6cc4e87..c651cdc 100644
--- a/src/app/api/tunnel/status/route.js
+++ b/src/app/api/tunnel/status/route.js
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
-import { getTunnelStatus } from "@/lib/tunnel/tunnelManager";
+import { getTunnelStatus, getTailscaleStatus } from "@/lib/tunnel/tunnelManager";
export async function GET() {
try {
- const status = await getTunnelStatus();
- return NextResponse.json(status);
+ const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
+ return NextResponse.json({ tunnel, tailscale });
} catch (error) {
console.error("Tunnel status error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
diff --git a/src/app/api/tunnel/tailscale-check/route.js b/src/app/api/tunnel/tailscale-check/route.js
new file mode 100644
index 0000000..3e5aee0
--- /dev/null
+++ b/src/app/api/tunnel/tailscale-check/route.js
@@ -0,0 +1,41 @@
+import os from "os";
+import { execSync } from "child_process";
+import { NextResponse } from "next/server";
+import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale";
+
+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; }
+}
+
+function isDaemonRunning() {
+ try {
+ // Use custom socket + --json; exit 0 even when not logged in
+ execSync(`tailscale --socket ${TAILSCALE_SOCKET} status --json`, {
+ stdio: "ignore",
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ timeout: 3000
+ });
+ return true;
+ } catch {
+ // Fallback: check if tailscaled process is alive
+ try {
+ execSync("pgrep -x tailscaled", { stdio: "ignore", timeout: 2000 });
+ return true;
+ } catch { return false; }
+ }
+}
+
+export async function GET() {
+ try {
+ const installed = isTailscaleInstalled();
+ const platform = os.platform();
+ const brewAvailable = platform === "darwin" && hasBrew();
+ const daemonRunning = installed ? isDaemonRunning() : false;
+ const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
+ return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tunnel/tailscale-disable/route.js b/src/app/api/tunnel/tailscale-disable/route.js
new file mode 100644
index 0000000..67ce160
--- /dev/null
+++ b/src/app/api/tunnel/tailscale-disable/route.js
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { disableTailscale } from "@/lib/tunnel/tunnelManager";
+
+export async function POST() {
+ try {
+ const result = await disableTailscale();
+ return NextResponse.json(result);
+ } catch (error) {
+ console.error("Tailscale disable error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tunnel/tailscale-enable/route.js b/src/app/api/tunnel/tailscale-enable/route.js
new file mode 100644
index 0000000..aee60f5
--- /dev/null
+++ b/src/app/api/tunnel/tailscale-enable/route.js
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { enableTailscale } from "@/lib/tunnel/tunnelManager";
+
+export async function POST() {
+ try {
+ const result = await enableTailscale();
+ return NextResponse.json(result);
+ } catch (error) {
+ console.error("Tailscale enable error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tunnel/tailscale-install/route.js b/src/app/api/tunnel/tailscale-install/route.js
new file mode 100644
index 0000000..9ec2015
--- /dev/null
+++ b/src/app/api/tunnel/tailscale-install/route.js
@@ -0,0 +1,67 @@
+"use server";
+
+import os from "os";
+import { execSync } from "child_process";
+import { installTailscale } from "@/lib/tunnel/tailscale";
+import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
+import { getSettings, updateSettings } from "@/lib/localDb";
+import { loadState, generateShortId } from "@/lib/tunnel/state.js";
+
+initDbHooks(getSettings, updateSettings);
+
+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; }
+}
+
+export async function POST(request) {
+ const body = await request.json().catch(() => ({}));
+ const platform = os.platform();
+ const isWindows = platform === "win32";
+ const isBrew = platform === "darwin" && hasBrew();
+ const needsPassword = !isWindows && !isBrew;
+
+ const sudoPassword = body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || "";
+
+ if (needsPassword && !sudoPassword.trim()) {
+ return new Response(JSON.stringify({ error: "Sudo password is required" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ const shortId = loadState()?.shortId || generateShortId();
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ async start(controller) {
+ const send = (event, data) => {
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
+ };
+
+ try {
+ const result = await installTailscale(sudoPassword, shortId, (msg) => {
+ send("progress", { message: msg });
+ });
+ send("done", { success: true, authUrl: result?.authUrl || null });
+ } catch (error) {
+ console.error("Tailscale install error:", error);
+ const msg = error.message?.includes("incorrect password") || error.message?.includes("Sorry")
+ ? "Wrong sudo password"
+ : error.message;
+ send("error", { error: msg });
+ } finally {
+ controller.close();
+ }
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ },
+ });
+}
diff --git a/src/app/api/tunnel/tailscale-login/route.js b/src/app/api/tunnel/tailscale-login/route.js
new file mode 100644
index 0000000..516e430
--- /dev/null
+++ b/src/app/api/tunnel/tailscale-login/route.js
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { startLogin } from "@/lib/tunnel/tailscale";
+import { loadState, generateShortId } from "@/lib/tunnel/state.js";
+
+export async function POST() {
+ try {
+ const shortId = loadState()?.shortId || generateShortId();
+ const result = await startLogin(shortId);
+ return NextResponse.json(result);
+ } catch (error) {
+ console.error("Tailscale login error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tunnel/tailscale-start-daemon/route.js b/src/app/api/tunnel/tailscale-start-daemon/route.js
new file mode 100644
index 0000000..826f8b0
--- /dev/null
+++ b/src/app/api/tunnel/tailscale-start-daemon/route.js
@@ -0,0 +1,21 @@
+"use server";
+
+import { NextResponse } from "next/server";
+import { startDaemonWithPassword } from "@/lib/tunnel/tailscale";
+import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
+import { getSettings, updateSettings } from "@/lib/localDb";
+
+initDbHooks(getSettings, updateSettings);
+
+export async function POST(request) {
+ try {
+ const body = await request.json().catch(() => ({}));
+ // Use provided password, or fall back to cached/stored MITM password
+ const password = body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || "";
+ await startDaemonWithPassword(password);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("Tailscale start daemon error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index aa81fb5..76cf935 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -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",
diff --git a/src/lib/tunnel/state.js b/src/lib/tunnel/state.js
index 54c7fca..cbb386b 100644
--- a/src/lib/tunnel/state.js
+++ b/src/lib/tunnel/state.js
@@ -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;
+}
diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js
new file mode 100644
index 0000000..91ae8fe
--- /dev/null
+++ b/src/lib/tunnel/tailscale.js
@@ -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 */ }
+}
diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js
index c19672d..b107a72 100644
--- a/src/lib/tunnel/tunnelManager.js
+++ b/src/lib/tunnel/tunnelManager.js
@@ -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
+ };
+}
diff --git a/src/mitm/server.js b/src/mitm/server.js
index 9882667..5cd40d8 100644
--- a/src/mitm/server.js
+++ b/src/mitm/server.js
@@ -3,6 +3,7 @@ const fs = require("fs");
const path = require("path");
const dns = require("dns");
const { promisify } = require("util");
+const { execSync } = require("child_process");
const { log, err } = require("./logger");
const { TARGET_HOSTS, URL_PATTERNS, getToolForHost } = require("./config");
const { DATA_DIR, MITM_DIR } = require("./paths");
@@ -218,6 +219,34 @@ const server = https.createServer(sslOptions, async (req, res) => {
}
});
+// Kill any process occupying LOCAL_PORT before binding
+function killPort(port) {
+ try {
+ const pids = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
+ if (!pids) return;
+ const pidList = pids.split("\n");
+ pidList.forEach(pid => {
+ try {
+ process.kill(Number(pid), "SIGKILL");
+ } catch (e) {
+ err(`Failed to kill PID ${pid}: ${e.message}`);
+ throw e;
+ }
+ });
+ log(`Killed ${pidList.length} process(es) on port ${port}`);
+ } catch (e) {
+ // lsof exits with status 1 when no process found — that's fine
+ if (e.status !== 1) throw e;
+ }
+}
+
+try {
+ killPort(LOCAL_PORT);
+} catch (e) {
+ err(`Cannot kill process on port ${LOCAL_PORT}: ${e.message}`);
+ process.exit(1);
+}
+
server.listen(LOCAL_PORT, () => log(`🚀 Server ready on :${LOCAL_PORT}`));
server.on("error", (e) => {