mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Feat : Tailscale
This commit is contained in:
@@ -103,6 +103,20 @@ export class GithubExecutor extends BaseExecutor {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Newer OpenAI models (gpt-5+, o1, o3, o4) require max_completion_tokens instead of max_tokens
|
||||
requiresMaxCompletionTokens(model) {
|
||||
return /gpt-5|o[134]-/i.test(model);
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const transformed = { ...body };
|
||||
if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) {
|
||||
transformed.max_completion_tokens = transformed.max_tokens;
|
||||
delete transformed.max_tokens;
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
async execute(options) {
|
||||
const { model, log } = options;
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardSkeleton } from "@/shared/components";
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard } from "./components";
|
||||
import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard, MitmLinkCard } from "./components";
|
||||
import { MITM_TOOLS } from "@/shared/constants/cliTools";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -184,23 +185,18 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
};
|
||||
|
||||
const regularTools = Object.entries(CLI_TOOLS);
|
||||
const mitmTools = Object.entries(MITM_TOOLS);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* {!hasActiveProviders && (
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div>
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">No active providers</p>
|
||||
<p className="text-sm text-text-muted">Please add and connect providers first to configure CLI tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)} */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{mitmTools.map(([toolId, tool]) => (
|
||||
<MitmLinkCard key={toolId} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
|
||||
/**
|
||||
* Clickable card for MITM tools — navigates to /dashboard/mitm on click.
|
||||
*/
|
||||
export default function MitmLinkCard({ tool }) {
|
||||
return (
|
||||
<Link href="/dashboard/mitm" className="block">
|
||||
<Card padding="sm" className="overflow-hidden hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image
|
||||
src={tool.image}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-purple-500/10 text-purple-600 dark:text-purple-400 rounded-full">MITM</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted text-[20px]">chevron_right</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export { default as OpenCodeToolCard } from "./OpenCodeToolCard";
|
||||
export { default as CopilotToolCard } from "./CopilotToolCard";
|
||||
export { default as MitmServerCard } from "./MitmServerCard";
|
||||
export { default as MitmToolCard } from "./MitmToolCard";
|
||||
export { default as MitmLinkCard } from "./MitmLinkCard";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
5
src/app/api/health/route.js
Normal file
5
src/app/api/health/route.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
41
src/app/api/tunnel/tailscale-check/route.js
Normal file
41
src/app/api/tunnel/tailscale-check/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/tunnel/tailscale-disable/route.js
Normal file
12
src/app/api/tunnel/tailscale-disable/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/tunnel/tailscale-enable/route.js
Normal file
12
src/app/api/tunnel/tailscale-enable/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
67
src/app/api/tunnel/tailscale-install/route.js
Normal file
67
src/app/api/tunnel/tailscale-install/route.js
Normal file
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
14
src/app/api/tunnel/tailscale-login/route.js
Normal file
14
src/app/api/tunnel/tailscale-login/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
21
src/app/api/tunnel/tailscale-start-daemon/route.js
Normal file
21
src/app/api/tunnel/tailscale-start-daemon/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
src/lib/tunnel/tailscale.js
Normal file
442
src/lib/tunnel/tailscale.js
Normal 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 */ }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user