feat: remove tunnel and tailscale feature

This commit is contained in:
2026-07-18 19:46:52 +07:00
parent 3ebfab2684
commit 6bb03d85f7
60 changed files with 1172 additions and 4254 deletions
+2 -53
View File
@@ -150,54 +150,14 @@ function killByPidFile(pidFile) {
} catch { }
}
// Kill tunnel processes (cloudflared/tailscale) by their PID files
function killTunnelByPidFile() {
const tunnelDir = path.join(getAppDataDir(), "tunnel");
killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
killByPidFile(path.join(tunnelDir, "tailscale.pid"));
}
// Kill cloudflared whose --url targets this app's port (covers stale PID file case)
function killCloudflaredByAppPort(appPort) {
if (!appPort) return [];
const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
const pids = [];
try {
if (process.platform === "win32") {
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
const lines = output.split("\n").slice(1).filter(l => l.trim());
lines.forEach(line => {
if (portMatchers.some(m => line.includes(m))) {
const match = line.match(/^"(\d+)"/);
if (match && match[1]) pids.push(match[1]);
}
});
} else {
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
output.split("\n").forEach(line => {
if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
const parts = line.trim().split(/\s+/);
const pid = parts[0];
if (pid && !isNaN(pid)) pids.push(pid);
}
});
}
} catch { }
return pids;
}
// Kill all 9router processes
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
// Background: MITM + tunnel/cloudflared run on separate ports/processes —
// killing them doesn't free the app port, so don't block the critical path.
// MITM runs on a separate process and does not block the critical path.
// Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
setImmediate(() => {
try { killProxyByPidFile(); } catch {}
try { killTunnelByPidFile(); } catch {}
try { killCloudflaredByAppPort(appPort); } catch {}
});
const platform = process.platform;
@@ -438,20 +398,11 @@ killAllAppProcesses(port)
async function showInterfaceMenu() {
const { selectMenu } = require("./src/cli/utils/input");
const { clearScreen } = require("./src/cli/utils/display");
const { getEndpoint } = require("./src/cli/utils/endpoint");
clearScreen();
const displayHost = getDisplayHost();
// Detect tunnel/local mode for server URL display
let serverUrl;
try {
const { endpoint, tunnelEnabled } = await getEndpoint(port);
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
} catch (e) {
serverUrl = `http://${displayHost}:${port}`;
}
const serverUrl = `http://${displayHost}:${port}`;
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
@@ -529,8 +480,6 @@ function startServer() {
} catch (e) { }
// Kill MIT server (privileged process) via PID file
killProxyByPidFile();
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
killTunnelByPidFile();
// Kill server process directly
if (server.pid) {
process.kill(server.pid, "SIGKILL");
-33
View File
@@ -430,34 +430,6 @@ async function validateProviderNode(data) {
return makeRequest("POST", "/api/provider-nodes/validate", data);
}
// ============================================================================
// TUNNEL API
// ============================================================================
/**
* Get tunnel status
* @returns {Promise<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
*/
async function getTunnelStatus() {
return makeRequest("GET", "/api/tunnel/status");
}
/**
* Enable tunnel
* @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
*/
async function enableTunnel() {
return makeRequest("POST", "/api/tunnel/enable");
}
/**
* Disable tunnel
* @returns {Promise<Object>} { success, data: { success } }
*/
async function disableTunnel() {
return makeRequest("POST", "/api/tunnel/disable");
}
// ============================================================================
// EXPORTS
// ============================================================================
@@ -501,11 +473,6 @@ module.exports = {
updateSettings,
resetPassword,
// Tunnel
getTunnelStatus,
enableTunnel,
disableTunnel,
// Models
getModels,
getAvailableModels,
+3 -59
View File
@@ -16,7 +16,7 @@ const COLORS = {
const DEFAULT_PASSWORD = "123456";
/**
* Show settings menu (tunnel + RTK + reset password)
* Show settings menu (RTK + reset password)
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showSettingsMenu(breadcrumb = []) {
@@ -26,15 +26,7 @@ async function showSettingsMenu(breadcrumb = []) {
headerContent: async (data) => {
const lines = [];
// Tunnel section
const tunnel = data?.tunnel || {};
if (tunnel.enabled && tunnel.publicUrl) {
lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
} else {
lines.push(` Endpoint: http://localhost:20128/v1`);
lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
}
lines.push(" Endpoint: http://localhost:20128/v1");
// RTK section
const rtkOn = data?.settings?.rtkEnabled !== false;
@@ -50,24 +42,12 @@ async function showSettingsMenu(breadcrumb = []) {
return lines.join("\n");
},
refresh: async () => {
const [tunnelRes, settingsRes] = await Promise.all([
api.getTunnelStatus(),
api.getSettings()
]);
const settingsRes = await api.getSettings();
return {
tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {},
settings: settingsRes.success ? (settingsRes.data || {}) : {}
};
},
items: [
{
label: "Tunnel ON",
action: async () => { await enableTunnel(); return true; }
},
{
label: "Tunnel OFF",
action: async () => { await disableTunnel(); return true; }
},
{
label: (d) => {
const on = d?.settings?.rtkEnabled !== false;
@@ -118,42 +98,6 @@ async function resetAuthMode() {
await pause();
}
/**
* Enable tunnel via API
*/
async function enableTunnel() {
showStatus("Creating tunnel...", "info");
const result = await api.enableTunnel();
if (result.success) {
const { publicUrl, shortId, alreadyRunning } = result.data || {};
if (alreadyRunning) {
showStatus(`Tunnel already running: ${publicUrl}`, "success");
} else {
showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success");
}
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Disable tunnel via API
*/
async function disableTunnel() {
const result = await api.disableTunnel();
if (result.success) {
showStatus("Tunnel disabled", "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Toggle RTK (Token Saver) via API
* @param {boolean} currentlyOn
+5 -17
View File
@@ -17,16 +17,8 @@ const COLORS = {
let cachedHeader = "";
let fetchingHeader = false;
function renderHeader(port, keys, tunnel) {
const tunnelEnabled = tunnel && tunnel.enabled === true;
const lines = [];
if (tunnelEnabled && tunnel.publicUrl) {
lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
} else {
lines.push(`Endpoint: http://localhost:${port}/v1`);
lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
}
function renderHeader(port, keys) {
const lines = [`Endpoint: http://localhost:${port}/v1`];
if (!keys || keys.length === 0) {
lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
} else {
@@ -40,13 +32,9 @@ async function refreshHeaderBg(port) {
if (fetchingHeader) return;
fetchingHeader = true;
try {
const [keysResult, tunnelResult] = await Promise.all([
api.getApiKeys(),
api.getTunnelStatus()
]);
const keysResult = await api.getApiKeys();
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
cachedHeader = renderHeader(port, keys, tunnel);
cachedHeader = renderHeader(port, keys);
} finally {
fetchingHeader = false;
}
@@ -55,7 +43,7 @@ async function refreshHeaderBg(port) {
function getHeader(port) {
// Kick off background refresh; return cache (or placeholder on first call).
refreshHeaderBg(port);
return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`;
return cachedHeader || `Endpoint: http://localhost:${port}/v1\nKey: ${COLORS.dim}...${COLORS.reset}`;
}
/**
+20 -32
View File
@@ -1,32 +1,20 @@
const api = require("../api/client");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m"
};
/**
* Get endpoint URL based on tunnel status
* @param {number} port - Local server port
* @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
*/
async function getEndpoint(port) {
const result = await api.getTunnelStatus();
const tunnelEnabled = result.success && result.data?.enabled === true;
const publicUrl = result.success ? result.data?.publicUrl : "";
const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`;
return { endpoint, tunnelEnabled };
}
/**
* Get endpoint with color formatting
* @param {number} port - Local server port
* @returns {Promise<string>} Colored endpoint string
*/
async function getEndpointColored(port) {
const { endpoint, tunnelEnabled } = await getEndpoint(port);
return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
}
module.exports = { getEndpoint, getEndpointColored };
/**
* Get the local gateway endpoint.
* @param {number} port - Local server port
* @returns {Promise<{endpoint: string}>}
*/
async function getEndpoint(port) {
return { endpoint: `http://localhost:${port}/v1` };
}
/**
* Get the local gateway endpoint for terminal output.
* @param {number} port - Local server port
* @returns {Promise<string>}
*/
async function getEndpointColored(port) {
const { endpoint } = await getEndpoint(port);
return endpoint;
}
module.exports = { getEndpoint, getEndpointColored };
-20
View File
@@ -101,7 +101,6 @@
"All providers": "همه ارائه‌دهندگان",
"All rates are in": "همه نرخ‌ها بر حسب",
"All selected currently unbound": "همه موارد انتخاب شده در حال حاضر بدون اتصال هستند",
"Allow dashboard access via tunnel": "اجازه دسترسی به داشبورد از طریق تونل",
"Allow either password or OIDC.": "اجازه ورود با رمز عبور یا OIDC را بدهید.",
"An error occurred": "خطایی رخ داد",
"An error occurred. Please try again.": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
@@ -115,7 +114,6 @@
"Apply Proxy": "اعمال پروکسی",
"Applying...": "در حال اعمال...",
"Are you sure you want to close the proxy server?": "آیا مطمئن هستید که می‌خواهید سرور پروکسی را ببندید؟",
"Are you sure you want to disable the tunnel?": "آیا مطمئن هستید که می‌خواهید تونل را غیرفعال کنید؟",
"Attempting to reconnect...": "در حال تلاش برای اتصال مجدد...",
"Audio File": "فایل صوتی",
"Auth Mode": "حالت احراز هویت",
@@ -228,7 +226,6 @@
"Closing in": "در حال بسته شدن در",
"Cloud Sync": "همگام‌سازی ابری",
"Cloudflare Relay": "Cloudflare Relay",
"Cloudflare Tunnel": "تونل Cloudflare",
"Cloudflare Workers AI": "Cloudflare Workers AI",
"Codex CLI - Manual Configuration": "Codex CLI - پیکربندی دستی",
"Codex CLI not detected locally": "Codex CLI در سیستم محلی شناسایی نشد",
@@ -328,7 +325,6 @@
"Currently using accounts in priority order (Fill First).": "در حال حاضر از حساب‌ها به ترتیب اولویت استفاده می‌کند (ابتدا پر کردن).",
"Cursor AI Code Editor": "ویرایشگر کد هوش مصنوعی Cursor",
"Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE شناسایی نشد. لطفاً توکن‌های خود را به صورت دستی بچسبانید.",
"Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor درخواست‌ها را از طریق سرور خود مسیردهی می‌کند، بنابراین نقطه پایانی محلی پشتیبانی نمی‌شود. لطفاً تونل یا نقطه پایانی ابری را در تنظیمات فعال کنید.",
"Custom": "سفارشی",
"Custom Pricing:": "قیمت‌گذاری سفارشی:",
"Custom Providers (OpenAI/Anthropic Compatible)": "ارائه‌دهندگان سفارشی (سازگار با OpenAI/Anthropic)",
@@ -388,8 +384,6 @@
"Dimensions": "ابعاد",
"Disable": "غیرفعال‌سازی",
"Disable All": "غیرفعال‌سازی همه",
"Disable Tailscale": "غیرفعال‌سازی Tailscale",
"Disable Tunnel": "غیرفعال‌سازی تونل",
"Disable connections with depleted quota on the current page": "غیرفعال‌سازی اتصالات با سهمیه تمام شده در صفحه فعلی",
"Disable provider": "غیرفعال‌سازی ارائه‌دهنده",
"Disable this model": "غیرفعال‌سازی این مدل",
@@ -423,7 +417,6 @@
"Enable DNS to edit model mappings": "برای ویرایش نگاشت‌های مدل، DNS را فعال کنید",
"Enable Observability": "فعال‌سازی مشاهده‌پذیری",
"Enable OpenAI API": "فعال‌سازی OpenAI API",
"Enable Tunnel": "فعال‌سازی تونل",
"Enable connections that still have quota on the current page": "فعال‌سازی اتصالاتی که هنوز در صفحه فعلی سهمیه دارند",
"Enable provider": "فعال‌سازی ارائه‌دهنده",
"Enable proxy for OAuth + provider outbound requests.": "فعال‌سازی پروکسی برای درخواست‌های خروجی OAuth + ارائه‌دهنده.",
@@ -568,14 +561,12 @@
"Install Cline VS Code extension or CLI from": "افزونه یا CLI Cline VS Code را از نصب کنید",
"Install Kilo Code from": "Kilo Code را از نصب کنید",
"Install Qwen Code": "نصب Qwen Code",
"Install Tailscale": "نصب Tailscale",
"Install command:": "دستور نصب:",
"Install jcode to enable automatic configuration:": "jcode را نصب کنید تا پیکربندی خودکار فعال شود:",
"Install the Amp CLI using the package manager supported by your environment.": "Amp CLI را با استفاده از مدیر بسته پشتیبانی شده توسط محیط خود نصب کنید.",
"Install then click Start:": "نصب کنید سپس روی شروع کلیک کنید:",
"Install via npm:": "نصب از طریق npm:",
"Installation Guide": "راهنمای نصب",
"Installing Tailscale...": "در حال نصب Tailscale...",
"Interactive diagram visible on desktop": "نمودار تعاملی در دسکتاپ قابل مشاهده است",
"Intercept CLI tool traffic and route through 9Router": "ترافیک ابزار CLI را رهگیری کرده و از طریق 9Router مسیردهی کنید",
"Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ترافیک Antigravity را از طریق تغییر مسیر DNS رهگیری می‌کند و به شما امکان می‌دهد مدل‌ها را از طریق 9Router مسیردهی مجدد کنید.",
@@ -1103,7 +1094,6 @@
"Start Headroom separately at the configured URL, then recheck.": "Headroom را به صورت جداگانه در آدرس پیکربندی شده راه‌اندازی کنید، سپس دوباره بررسی کنید.",
"Start MITM": "راه‌اندازی MITM",
"Start Server": "راه‌اندازی سرور",
"Start Tunnel": "راه‌اندازی تونل",
"Start a conversation": "شروع یک گفتگو",
"Starting 9Router...": "در حال راه‌اندازی 9Router...",
"Status": "وضعیت",
@@ -1129,11 +1119,6 @@
"Sync settings across devices with optional cloud storage.": "همگام‌سازی تنظیمات بین دستگاه‌ها با ذخیره‌سازی اختیاری ابری.",
"System": "سیستم",
"TTFT:": "TTFT:",
"Tailscale": "Tailscale",
"Tailscale Funnel": "قیف Tailscale",
"Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "قیف Tailscale متوقف خواهد شد. دسترسی از راه دور از طریق URL Tailscale از کار خواهد افتاد.",
"Tailscale installed": "Tailscale نصب شد",
"Tailscale is not installed. Install it to enable Funnel.": "Tailscale نصب نشده است. برای فعال‌سازی Funnel آن را نصب کنید.",
"Target Request": "درخواست هدف",
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
"Temperature": "دما",
@@ -1161,10 +1146,8 @@
"Text to Image combo": "ترکیب متن به تصویر",
"Text-to-Speech": "متن به گفتار",
"Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "تولید متن به تصویر از طریق DALL-E، Imagen، FLUX، MiniMax، SDWebUI…",
"The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "تونل Cloudflare قطع خواهد شد. دسترسی از راه دور از طریق URL تونل از کار خواهد افتاد.",
"The proxy server has been stopped.": "سرور پروکسی متوقف شده است.",
"The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "درخواست فوراً توسط OpenAI، Anthropic، Gemini یا دیگران برآورده می‌شود.",
"The tunnel will be disconnected. Remote access will stop working.": "تونل قطع خواهد شد. دسترسی از راه دور از کار خواهد افتاد.",
"The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "نقطه پایانی یکپارچه برای تولید هوش مصنوعی. به راحتی ارائه‌دهندگان هوش مصنوعی خود را متصل، مسیردهی و مدیریت کنید.",
"The unified interface for modern AI infrastructure": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی",
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی. امن، قابل مشاهده و مقیاس‌پذیر.",
@@ -1210,9 +1193,6 @@
"Trust Cert": "اعتماد به گواهی",
"Trusted": "معتمد",
"Try Again": "دوباره تلاش کنید",
"Tunnel": "تونل",
"Tunnel connected!": "تونل متصل شد!",
"Tunnel disabled": "تونل غیرفعال شد",
"Turn off Empty": "خاموش کردن حساب‌های خالی",
"Turn on Available": "روشن کردن حساب‌های موجود",
"Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری",
-20
View File
@@ -101,7 +101,6 @@
"All providers": "ผู้ให้บริการทั้งหมด",
"All rates are in": "อัตราทั้งหมดเป็น",
"All selected currently unbound": "ที่เลือกทั้งหมดยังไม่ได้เชื่อมต่อ",
"Allow dashboard access via tunnel": "อนุญาตให้เข้าถึง dashboard ผ่าน tunnel",
"Allow either password or OIDC.": "อนุญาตทั้งรหัสผ่านหรือ OIDC",
"An error occurred": "เกิดข้อผิดพลาด",
"An error occurred. Please try again.": "เกิดข้อผิดพลาด กรุณาลองใหม่",
@@ -115,7 +114,6 @@
"Apply Proxy": "ใช้ Proxy",
"Applying...": "กำลังใช้...",
"Are you sure you want to close the proxy server?": "คุณแน่ใจหรือว่าต้องการปิด proxy server?",
"Are you sure you want to disable the tunnel?": "คุณแน่ใจหรือว่าต้องการปิด tunnel?",
"Attempting to reconnect...": "กำลังพยายามเชื่อมต่อใหม่...",
"Audio File": "ไฟล์เสียง",
"Auth Mode": "โหมดยืนยันตัวตน",
@@ -228,7 +226,6 @@
"Closing in": "ปิดใน",
"Cloud Sync": "Cloud Sync",
"Cloudflare Relay": "Cloudflare Relay",
"Cloudflare Tunnel": "Cloudflare Tunnel",
"Cloudflare Workers AI": "Cloudflare Workers AI",
"Codex CLI - Manual Configuration": "Codex CLI - กำหนดค่าด้วยตนเอง",
"Codex CLI not detected locally": "ไม่พบ Codex CLI บนเครื่อง",
@@ -328,7 +325,6 @@
"Currently using accounts in priority order (Fill First).": "ใช้บัญชีตามลำดับความสำคัญ (เติมก่อน)",
"Cursor AI Code Editor": "Cursor AI Code Editor",
"Cursor IDE not detected. Please paste your tokens manually.": "ไม่พบ Cursor IDE กรุณาวาง tokens ด้วยตนเอง",
"Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor ส่งต่อคำขอผ่านเซิร์ฟเวอร์ของตัวเอง จึงไม่รองรับ local endpoint กรุณาเปิดใช้งาน Tunnel หรือ Cloud Endpoint ในการตั้งค่า",
"Custom": "กำหนดเอง",
"Custom Pricing:": "กำหนดราคาเอง:",
"Custom Providers (OpenAI/Anthropic Compatible)": "Custom Providers (OpenAI/Anthropic Compatible)",
@@ -388,8 +384,6 @@
"Dimensions": "มิติ",
"Disable": "ปิดใช้งาน",
"Disable All": "ปิดทั้งหมด",
"Disable Tailscale": "ปิด Tailscale",
"Disable Tunnel": "ปิด Tunnel",
"Disable connections with depleted quota on the current page": "ปิดการเชื่อมต่อที่ quota หมดบนหน้าปัจจุบัน",
"Disable provider": "ปิด provider",
"Disable this model": "ปิดโมเดลนี้",
@@ -423,7 +417,6 @@
"Enable DNS to edit model mappings": "เปิดใช้งาน DNS เพื่อแก้ไข model mappings",
"Enable Observability": "เปิดใช้งาน Observability",
"Enable OpenAI API": "เปิดใช้งาน OpenAI API",
"Enable Tunnel": "เปิดใช้งาน Tunnel",
"Enable connections that still have quota on the current page": "เปิดการเชื่อมต่อที่ยังมี quota บนหน้าปัจจุบัน",
"Enable provider": "เปิด provider",
"Enable proxy for OAuth + provider outbound requests.": "เปิด proxy สำหรับ OAuth + provider outbound requests",
@@ -568,14 +561,12 @@
"Install Cline VS Code extension or CLI from": "ติดตั้ง Cline VS Code extension หรือ CLI จาก",
"Install Kilo Code from": "ติดตั้ง Kilo Code จาก",
"Install Qwen Code": "ติดตั้ง Qwen Code",
"Install Tailscale": "ติดตั้ง Tailscale",
"Install command:": "คำสั่งติดตั้ง:",
"Install jcode to enable automatic configuration:": "ติดตั้ง jcode เพื่อเปิดใช้งานการกำหนดค่าอัตโนมัติ:",
"Install the Amp CLI using the package manager supported by your environment.": "ติดตั้ง Amp CLI โดยใช้ package manager ที่รองรับในสภาพแวดล้อมของคุณ",
"Install then click Start:": "ติดตั้งแล้วคลิกเริ่ม:",
"Install via npm:": "ติดตั้งผ่าน npm:",
"Installation Guide": "คู่มือการติดตั้ง",
"Installing Tailscale...": "กำลังติดตั้ง Tailscale...",
"Interactive diagram visible on desktop": "แผนภาพแบบ interactive ที่มองเห็นบนเดสก์ท็อป",
"Intercept CLI tool traffic and route through 9Router": "ดักจับ CLI tool traffic แล้วส่งต่อผ่าน 9Router",
"Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ดักจับ Antigravity traffic ผ่าน DNS redirect ช่วยให้คุณ reroute โมเดลผ่าน 9Router",
@@ -1103,7 +1094,6 @@
"Start Headroom separately at the configured URL, then recheck.": "เริ่ม Headroom แยกต่างหากที่ URL ที่กำหนด แล้วตรวจสอบอีกครั้ง",
"Start MITM": "เริ่ม MITM",
"Start Server": "เริ่มเซิร์ฟเวอร์",
"Start Tunnel": "เริ่ม Tunnel",
"Start a conversation": "เริ่มการสนทนา",
"Starting 9Router...": "กำลังเริ่ม 9Router...",
"Status": "สถานะ",
@@ -1129,11 +1119,6 @@
"Sync settings across devices with optional cloud storage.": "ซิงค์การตั้งค่าผ่านอุปกรณ์ต่างๆ ด้วย cloud storage ทางเลือก",
"System": "ระบบ",
"TTFT:": "TTFT:",
"Tailscale": "Tailscale",
"Tailscale Funnel": "Tailscale Funnel",
"Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "Tailscale Funnel จะหยุด การเข้าถึงจากที่ไกลผ่าน Tailscale URL จะหยุดทำงาน",
"Tailscale installed": "ติดตั้ง Tailscale แล้ว",
"Tailscale is not installed. Install it to enable Funnel.": "ไม่ได้ติดตั้ง Tailscale ติดตั้งเพื่อเปิดใช้งาน Funnel",
"Target Request": "Target Request",
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com",
"Temperature": "Temperature",
@@ -1161,10 +1146,8 @@
"Text to Image combo": "Text to Image combo",
"Text-to-Speech": "Text-to-Speech",
"Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Text-to-image ผ่าน DALL-E, Imagen, FLUX, MiniMax, SDWebUI...",
"The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "Cloudflare tunnel จะถูกตัดการเชื่อมต่อ การเข้าถึงจากที่ไกลผ่าน tunnel URL จะหยุดทำงาน",
"The proxy server has been stopped.": "Proxy server ถูกหยุดแล้ว",
"The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "Request ได้รับการตอบสนองจาก OpenAI, Anthropic, Gemini หรือผู้ให้บริการอื่นทันที",
"The tunnel will be disconnected. Remote access will stop working.": "Tunnel จะถูกตัดการเชื่อมต่อ การเข้าถึงจากที่ไกลจะหยุดทำงาน",
"The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "Endpoint เดียวสำหรับ AI generation เชื่อมต่อ route และจัดการ AI providers ของคุณอย่างง่ายดาย",
"The unified interface for modern AI infrastructure": "Interface เดียวสำหรับ AI infrastructure สมัยใหม่",
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "Interface เดียวสำหรับ AI infrastructure สมัยใหม่ ปลอดภัย ตรวจสอบได้ และขยายขนาดได้",
@@ -1210,9 +1193,6 @@
"Trust Cert": "Trust Cert",
"Trusted": "เชื่อถือแล้ว",
"Try Again": "ลองอีกครั้ง",
"Tunnel": "Tunnel",
"Tunnel connected!": "Tunnel เชื่อมต่อแล้ว!",
"Tunnel disabled": "ปิด Tunnel แล้ว",
"Turn off Empty": "ปิด Empty",
"Turn on Available": "เปิด Available",
"Turn request detail recording on/off globally": "เปิด/ปิดการบันทึก request details ทั่วโลก",
-20
View File
@@ -101,7 +101,6 @@
"All providers": "所有提供商",
"All rates are in": "所有费率均在",
"All selected currently unbound": "所有选中项当前未绑定",
"Allow dashboard access via tunnel": "允许通过隧道访问仪表盘",
"Allow either password or OIDC.": "允许密码或 OIDC 登录。",
"An error occurred": "发生错误",
"An error occurred. Please try again.": "发生错误,请重试。",
@@ -115,7 +114,6 @@
"Apply Proxy": "应用代理",
"Applying...": "应用中...",
"Are you sure you want to close the proxy server?": "您确定要关闭代理服务器吗?",
"Are you sure you want to disable the tunnel?": "您确定要禁用隧道吗?",
"Attempting to reconnect...": "正在尝试重新连接...",
"Audio File": "音频文件",
"Auth Mode": "认证模式",
@@ -228,7 +226,6 @@
"Closing in": "即将关闭",
"Cloud Sync": "云端同步",
"Cloudflare Relay": "Cloudflare Relay",
"Cloudflare Tunnel": "Cloudflare 隧道",
"Cloudflare Workers AI": "Cloudflare Workers AI",
"Codex CLI - Manual Configuration": "Codex CLI - 手动配置",
"Codex CLI not detected locally": "未在本地检测到 Codex CLI",
@@ -328,7 +325,6 @@
"Currently using accounts in priority order (Fill First).": "当前按优先级顺序使用账号(优先填满)。",
"Cursor AI Code Editor": "Cursor AI 代码编辑器",
"Cursor IDE not detected. Please paste your tokens manually.": "未检测到Cursor IDE。请手动粘贴您的令牌。",
"Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor 通过自己的服务器路由请求,因此不支持本地端点。请在设置中启用隧道或云端点。",
"Custom": "自定义",
"Custom Pricing:": "定制定价:",
"Custom Providers (OpenAI/Anthropic Compatible)": "自定义提供商(OpenAI/Anthropic 兼容)",
@@ -388,8 +384,6 @@
"Dimensions": "维度",
"Disable": "禁用",
"Disable All": "全部禁用",
"Disable Tailscale": "禁用 Tailscale",
"Disable Tunnel": "禁用隧道",
"Disable connections with depleted quota on the current page": "禁用当前页面上配额已耗尽的连接",
"Disable provider": "禁用提供商",
"Disable this model": "禁用此模型",
@@ -422,7 +416,6 @@
"Enable DNS to edit model mappings": "启用 DNS 以编辑模型映射",
"Enable Observability": "启用可观察性",
"Enable OpenAI API": "启用 OpenAI API",
"Enable Tunnel": "启用隧道",
"Enable connections that still have quota on the current page": "启用当前页面上仍有配额的连接",
"Enable provider": "启用提供商",
"Enable proxy for OAuth + provider outbound requests.": "为 OAuth + 提供商出站请求启用代理。",
@@ -567,14 +560,12 @@
"Install Cline VS Code extension or CLI from": "从以下位置安装 Cline VS Code 扩展或 CLI",
"Install Kilo Code from": "从以下位置安装 Kilo Code",
"Install Qwen Code": "安装 Qwen Code",
"Install Tailscale": "安装 Tailscale",
"Install command:": "安装命令:",
"Install jcode to enable automatic configuration:": "安装 jcode 以启用自动配置:",
"Install the Amp CLI using the package manager supported by your environment.": "使用您环境支持的包管理器安装 Amp CLI。",
"Install then click Start:": "安装后点击启动:",
"Install via npm:": "通过 npm 安装:",
"Installation Guide": "安装指南",
"Installing Tailscale...": "正在安装 Tailscale...",
"Interactive diagram visible on desktop": "桌面上可见的交互式图表",
"Intercept CLI tool traffic and route through 9Router": "拦截 CLI 工具流量并通过 9Router 路由",
"Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "通过 DNS 重定向拦截Antigravity流量,让您可以通过 9Router 重新路由模型。",
@@ -1102,7 +1093,6 @@
"Start Headroom separately at the configured URL, then recheck.": "在配置的 URL 上单独启动 Headroom,然后重新检查。",
"Start MITM": "启动中间人",
"Start Server": "启动服务器",
"Start Tunnel": "开始隧道",
"Start a conversation": "开始一个对话",
"Starting 9Router...": "正在启动 9Router...",
"Status": "状态",
@@ -1128,11 +1118,6 @@
"Sync settings across devices with optional cloud storage.": "通过可选的云存储在设备间同步设置。",
"System": "系统",
"TTFT:": "TTFT",
"Tailscale": "Tailscale",
"Tailscale Funnel": "Tailscale Funnel",
"Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "Tailscale Funnel 将停止。通过 Tailscale URL 的远程访问将停止工作。",
"Tailscale installed": "Tailscale 已安装",
"Tailscale is not installed. Install it to enable Funnel.": "Tailscale 未安装。请安装它以启用 Funnel。",
"Target Request": "目标请求",
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com。",
"Temperature": "温度",
@@ -1160,10 +1145,8 @@
"Text to Image combo": "文本转图像组合",
"Text-to-Speech": "文本转语音",
"Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "通过 DALL-E、Imagen、FLUX、MiniMax、SDWebUI 等进行文本到图像生成。",
"The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "Cloudflare 隧道将被断开。通过隧道 URL 的远程访问将停止工作。",
"The proxy server has been stopped.": "代理服务器已停止。",
"The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "请求由 OpenAI、Anthropic、Gemini 或其他提供商即时响应。",
"The tunnel will be disconnected. Remote access will stop working.": "隧道将被断开。远程访问将停止工作。",
"The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "统一的 AI 生成端点。轻松连接、路由和管理您的 AI 提供商。",
"The unified interface for modern AI infrastructure": "现代 AI 基础设施的统一接口",
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "现代人工智能基础设施的统一接口。安全、可观察且可扩展。",
@@ -1209,9 +1192,6 @@
"Trust Cert": "信任证书",
"Trusted": "已信任",
"Try Again": "再试一次",
"Tunnel": "隧道",
"Tunnel connected!": "隧道连通!",
"Tunnel disabled": "隧道已禁用",
"Turn off Empty": "关闭空账号",
"Turn on Available": "开启可用账号",
"Turn request detail recording on/off globally": "全局打开/关闭请求详细信息记录",
@@ -0,0 +1,381 @@
.console {
--console-canvas: #f7f8fa;
--console-panel: #f1f3f6;
--console-panel-strong: #ffffff;
--console-ink: #171d27;
--console-muted: #657084;
--console-rule: #dce2e9;
--console-signal: #ed6548;
--console-signal-soft: rgba(237, 101, 72, 0.12);
--console-shadow: 0 18px 44px -30px rgba(32, 44, 62, 0.3);
position: relative;
isolation: isolate;
display: grid;
gap: 1.5rem;
color: var(--console-ink);
font-variant-numeric: tabular-nums;
}
.console::before {
position: absolute;
z-index: -1;
inset: -2rem -1.5rem auto;
height: 20rem;
content: "";
pointer-events: none;
background:
radial-gradient(circle at 74% 4%, rgba(237, 101, 72, 0.12), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 82%);
mask-image: linear-gradient(to bottom, black, transparent);
}
:global(.dark) .console {
--console-canvas: #0f1115;
--console-panel: #151a21;
--console-panel-strong: #1a2029;
--console-ink: #f1f5f9;
--console-muted: #a5b0bf;
--console-rule: #2d3744;
--console-signal: #f17052;
--console-signal-soft: rgba(241, 112, 82, 0.15);
--console-shadow: 0 22px 52px -32px rgba(0, 0, 0, 0.72);
}
:global(.dark) .console::before {
background:
radial-gradient(circle at 74% 4%, rgba(241, 112, 82, 0.07), transparent 28%),
linear-gradient(180deg, rgba(33, 40, 51, 0.42), transparent 82%);
}
.intro {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem);
align-items: end;
gap: 2rem;
padding: 0.75rem 0 0.25rem;
}
.sectionKicker {
margin: 0;
color: var(--console-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.1em;
line-height: 1.4;
text-transform: uppercase;
}
.title {
max-width: 17ch;
margin: 0.45rem 0 0;
color: var(--console-ink);
font-size: clamp(1.8rem, 3.4vw, 2.75rem);
font-weight: 650;
letter-spacing: -0.045em;
line-height: 1.02;
text-wrap: balance;
}
.description {
max-width: 58ch;
margin: 0.85rem 0 0;
color: var(--console-muted);
font-size: 0.9375rem;
line-height: 1.6;
text-wrap: pretty;
}
.posture {
display: grid;
gap: 0.35rem;
align-self: stretch;
padding: 1rem 1.1rem 1.05rem;
border: 1px solid var(--console-rule);
border-radius: 1rem;
background: color-mix(in srgb, var(--console-panel-strong) 92%, transparent);
box-shadow: var(--console-shadow);
}
.postureHeading {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--console-ink);
font-size: 0.8125rem;
font-weight: 700;
}
.postureIndicator {
display: inline-grid;
width: 0.5rem;
height: 0.5rem;
border-radius: 0.125rem;
background: var(--console-signal);
box-shadow: 0 0 0 0.2rem var(--console-signal-soft);
}
.postureCopy {
margin: 0;
color: var(--console-muted);
font-size: 0.75rem;
line-height: 1.45;
}
.accessPanel,
.keysPanel {
border: 1px solid var(--console-rule) !important;
border-radius: 1rem !important;
background: color-mix(in srgb, var(--console-panel-strong) 94%, transparent) !important;
box-shadow: var(--console-shadow) !important;
}
.accessPanel {
position: relative;
overflow: visible !important;
}
.accessPanel::before {
position: absolute;
top: 5.5rem;
bottom: 1.5rem;
left: 2.7rem;
width: 1px;
content: "";
pointer-events: none;
background: linear-gradient(to bottom, var(--console-signal), var(--console-rule) 28%, var(--console-rule));
opacity: 0.62;
}
.panelHeader {
position: relative;
z-index: 1;
margin: -1.5rem -1.5rem 1.25rem;
padding: 1.25rem 1.5rem 1.1rem;
border-bottom: 1px solid var(--console-rule);
border-radius: 1rem 1rem 0 0;
background: linear-gradient(112deg, color-mix(in srgb, var(--console-panel) 96%, transparent), color-mix(in srgb, var(--console-panel-strong) 80%, transparent));
}
.panelHeader::after {
position: absolute;
right: 1.5rem;
bottom: -1px;
left: 1.5rem;
height: 1px;
content: "";
background: linear-gradient(to right, var(--console-signal), transparent 45%);
}
.panelIcon {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border: 1px solid color-mix(in srgb, var(--console-signal) 35%, var(--console-rule));
border-radius: 0.625rem;
background: var(--console-signal-soft);
color: var(--console-signal);
}
.routeLocal,
.routeRemote {
position: relative;
z-index: 1;
border-color: var(--console-rule) !important;
border-radius: 0.75rem !important;
background: color-mix(in srgb, var(--console-panel) 82%, transparent) !important;
transition: transform 180ms ease, border-color 180ms ease, background-color 180ms ease;
}
.routeRemote {
border: 1px solid var(--console-rule);
padding: 0.625rem;
}
.routeLocal:hover,
.routeRemote:hover {
border-color: color-mix(in srgb, var(--console-signal) 44%, var(--console-rule)) !important;
background: color-mix(in srgb, var(--console-panel-strong) 92%, transparent) !important;
transform: translateX(0.15rem);
}
.routeLocal::before,
.routeRemote::before {
position: absolute;
top: 50%;
left: -1.05rem;
width: 0.45rem;
height: 0.45rem;
content: "";
border: 2px solid var(--console-panel-strong);
border-radius: 0.125rem;
background: var(--console-signal);
transform: translateY(-50%);
}
.routeRemote::before {
background: var(--console-muted);
}
.routeRemote[data-route-state="connected"]::before {
background: var(--color-success);
}
.routeRemote[data-route-state="checking"]::before {
background: var(--color-warning);
}
.routeRemote[data-route-state="error"]::before {
background: var(--color-danger);
}
.routeInput :global(input) {
border-color: transparent !important;
background: transparent !important;
box-shadow: none !important;
color: var(--console-ink) !important;
}
.keysPanel {
position: relative;
}
.keyRegistry {
overflow: hidden;
border: 1px solid var(--console-rule) !important;
border-radius: 0.75rem !important;
background: color-mix(in srgb, var(--console-panel) 68%, transparent) !important;
}
.keyRegistry > div {
border-color: var(--console-rule) !important;
}
.keyRegistry > div:hover {
background: var(--console-panel) !important;
}
.securityPolicy {
border: 1px solid var(--console-rule) !important;
border-radius: 0.75rem !important;
background: color-mix(in srgb, var(--console-panel) 74%, transparent) !important;
}
.signalButton {
box-shadow: 0 10px 18px -14px color-mix(in srgb, var(--console-signal) 88%, transparent);
}
.actionIcon {
color: var(--console-muted) !important;
}
.actionIcon:hover {
color: var(--console-signal) !important;
}
.actionIcon:focus-visible,
.routeButton:focus-visible,
.console :global(button):focus-visible,
.console :global(a):focus-visible {
outline: 2px solid var(--console-signal);
outline-offset: 2px;
}
.emptyState {
display: grid;
min-height: 17rem;
place-items: center;
padding: 2rem;
text-align: center;
}
.emptyGlyph {
display: grid;
width: 3.5rem;
height: 3.5rem;
place-items: center;
border: 1px solid color-mix(in srgb, var(--console-signal) 32%, var(--console-rule));
border-radius: 0.875rem;
background: var(--console-signal-soft);
color: var(--console-signal);
}
.skeleton {
display: grid;
gap: 1.5rem;
}
.skeletonIntro,
.skeletonPanel {
overflow: hidden;
border: 1px solid var(--color-border-subtle);
border-radius: 1rem;
background: var(--color-surface);
}
.skeletonIntro {
display: grid;
grid-template-columns: minmax(0, 1fr) 18rem;
gap: 2rem;
padding: 1.5rem;
}
.skeletonPanel {
min-height: 18rem;
padding: 1.5rem;
}
.skeletonLine {
border-radius: 0.375rem;
background: var(--color-surface-2);
}
@media (max-width: 767px) {
.console {
gap: 1.15rem;
}
.console::before {
inset: -1.25rem -1rem auto;
}
.intro,
.skeletonIntro {
grid-template-columns: 1fr;
gap: 1rem;
}
.title {
max-width: 21ch;
}
.posture {
min-height: auto;
}
.accessPanel::before {
display: none;
}
.routeLocal::before,
.routeRemote::before {
display: none;
}
.panelHeader {
margin: -1.5rem -1.5rem 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.routeLocal,
.routeRemote {
transition: none;
}
.routeLocal:hover,
.routeRemote:hover {
transform: none;
}
}
@@ -0,0 +1,3 @@
"use client";
export { default } from "./endpoint/EndpointPageClient";
@@ -15,10 +15,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [cloudEnabled, setCloudEnabled] = useState(false);
const [tunnelEnabled, setTunnelEnabled] = useState(false);
const [tunnelPublicUrl, setTunnelPublicUrl] = useState("");
const [tailscaleEnabled, setTailscaleEnabled] = useState(false);
const [tailscaleUrl, setTailscaleUrl] = useState("");
const [apiKeys, setApiKeys] = useState([]);
const [availableModels, setAvailableModels] = useState([]);
const [initialConfig, setInitialConfig] = useState(null);
@@ -27,10 +23,9 @@ export default function ToolDetailClient({ toolId, machineId }) {
let mounted = true;
(async () => {
try {
const [provRes, settingsRes, tunnelRes, keysRes, modelsRes, configRes] = await Promise.all([
const [provRes, settingsRes, keysRes, modelsRes, configRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/settings"),
fetch("/api/tunnel/status"),
fetch("/api/keys"),
fetch("/api/models/connected", { cache: "no-store" }),
fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, { cache: "no-store" }),
@@ -44,13 +39,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
const data = await settingsRes.json();
setCloudEnabled(data.cloudEnabled || false);
}
if (tunnelRes.ok) {
const data = await tunnelRes.json();
setTunnelEnabled(!!(data.tunnel?.enabled || data.tunnel?.settingsEnabled));
setTunnelPublicUrl(data.tunnel?.publicUrl || "");
setTailscaleEnabled(!!(data.tailscale?.enabled || data.tailscale?.settingsEnabled));
setTailscaleUrl(data.tailscale?.tunnelUrl || "");
}
if (keysRes.ok) {
const data = await keysRes.json();
setApiKeys(data.keys || []);
@@ -91,10 +79,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
appUrl: typeof window !== "undefined" ? window.location.origin : "",
configuredBaseUrl: CONFIGURED_BASE_URL,
requiresExternalUrl: tool?.requiresExternalUrl === true,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
cloudEnabled,
cloudUrl: CLOUD_URL,
});
@@ -106,10 +90,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
toolId,
baseUrl: getBaseUrl(),
apiKeys,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
activeProviders: getActiveProviders(),
availableModels,
cloudEnabled,
@@ -24,7 +24,7 @@ const writeSavedPresets = (presets) => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
};
const buildOptions = ({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const buildOptions = ({ appUrl, requiresExternalUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const opts = [];
const wrap = (url) => (withV1 ? ensureCliToolV1Endpoint(url) : (url || "").replace(/\/+$/, ""));
const runtimeUrl = wrap(appUrl);
@@ -34,14 +34,6 @@ const buildOptions = ({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublic
const fallbackLocalUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`);
opts.push({ value: "local", label: fallbackLocalUrl, url: fallbackLocalUrl });
}
if (tunnelEnabled && tunnelPublicUrl) {
const u = wrap(tunnelPublicUrl);
opts.push({ value: "tunnel", label: u, url: u });
}
if (tailscaleEnabled && tailscaleUrl) {
const u = wrap(tailscaleUrl);
opts.push({ value: "tailscale", label: u, url: u });
}
if (cloudEnabled && cloudUrl) {
const u = wrap(cloudUrl);
opts.push({ value: "cloud", label: u, url: u });
@@ -58,10 +50,6 @@ export default function BaseUrlSelect({
onChange,
appUrl = "",
requiresExternalUrl = false,
tunnelEnabled = false,
tunnelPublicUrl = "",
tailscaleEnabled = false,
tailscaleUrl = "",
cloudEnabled = false,
cloudUrl = "",
withV1 = true,
@@ -74,8 +62,8 @@ export default function BaseUrlSelect({
}, []);
const options = useMemo(
() => buildOptions({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
[appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
() => buildOptions({ appUrl, requiresExternalUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
[appUrl, requiresExternalUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
);
const effectiveMode = useMemo(() => {
@@ -173,10 +173,6 @@ export default function ConfigGeneratorCard({
activeProviders,
availableModels = [],
cloudEnabled,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
initialConfig,
onSaveConfig,
}) {
@@ -366,10 +362,6 @@ export default function ConfigGeneratorCard({
value={customBaseUrl}
onChange={setCustomBaseUrl}
appUrl={baseUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled}
cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL}
/>
@@ -6,7 +6,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import Image from "next/image";
import ApiKeySelect from "./ApiKeySelect";
export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false, initialConfig, onSaveConfig }) {
export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, initialConfig, onSaveConfig }) {
const [copiedField, setCopiedField] = useState(null);
const [showModelModal, setShowModelModal] = useState(false);
const [modelValue, setModelValue] = useState("");
@@ -184,11 +184,11 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
return (
<div className="flex flex-col gap-2 mb-4">
{tool.notes.map((note, index) => {
// Skip cloudCheck note if tunnel or cloud is enabled
if (note.type === "cloudCheck" && (cloudEnabled || tunnelEnabled)) return null;
// Skip cloudCheck note if the cloud endpoint is enabled.
if (note.type === "cloudCheck" && cloudEnabled) return null;
const isWarning = note.type === "warning";
const isError = note.type === "cloudCheck" && !cloudEnabled && !tunnelEnabled;
const isError = note.type === "cloudCheck" && !cloudEnabled;
let bgClass = "bg-blue-500/10 border-blue-500/30";
let textClass = "text-blue-600 dark:text-blue-400";
@@ -219,7 +219,7 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
};
const canShowGuide = () => {
if (tool.requiresExternalUrl && !cloudEnabled && !tunnelEnabled) return false;
if (tool.requiresExternalUrl && !cloudEnabled) return false;
if (tool.requiresCloud && !cloudEnabled) return false;
return true;
};
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,22 @@
"use client";
import { Input } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
/** Reusable endpoint row component */
export default function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
export default function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions, className }) {
return (
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-border-subtle bg-surface-2/40 p-2.5">
<div className={cn("grid grid-cols-[5.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-border-subtle bg-surface-2/40 p-2.5", className)}>
<span className={`rounded-md px-2 py-1 text-center font-mono text-[11px] font-medium tracking-wide ${
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
}`}>{label}</span>
<Input value={url} readOnly className="min-w-0 font-mono text-sm" />
<button
type="button"
onClick={() => onCopy(url, copyId)}
className="grid size-9 place-items-center rounded-lg text-text-muted transition-colors hover:bg-primary/10 hover:text-primary"
title={copied === copyId ? "Copied" : "Copy endpoint"}
aria-label={copied === copyId ? "Endpoint copied" : `Copy ${label} endpoint`}
>
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
</button>
@@ -4,8 +4,14 @@
export default function Tooltip({ text }) {
return (
<span className="relative group inline-flex items-center">
<span className="material-symbols-outlined text-[14px] text-text-muted cursor-help">help</span>
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
<button
type="button"
className="material-symbols-outlined text-[14px] text-text-muted cursor-help rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={text}
>
help
</button>
<span role="tooltip" className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity shadow-lg">
{text}
</span>
</span>
@@ -1,21 +1,5 @@
export const WENYAN_LOCALES = ["zh-CN", "zh-TW"];
export const TUNNEL_BENEFITS = [
{ icon: "public", title: "Access Anywhere", desc: "Use your API from any network" },
{ icon: "group", title: "Share Endpoint", desc: "Share URL with team members" },
{ icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" },
{ icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" },
];
export const TUNNEL_PING_INTERVAL_MS = 2000;
export const TUNNEL_PING_MAX_MS = 300000;
export const STATUS_POLL_FAST_MS = 5000;
export const STATUS_POLL_SLOW_MS = 30000;
export const REACHABLE_MISS_THRESHOLD = 5;
export const CLIENT_PING_FAST_MS = 10000;
export const CLIENT_PING_SLOW_MS = 60000;
export const CLIENT_PING_TIMEOUT_MS = 5000;
export const CAVEMAN_LEVELS = [
{ id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
{ id: "full", label: "Full", desc: "Drop articles, fragments OK" },
@@ -1,29 +0,0 @@
import { CLIENT_PING_TIMEOUT_MS } from "./endpointConstants";
// Browser-side health probe: must reach origin (not just CF/TS edge).
// cors mode → res.ok=false for 5xx (e.g. Cloudflare 530 when origin dead).
// /api/health route sets Access-Control-Allow-Origin: * → CORS works through tunnel.
export async function clientPingUrl(url) {
if (!url) return false;
try {
const res = await fetch(`${url}/api/health`, {
mode: "cors",
cache: "no-store",
signal: AbortSignal.timeout(CLIENT_PING_TIMEOUT_MS),
});
return res.ok;
} catch { return false; }
}
// Race multiple URLs: resolve true as soon as any one passes ping.
export async function clientPingAny(...urls) {
const checks = urls.filter(Boolean).map(clientPingUrl);
if (!checks.length) return false;
return new Promise((resolve) => {
let pending = checks.length;
checks.forEach((p) => p.then((ok) => {
if (ok) resolve(true);
else if (--pending === 0) resolve(false);
}));
});
}
@@ -1,8 +0,0 @@
import { getMachineId } from "@/shared/utils/machine";
import { getCurrentDashboardUser } from "@/lib/auth/currentUser";
import EndpointPageClient from "./EndpointPageClient";
export default async function EndpointPage() {
const [machineId, user] = await Promise.all([getMachineId(), getCurrentDashboardUser()]);
return <EndpointPageClient machineId={machineId} isAdmin={user?.role !== "user"} />;
}
@@ -27,9 +27,7 @@ export function EmbeddingExampleCard({ providerId, customAlias }) {
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
const [dimensions, setDimensions] = useState("");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [result, setResult] = useState(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
@@ -42,13 +40,9 @@ export function EmbeddingExampleCard({ providerId, customAlias }) {
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
}, []);
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const endpoint = localEndpoint;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
// Build request body — include dimensions only if user provided a positive number
@@ -135,23 +129,10 @@ export function EmbeddingExampleCard({ providerId, customAlias }) {
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<input
value={endpoint}
onChange={(e) => useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
onChange={(e) => setLocalEndpoint(e.target.value)}
className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
placeholder="http://localhost:3000"
/>
{/* Tunnel toggle — only show if tunnel URL is available */}
{tunnelEndpoint && (
<button
onClick={() => setUseTunnel((v) => !v)}
title={useTunnel ? "Using tunnel" : "Using local"}
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
}`}
>
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
Tunnel
</button>
)}
</div>
</Row>
@@ -54,9 +54,7 @@ export function GenericExampleCard({ providerId, kind }) {
(safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
);
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [result, setResult] = useState(null);
const [progress, setProgress] = useState(null); // { stage, bytesReceived }
const [partialImage, setPartialImage] = useState(null);
@@ -75,10 +73,6 @@ export function GenericExampleCard({ providerId, kind }) {
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
// Load active connections of this provider for pinning
fetch("/api/providers/client")
.then((r) => r.json())
@@ -92,7 +86,7 @@ export function GenericExampleCard({ providerId, kind }) {
// Safe to early-return now that all hooks are declared
if (!kindConfig || !exConfig) return null;
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const endpoint = localEndpoint;
const apiPath = kindConfig.endpoint.path;
// webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present.
const modelFull = !needsModel
@@ -257,18 +251,6 @@ export function GenericExampleCard({ providerId, kind }) {
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
{endpoint}{apiPath}
</span>
{tunnelEndpoint && (
<button
onClick={() => setUseTunnel((v) => !v)}
title={useTunnel ? "Using tunnel" : "Using local"}
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
}`}
>
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
Tunnel
</button>
)}
</div>
</Row>
@@ -24,9 +24,7 @@ export function SttExampleCard({ providerId }) {
const [responseFormat, setResponseFormat] = useState("json");
const [temperature, setTemperature] = useState("");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [result, setResult] = useState(null);
const [latency, setLatency] = useState(null);
const [running, setRunning] = useState(false);
@@ -40,10 +38,6 @@ export function SttExampleCard({ providerId }) {
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
const loadCustom = () => {
fetch("/api/models/custom", { cache: "no-store" })
.then((r) => r.json())
@@ -62,7 +56,7 @@ export function SttExampleCard({ providerId }) {
};
}, [providerAlias]);
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const endpoint = localEndpoint;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\
@@ -139,18 +133,6 @@ export function SttExampleCard({ providerId }) {
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
{endpoint}/v1/audio/transcriptions
</span>
{tunnelEndpoint && (
<button
onClick={() => setUseTunnel((v) => !v)}
title={useTunnel ? "Using tunnel" : "Using local"}
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
}`}
>
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
Tunnel
</button>
)}
</div>
</Row>
@@ -41,9 +41,7 @@ export function TtsExampleCard({ providerId }) {
// Form state
const [input, setInput] = useState("Hello, this is a text to speech test.");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
const [audioUrl, setAudioUrl] = useState("");
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
@@ -68,11 +66,6 @@ export function TtsExampleCard({ providerId }) {
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
// Pre-select default voice based on provider config
if (config.voiceSource === "hardcoded") {
const defaultModel = config.hasModelSelector && config.modelKey
@@ -171,7 +164,7 @@ export function TtsExampleCard({ providerId }) {
)
: languages;
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const endpoint = localEndpoint;
// For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice
const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice;
const modelFull = (() => {
@@ -243,18 +236,6 @@ export function TtsExampleCard({ providerId }) {
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
{endpoint}/v1/audio/speech
</span>
{tunnelEndpoint && (
<button
onClick={() => setUseTunnel((v) => !v)}
title={useTunnel ? "Using tunnel" : "Using local"}
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
}`}
>
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
Tunnel
</button>
)}
</div>
</Row>
<Row label="API Key">
@@ -457,7 +438,7 @@ export function TtsExampleCard({ providerId }) {
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
</div>
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
{error && <p className="text-xs text-red-500 wrap-break-word">{error}</p>}
{/* Audio player */}
{audioUrl ? (
+4 -3
View File
@@ -1,7 +1,8 @@
import { getMachineId } from "@/shared/utils/machine";
import EndpointPageClient from "./endpoint/EndpointPageClient";
import { getCurrentDashboardUser } from "@/lib/auth/currentUser";
import DashboardPageClient from "./DashboardPageClient";
export default async function DashboardPage() {
const machineId = await getMachineId();
return <EndpointPageClient machineId={machineId} />;
const user = await getCurrentDashboardUser();
return <DashboardPageClient isAdmin={user?.role === "admin"} />;
}
-12
View File
@@ -10,13 +10,6 @@ import { verifyUserCredentials } from "@/lib/db";
const RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default.";
const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
function isTunnelRequest(request, settings) {
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
const tunnelHost = settings.tunnelUrl ? new URL(settings.tunnelUrl).hostname.toLowerCase() : "";
const tailscaleHost = settings.tailscaleUrl ? new URL(settings.tailscaleUrl).hostname.toLowerCase() : "";
return (tunnelHost && host === tunnelHost) || (tailscaleHost && host === tailscaleHost);
}
export async function POST(request) {
try {
const ip = getClientIp(request);
@@ -31,11 +24,6 @@ export async function POST(request) {
const { username, password } = await request.json();
const settings = await getSettings();
// Block login via tunnel/tailscale if dashboard access is disabled
if (isTunnelRequest(request, settings) && settings.tunnelDashboardAccess !== true) {
return NextResponse.json({ error: "Dashboard access via tunnel is disabled" }, { status: 403 });
}
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
}
-1
View File
@@ -98,7 +98,6 @@ export async function PATCH(request) {
if (
Object.prototype.hasOwnProperty.call(body, "requireApiKey") ||
Object.prototype.hasOwnProperty.call(body, "tunnelDashboardAccess") ||
TOKEN_SAVER_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key))
) {
let user;
-17
View File
@@ -1,17 +0,0 @@
import { NextResponse } from "next/server";
import { disableTunnel } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
export async function POST() {
try {
const result = await disableTunnel();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tunnel monitor update failed:", error.message));
return NextResponse.json(result);
} catch (error) {
console.error("Tunnel disable error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
-21
View File
@@ -1,21 +0,0 @@
import { NextResponse } from "next/server";
import { enableTunnel } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
const DNS_WARMUP_DELAY_MS = 8000;
export async function POST() {
try {
const result = await enableTunnel();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tunnel monitor start failed:", error.message));
// Wait for DNS warmup to propagate at Cloudflare edge after tunnel registered
await new Promise((r) => setTimeout(r, DNS_WARMUP_DELAY_MS));
return NextResponse.json(result);
} catch (error) {
console.error("Tunnel enable error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
-25
View File
@@ -1,25 +0,0 @@
import { NextResponse } from "next/server";
import { getTunnelStatus, getTailscaleStatus, getDownloadStatus } from "@/lib/tunnel";
const STATUS_CACHE_TTL_MS = 3000; // coalesce rapid polls; underlying probes already cache 10s
// Survive hot reload; one cache per process. Only tunnel/tailscale probes are cached —
// download progress stays live so the enable/download UI updates smoothly.
const statusCache = (global.__tunnelStatusCache ??= { value: null, fetchedAt: 0 });
export async function GET() {
try {
let probes = statusCache.value;
if (!probes || Date.now() - statusCache.fetchedAt >= STATUS_CACHE_TTL_MS) {
const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
probes = { tunnel, tailscale };
statusCache.value = probes;
statusCache.fetchedAt = Date.now();
}
const download = getDownloadStatus();
return NextResponse.json({ ...probes, download });
} catch (error) {
console.error("Tunnel status error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
@@ -1,54 +0,0 @@
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { NextResponse } from "next/server";
import { isTailscaleInstalled, isTailscaleLoggedIn, isSystemDaemonRunning, getTailscaleBin, TAILSCALE_SOCKET } from "@/lib/tunnel";
import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager";
const execAsync = promisify(exec);
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`;
const PROBE_TIMEOUT_MS = 1500;
async function hasBrew() {
try {
await execAsync("which brew", { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS });
return true;
} catch { return false; }
}
async function isCustomDaemonRunning() {
const bin = getTailscaleBin();
if (!bin) return false;
try {
await execAsync(`"${bin}" --socket ${TAILSCALE_SOCKET} status --json`, {
windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH },
timeout: PROBE_TIMEOUT_MS
});
return true;
} catch {
try {
await execAsync("pgrep -x tailscaled", { windowsHide: true, timeout: PROBE_TIMEOUT_MS });
return true;
} catch { return false; }
}
}
export async function GET() {
try {
const installed = isTailscaleInstalled();
const platform = os.platform();
// Run independent probes in parallel — none blocks the event loop
const [brewAvailable, customDaemonRunning, systemDaemonRunning] = await Promise.all([
platform === "darwin" ? hasBrew() : Promise.resolve(false),
installed ? isCustomDaemonRunning() : Promise.resolve(false),
installed ? Promise.resolve(isSystemDaemonRunning()) : Promise.resolve(false),
]);
const daemonRunning = customDaemonRunning || systemDaemonRunning;
const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
const hasCachedPassword = !!(getCachedPassword() || await loadEncryptedPassword());
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, customDaemonRunning, systemDaemonRunning, hasCachedPassword });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
@@ -1,17 +0,0 @@
import { NextResponse } from "next/server";
import { disableTailscale } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
export async function POST() {
try {
const result = await disableTailscale();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tailscale monitor update failed:", error.message));
return NextResponse.json(result);
} catch (error) {
console.error("Tailscale disable error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
@@ -1,17 +0,0 @@
import { NextResponse } from "next/server";
import { enableTailscale } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
export async function POST() {
try {
const result = await enableTailscale();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tailscale monitor start failed:", error.message));
return NextResponse.json(result);
} catch (error) {
console.error("Tailscale enable error:", error.message);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
@@ -1,72 +0,0 @@
"use server";
import os from "os";
import { execSync } from "child_process";
import { installTailscale, loadState, generateShortId } from "@/lib/tunnel";
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
import { getSettings, updateSettings } from "@/lib/localDb";
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", windowsHide: true, 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) {
let closed = false;
const send = (event, data) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
closed = true;
}
};
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 {
if (!closed) { try { controller.close(); } catch {} }
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
+94 -95
View File
@@ -8,43 +8,42 @@
.fonts-loaded .material-symbols-outlined { visibility: visible; }
/* ============================================================
9Router palette — adopted from 9remote_private/web
Brand orange (dark) / soft coral (light), neutral warm bases
============================================================ */
9Router palette — cool slate control plane with coral accent
============================================================ */
:root {
/* Brand scale (light) - centered on #E56A4A */
--color-brand-50: #fdf1ed;
--color-brand-100: #fadccf;
--color-brand-200: #f4b59c;
--color-brand-300: #ee8d6a;
--color-brand-400: #ea7855;
--color-brand-500: #E56A4A;
--color-brand-600: #cc5236;
--color-brand-700: #a64027;
--color-brand-800: #7a2f1d;
--color-brand-900: #4d1e12;
/* Brand scale (light) coral is reserved for primary actions and emphasis. */
--color-brand-50: #fff1ed;
--color-brand-100: #ffdfd5;
--color-brand-200: #ffc0ad;
--color-brand-300: #ff9a7d;
--color-brand-400: #fb7759;
--color-brand-500: #ed6548;
--color-brand-600: #d84c31;
--color-brand-700: #b33c27;
--color-brand-800: #8f3324;
--color-brand-900: #762f24;
/* Primary (legacy alias for backward compat with existing components) */
--color-primary: var(--color-brand-500);
--color-primary-hover: var(--color-brand-600);
/* Surfaces & backgrounds (light) */
--color-bg: #FDFAF6;
--color-bg-alt: #F7F3EE;
/* Surfaces & backgrounds (light) — a cool neutral family. */
--color-bg: #f7f8fa;
--color-bg-alt: #eef1f5;
--color-surface: #ffffff;
--color-surface-2: #f4f4f5;
--color-surface-3: #e7e7e9;
--color-sidebar: rgba(244, 241, 236, 0.85);
--color-surface-2: #f1f3f6;
--color-surface-3: #e4e8ed;
--color-sidebar: rgba(247, 248, 250, 0.86);
/* Borders */
--color-border: #e5e7eb;
--color-border-subtle: #f1f1f3;
--color-border: #dce2e9;
--color-border-subtle: #e9edf2;
/* Text */
--color-text: #0a0a0a;
--color-text-main: #0a0a0a;
--color-text-muted: #6B7280;
--color-text-subtle: #9CA3AF;
--color-text: #171d27;
--color-text-main: #171d27;
--color-text-muted: #657084;
--color-text-subtle: #98a2b3;
/* Status */
--color-danger: #cf222e;
@@ -57,63 +56,63 @@
--radius-brand-lg: 14px;
/* Shadows */
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.04);
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.18);
--shadow-elevated: 0 12px 28px -4px rgba(60, 50, 45, 0.06);
--shadow-soft: 0 1px 2px 0 rgba(32, 44, 62, 0.05);
--shadow-warm: 0 8px 18px -12px rgba(216, 76, 49, 0.42);
--shadow-elevated: 0 14px 34px -14px rgba(32, 44, 62, 0.16);
--shadow-elev:
inset 0 1px 0 0 rgba(255,255,255,0.8),
0 1px 2px rgba(15,23,42,0.04),
0 12px 36px -8px rgba(15,23,42,0.10);
--shadow-focus: 0 0 0 3px rgba(229,106,74,0.18);
inset 0 1px 0 0 rgba(255,255,255,0.88),
0 1px 2px rgba(32, 44, 62, 0.05),
0 14px 36px -14px rgba(32, 44, 62, 0.16);
--shadow-focus: 0 0 0 3px rgba(237, 101, 72, 0.2);
color-scheme: light;
}
.dark {
/* Brand scale (dark) - centered on #E56A4A, same as light for consistency */
--color-brand-50: #fdf1ed;
--color-brand-100: #fadccf;
--color-brand-200: #f4b59c;
--color-brand-300: #ee8d6a;
--color-brand-400: #ea7855;
--color-brand-500: #E56A4A;
--color-brand-600: #cc5236;
--color-brand-700: #a64027;
--color-brand-800: #7a2f1d;
--color-brand-900: #4d1e12;
/* Same brand hue, calibrated for a dark developer control plane. */
--color-brand-50: #fff1ed;
--color-brand-100: #ffdfd5;
--color-brand-200: #ffc0ad;
--color-brand-300: #ffad96;
--color-brand-400: #fb8569;
--color-brand-500: #f17052;
--color-brand-600: #dc563a;
--color-brand-700: #b94530;
--color-brand-800: #913829;
--color-brand-900: #773025;
--color-primary: #E56A4A;
--color-primary-hover: #cc5236;
--color-primary: var(--color-brand-500);
--color-primary-hover: var(--color-brand-400);
/* Surfaces (dark - Claude-like neutral warm) */
--color-bg: #1a1a1a;
--color-bg-alt: #1F1F1E;
--color-surface: #262626;
--color-surface-2: #303030;
--color-surface-3: #3a3a3a;
--color-sidebar: rgba(30, 30, 30, 0.85);
/* Surfaces (dark) — all greys share a restrained blue-slate undertone. */
--color-bg: #0f1115;
--color-bg-alt: #141820;
--color-surface: #191e26;
--color-surface-2: #212833;
--color-surface-3: #2a3340;
--color-sidebar: rgba(15, 17, 21, 0.88);
--color-border: #333333;
--color-border-subtle: #2a2a2a;
--color-border: #2d3744;
--color-border-subtle: #222b36;
--color-text: #ededed;
--color-text-main: #ededed;
--color-text-muted: #9ca3af;
--color-text-subtle: #6b7280;
--color-text: #f1f5f9;
--color-text-main: #f1f5f9;
--color-text-muted: #a5b0bf;
--color-text-subtle: #708096;
--color-danger: #ef4444;
--color-success: #22c55e;
--color-warning: #fbbf24;
--color-info: #60a5fa;
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.3);
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.25);
--shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.45);
--shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.28);
--shadow-warm: 0 10px 22px -15px rgba(241, 112, 82, 0.5);
--shadow-elevated: 0 18px 38px -18px rgba(0, 0, 0, 0.62);
--shadow-elev:
inset 0 1px 0 0 rgba(255,255,255,0.06),
0 1px 2px rgba(0,0,0,0.4),
0 16px 48px -8px rgba(0,0,0,0.55);
--shadow-focus: 0 0 0 3px rgba(229, 106, 74, 0.18);
inset 0 1px 0 0 rgba(255,255,255,0.05),
0 1px 2px rgba(0,0,0,0.42),
0 18px 44px -18px rgba(0,0,0,0.64);
--shadow-focus: 0 0 0 3px rgba(241, 112, 82, 0.22);
color-scheme: dark;
}
@@ -171,18 +170,18 @@ select {
--color-info: var(--color-info);
/* Static fallbacks (explicit per-mode usage if needed) */
--color-bg-light: #FCFBF9;
--color-bg-dark: #1a1a1a;
--color-bg-light: #f7f8fa;
--color-bg-dark: #0f1115;
--color-surface-light: #ffffff;
--color-surface-dark: #262626;
--color-sidebar-light: #F4F1EC;
--color-sidebar-dark: #1F1F1E;
--color-border-light: #e5e7eb;
--color-border-dark: #333333;
--color-text-main-light: #0a0a0a;
--color-text-main-dark: #ededed;
--color-text-muted-light: #6B7280;
--color-text-muted-dark: #9ca3af;
--color-surface-dark: #191e26;
--color-sidebar-light: #f7f8fa;
--color-sidebar-dark: #0f1115;
--color-border-light: #dce2e9;
--color-border-dark: #2d3744;
--color-text-main-light: #171d27;
--color-text-main-dark: #f1f5f9;
--color-text-muted-light: #657084;
--color-text-muted-dark: #a5b0bf;
/* Radius */
--radius-brand: var(--radius-brand);
@@ -195,8 +194,8 @@ select {
--shadow-elev: var(--shadow-elev);
--shadow-focus: var(--shadow-focus);
/* Font - Inter primary, Apple system fallback */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
/* System UI keeps the operational surface crisp and fast to load. */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI Variable', 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
}
/* Base */
@@ -210,11 +209,11 @@ body {
/* Selection - brand-tinted */
::selection {
background-color: rgba(229, 106, 74, 0.25);
background-color: rgba(237, 101, 72, 0.25);
color: var(--color-primary);
}
.dark ::selection {
background-color: rgba(229, 106, 74, 0.3);
background-color: rgba(241, 112, 82, 0.3);
color: var(--color-brand-300);
}
@@ -241,19 +240,19 @@ input, textarea {
/* Thin horizontal scrollbar - brand colored */
.scroll-thin-x {
scrollbar-width: thin;
scrollbar-color: rgba(229, 106, 74, 0.55) transparent;
scrollbar-color: rgba(237, 101, 72, 0.55) transparent;
}
.dark .scroll-thin-x {
scrollbar-color: rgba(229, 106, 74, 0.55) transparent;
scrollbar-color: rgba(241, 112, 82, 0.55) transparent;
}
.scroll-thin-x::-webkit-scrollbar { height: 3px; }
.scroll-thin-x::-webkit-scrollbar-track { background: transparent; }
.scroll-thin-x::-webkit-scrollbar-thumb {
background: rgba(229, 106, 74, 0.55);
background: rgba(237, 101, 72, 0.55);
border-radius: 3px;
}
.dark .scroll-thin-x::-webkit-scrollbar-thumb {
background: rgba(229, 106, 74, 0.55);
background: rgba(241, 112, 82, 0.55);
}
/* Reusable elevated card */
@@ -280,7 +279,7 @@ input, textarea {
background: rgba(255, 255, 255, 0.72);
}
.dark .bg-vibrancy {
background: rgba(38, 38, 38, 0.72);
background: rgba(15, 17, 21, 0.74);
}
/* macOS Traffic Lights */
@@ -421,25 +420,25 @@ button:disabled,
.dot-grid-bg {
background-color: var(--color-bg);
background-image:
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.10) 0%, transparent 40%),
radial-gradient(circle at 85% 80%, rgba(229, 106, 74, 0.06) 0%, transparent 40%);
radial-gradient(circle at 15% 20%, rgba(237, 101, 72, 0.09) 0%, transparent 40%),
radial-gradient(circle at 85% 80%, rgba(237, 101, 72, 0.05) 0%, transparent 40%);
}
.dark .dot-grid-bg {
background-image:
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.18) 0%, transparent 40%),
radial-gradient(circle at 85% 80%, rgba(229, 106, 74, 0.10) 0%, transparent 40%);
radial-gradient(circle at 15% 20%, rgba(241, 112, 82, 0.12) 0%, transparent 40%),
radial-gradient(circle at 85% 80%, rgba(241, 112, 82, 0.06) 0%, transparent 40%);
}
/* Landing-style faint grid overlay (use absolute pos inside relative parent) */
.landing-grid {
background-image:
linear-gradient(to right, var(--color-accent) 1px, transparent 1px),
linear-gradient(to bottom, var(--color-accent) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.08;
linear-gradient(to right, var(--color-border) 1px, transparent 1px),
linear-gradient(to bottom, var(--color-border) 1px, transparent 1px);
background-size: 44px 44px;
opacity: 0.28;
}
.dark .landing-grid {
opacity: 0.04;
opacity: 0.16;
}
/* React Flow controls: match app theme */
+1 -1
View File
@@ -4,7 +4,7 @@ import "material-symbols/outlined.css";
import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
import "@/shared/services/bootstrap"; // Auto-run initializeApp (watchdog, auto-resume tunnel)
import "@/shared/services/bootstrap";
import { initConsoleLogCapture } from "@/lib/consoleLogBuffer";
import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider";
+2 -40
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getSettings, getUserById, validateApiKey } from "@/lib/localDb";
import { getUserById, validateApiKey } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { getDashboardAuthSession, verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
@@ -47,7 +47,6 @@ const ADMIN_ONLY_PATHS = [
"/api/providers",
"/api/provider-nodes",
"/api/oauth",
"/api/tunnel",
"/api/headroom",
"/api/pxpipe",
"/api/media-providers",
@@ -83,18 +82,11 @@ const PROTECTED_API_PATHS = [
"/api/tags",
"/api/mcp",
"/api/translator",
"/api/tunnel",
];
// Routes that spawn child processes or read host secrets — restrict to localhost.
const LOCAL_ONLY_PATHS = [
"/api/mcp/",
"/api/tunnel/tailscale-install",
"/api/tunnel/tailscale-enable",
"/api/tunnel/tailscale-disable",
"/api/tunnel/tailscale-check",
"/api/tunnel/enable",
"/api/tunnel/disable",
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/auth/reset-password",
@@ -160,7 +152,7 @@ async function canAccessPublicLlmApi(request) {
async function canAccessLocalOnlyRoute(request) {
if (await hasValidCliToken(request)) return true;
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + JWT auth.
// Browser on host: loopback Host + Origin + JWT auth.
if (isLocalRequest(request) && await isAuthenticated(request)) return true;
return false;
}
@@ -188,15 +180,6 @@ async function isAdmin(request) {
return user?.isActive === true && user.role === "admin";
}
// Read settings directly from DB to avoid self-fetch deadlock in proxy
async function loadSettings() {
try {
return await getSettings();
} catch {
return null;
}
}
async function isAuthenticated(request) {
return hasValidToken(request);
}
@@ -270,27 +253,6 @@ export async function proxy(request) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
let tunnelDashboardAccess = true;
try {
const settings = await loadSettings();
if (settings) {
tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
// Block tunnel/tailscale access if disabled (redirect to login)
if (!tunnelDashboardAccess) {
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
const tunnelHost = settings.tunnelUrl ? new URL(settings.tunnelUrl).hostname.toLowerCase() : "";
const tailscaleHost = settings.tailscaleUrl ? new URL(settings.tailscaleUrl).hostname.toLowerCase() : "";
if ((tunnelHost && host === tunnelHost) || (tailscaleHost && host === tailscaleHost)) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
}
} catch {
// On error, keep the secure default and block tunnel dashboard access.
}
// Verify JWT token
const token = request.cookies.get("auth_token")?.value;
if (token) {
@@ -0,0 +1,38 @@
const REMOVED_SETTING_KEYS = [
"tunnelEnabled",
"tunnelUrl",
"tunnelProvider",
"tailscaleEnabled",
"tailscaleUrl",
"tunnelDashboardAccess",
];
const removeTunnelSettingsMigration = {
version: 9,
name: "remove-tunnel-settings",
up(db) {
const row = db.get("SELECT data FROM settings WHERE id = 1");
if (!row?.data) return;
let settings;
try {
settings = JSON.parse(row.data);
} catch {
return;
}
let changed = false;
for (const key of REMOVED_SETTING_KEYS) {
if (Object.prototype.hasOwnProperty.call(settings, key)) {
delete settings[key];
changed = true;
}
}
if (changed) {
db.run("UPDATE settings SET data = ? WHERE id = 1", [JSON.stringify(settings)]);
}
},
};
export default removeTunnelSettingsMigration;
+2 -1
View File
@@ -9,8 +9,9 @@ import m005 from "./005-usage-user-attribution.js";
import m006 from "./006-combo-owners.js";
import m007 from "./007-admin-provider-connections.js";
import m008 from "./008-user-token-limits.js";
import m009 from "./009-remove-tunnel-settings.js";
export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008].sort((a, b) => a.version - b.version);
export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009].sort((a, b) => a.version - b.version);
export function latestVersion() {
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
+18 -10
View File
@@ -3,21 +3,23 @@ import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
const REMOVED_TUNNEL_SETTING_KEYS = new Set([
"tunnelEnabled",
"tunnelUrl",
"tunnelProvider",
"tailscaleEnabled",
"tailscaleUrl",
"tunnelDashboardAccess",
]);
const DEFAULT_SETTINGS = {
cloudEnabled: false,
tunnelEnabled: false,
tunnelUrl: "",
tunnelProvider: "cloudflare",
tailscaleEnabled: false,
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
providerStrategies: {},
quotaVisibility: {},
comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1,
comboStrategies: {},
tunnelDashboardAccess: true,
authMode: "password",
oidcIssuerUrl: "",
oidcClientId: "",
@@ -51,12 +53,18 @@ const DEFAULT_SETTINGS = {
async function readRaw() {
const db = await getAdapter();
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
return row ? parseJson(row.data, {}) : {};
return removeTunnelSettings(row ? parseJson(row.data, {}) : {});
}
function removeTunnelSettings(settings) {
const sanitized = { ...(settings || {}) };
for (const key of REMOVED_TUNNEL_SETTING_KEYS) delete sanitized[key];
return sanitized;
}
// Merge raw settings with defaults; backward-compat for missing keys
function mergeWithDefaults(raw) {
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
const merged = { ...DEFAULT_SETTINGS, ...removeTunnelSettings(raw) };
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (merged[key] === undefined) {
if (
@@ -84,8 +92,8 @@ export async function updateSettings(updates) {
let next;
db.transaction(() => {
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
const current = row ? parseJson(row.data, {}) : {};
next = { ...current, ...updates };
const current = removeTunnelSettings(row ? parseJson(row.data, {}) : {});
next = removeTunnelSettings({ ...current, ...updates });
db.run(
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
[stringifyJson(next)]
+1 -1
View File
@@ -3,7 +3,7 @@
// pre-change safety backup in migrate.js: when the stored version is lower,
// one lightweight DB backup is taken before applying schema changes. Forgetting
// to bump only skips that backup — it does NOT break the additive auto-sync.
export const SCHEMA_VERSION = 9;
export const SCHEMA_VERSION = 10;
export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL;
-449
View File
@@ -1,449 +0,0 @@
import fs from "fs";
import path from "path";
import https from "https";
import os from "os";
import { execSync, spawn } from "child_process";
import { savePid, loadPid, clearPid } from "./pid.js";
import { DATA_DIR } from "@/lib/dataDir.js";
const BIN_DIR = path.join(DATA_DIR, "bin");
const BINARY_NAME = "cloudflared";
const IS_WINDOWS = os.platform() === "win32";
const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
const POWERSHELL_HIDDEN_COMMAND = "powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command";
const DEFAULT_QUICK_TUNNEL_PROTOCOL = "http2";
const QUICK_TUNNEL_PROTOCOLS = new Set(["http2", "quic", "auto"]);
const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
const PLATFORM_MAPPINGS = {
darwin: {
x64: "cloudflared-darwin-amd64.tgz",
arm64: "cloudflared-darwin-arm64.tgz"
},
win32: {
x64: "cloudflared-windows-amd64.exe",
ia32: "cloudflared-windows-386.exe",
arm64: "cloudflared-windows-386.exe"
},
linux: {
x64: "cloudflared-linux-amd64",
arm64: "cloudflared-linux-arm64"
}
};
// Fallback order: prefer smallest/most-compatible binary per platform
const PLATFORM_FALLBACK = {
darwin: "cloudflared-darwin-amd64.tgz",
win32: "cloudflared-windows-386.exe",
linux: "cloudflared-linux-amd64"
};
function getDownloadUrl() {
const platform = os.platform();
const arch = os.arch();
const platformMapping = PLATFORM_MAPPINGS[platform];
if (!platformMapping) {
throw new Error(`Unsupported platform: ${platform}`);
}
const binaryName = platformMapping[arch] || PLATFORM_FALLBACK[platform];
return `${GITHUB_BASE_URL}/${binaryName}`;
}
// Download state — shared so status API can read it
const dlState = { downloading: false, progress: 0 };
export function getDownloadStatus() {
return { downloading: dlState.downloading, progress: dlState.progress };
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
https.get(url, (response) => {
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
file.close();
fs.unlinkSync(dest);
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(dest);
reject(new Error(`Download failed with status ${response.statusCode}`));
return;
}
const totalBytes = parseInt(response.headers["content-length"], 10) || 0;
let receivedBytes = 0;
dlState.downloading = true;
dlState.progress = 0;
response.on("data", (chunk) => {
receivedBytes += chunk.length;
if (totalBytes > 0) dlState.progress = Math.round((receivedBytes / totalBytes) * 100);
});
response.pipe(file);
file.on("finish", () => {
dlState.downloading = false;
dlState.progress = 100;
file.close(() => resolve(dest));
});
file.on("error", (err) => {
dlState.downloading = false;
dlState.progress = 0;
file.close();
fs.unlinkSync(dest);
reject(err);
});
}).on("error", (err) => {
dlState.downloading = false;
dlState.progress = 0;
file.close();
if (fs.existsSync(dest)) fs.unlinkSync(dest);
reject(err);
});
});
}
const MIN_BINARY_SIZE = 1024 * 1024; // 1MB - cloudflared is ~30MB+
// Validate binary is executable on current platform and not truncated
function isValidBinary(filePath) {
try {
const stat = fs.statSync(filePath);
if (stat.size < MIN_BINARY_SIZE) return false;
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(4);
fs.readSync(fd, buf, 0, 4, 0);
fs.closeSync(fd);
const magic = buf.toString("hex");
if (IS_WINDOWS) return magic.startsWith("4d5a"); // PE (MZ)
if (os.platform() === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
return magic.startsWith("7f454c46"); // ELF (Linux)
} catch {
return false;
}
}
let downloadPromise = null;
export async function ensureCloudflared() {
if (downloadPromise) return downloadPromise;
downloadPromise = _ensureCloudflared().finally(() => { downloadPromise = null; });
return downloadPromise;
}
async function _ensureCloudflared() {
if (!fs.existsSync(BIN_DIR)) {
fs.mkdirSync(BIN_DIR, { recursive: true });
}
// Clean up incomplete downloads from previous runs
const tmpPath = `${BIN_PATH}.tmp`;
if (fs.existsSync(tmpPath)) {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
if (fs.existsSync(BIN_PATH)) {
if (!isValidBinary(BIN_PATH)) {
console.log("[cloudflared] Invalid binary detected, re-downloading...");
fs.unlinkSync(BIN_PATH);
} else {
if (!IS_WINDOWS) fs.chmodSync(BIN_PATH, "755");
return BIN_PATH;
}
}
const url = getDownloadUrl();
const isArchive = url.endsWith(".tgz");
const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz.tmp") : tmpPath;
await downloadFile(url, downloadDest);
if (isArchive) {
execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true });
fs.unlinkSync(downloadDest);
} else {
fs.renameSync(downloadDest, BIN_PATH);
}
if (!IS_WINDOWS) {
fs.chmodSync(BIN_PATH, "755");
}
return BIN_PATH;
}
let cloudflaredProcess = null;
let unexpectedExitHandler = null;
let intentionalKill = false; // suppress unexpected-exit callback during deliberate kill
/** Register a callback to be called when cloudflared exits unexpectedly after connecting */
export function setUnexpectedExitHandler(handler) {
unexpectedExitHandler = handler;
}
export async function spawnCloudflared(tunnelToken) {
const binaryPath = await ensureCloudflared();
const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], {
detached: false,
windowsHide: true,
cwd: os.tmpdir(),
stdio: ["ignore", "pipe", "pipe"]
});
cloudflaredProcess = child;
savePid(child.pid);
return new Promise((resolve, reject) => {
let connectionCount = 0;
let resolved = false;
const timeout = setTimeout(() => {
resolved = true;
resolve(child);
}, 90000);
const handleLog = (data) => {
const msg = data.toString();
// Count exact occurrences in this chunk (each chunk may contain multiple lines)
const matches = msg.match(/Registered tunnel connection/g);
if (matches) {
connectionCount += matches.length;
if (connectionCount >= 4 && !resolved) {
resolved = true;
clearTimeout(timeout);
resolve(child);
}
}
};
child.stdout.on("data", handleLog);
child.stderr.on("data", handleLog);
child.on("error", (err) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(err);
}
});
child.on("exit", (code, signal) => {
cloudflaredProcess = null;
clearPid();
const wasConnected = resolved; // true = already connected successfully
if (!resolved) {
resolved = true;
clearTimeout(timeout);
// Collect stderr output for better error diagnosis
let stderrOutput = "";
if (child.stderr && !child.stderr.destroyed) {
// Try to read any buffered stderr (may not have all output but helps with common errors)
stderrOutput = " Check cloudflared logs for details.";
}
if (code === 1) {
// Common exit code 1 issues: invalid token, auth failure, network issues
reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Ensure your tunnel token is valid and network is reachable.`));
} else if (code === 2) {
reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Check if required arguments are correct.`));
} else {
reject(new Error(`cloudflared exited with code ${code}${stderrOutput}`));
}
return;
}
// Watchdog (initializeApp) handles recovery — no auto-reconnect here
if (intentionalKill) { intentionalKill = false; return; }
if (wasConnected && unexpectedExitHandler) unexpectedExitHandler();
});
});
}
/**
* Spawn cloudflared quick tunnel (no account needed)
* Returns the generated trycloudflare.com URL
*/
export async function spawnQuickTunnel(localPort, onUrlUpdate) {
const binaryPath = await ensureCloudflared();
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-quick-"));
const configPath = path.join(configDir, "config.yml");
// Avoid using default ~/.cloudflared/config.yml, which can conflict with quick tunnel behavior.
fs.writeFileSync(configPath, "# quick-tunnel config placeholder\n", "utf8");
let isCleaned = false;
const cleanup = () => {
if (isCleaned) return;
isCleaned = true;
try {
fs.rmSync(configDir, { recursive: true, force: true });
} catch (e) { /* ignore */ }
};
const requestedProtocol = String(process.env.TUNNEL_TRANSPORT_PROTOCOL || process.env.CLOUDFLARED_PROTOCOL || DEFAULT_QUICK_TUNNEL_PROTOCOL).trim().toLowerCase();
const tunnelProtocol = QUICK_TUNNEL_PROTOCOLS.has(requestedProtocol) ? requestedProtocol : DEFAULT_QUICK_TUNNEL_PROTOCOL;
const child = spawn(binaryPath, ["tunnel", "--url", `http://127.0.0.1:${localPort}`, "--config", configPath, "--no-autoupdate", "--retries", "99"], {
detached: false,
windowsHide: true,
cwd: os.tmpdir(),
env: {
...process.env,
TUNNEL_TRANSPORT_PROTOCOL: tunnelProtocol,
},
stdio: ["ignore", "pipe", "pipe"],
});
cloudflaredProcess = child;
savePid(child.pid);
return new Promise((resolve, reject) => {
let resolved = false;
// Keep a small tail of raw cloudflared logs to surface real failure causes
let logTail = "";
function getQuickTunnelUrlFromLog(message) {
// cloudflared logs may contain "api.trycloudflare.com" as well,
// but that is NOT the quick-tunnel endpoint we need.
const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi;
const candidates = [];
for (const match of message.matchAll(regex)) {
const host = match[1];
if (host === "api") continue;
candidates.push(`https://${host}.trycloudflare.com`);
}
if (!candidates.length) return null;
return candidates[candidates.length - 1];
}
const timeout = setTimeout(() => {
if (resolved) return;
resolved = true;
cleanup();
reject(new Error(`Quick tunnel timed out. Last log: ${logTail.slice(-800) || "(empty)"}`));
}, 90000);
let lastUrl = null;
const handleLog = (data) => {
const msg = data.toString();
logTail = (logTail + msg).slice(-4000);
const tunnelUrl = getQuickTunnelUrlFromLog(msg);
if (!tunnelUrl) return;
if (!resolved) {
// First URL — resolve the promise, do NOT call onUrlUpdate (caller handles initial register)
resolved = true;
lastUrl = tunnelUrl;
clearTimeout(timeout);
cleanup();
console.log(`[Tunnel] cloudflared URL: ${tunnelUrl}`);
resolve({ child, tunnelUrl });
return;
}
// URL changed after initial connect — notify caller to re-register
if (tunnelUrl !== lastUrl) {
console.log(`[Tunnel] cloudflared URL changed: ${tunnelUrl}`);
lastUrl = tunnelUrl;
if (onUrlUpdate) onUrlUpdate(tunnelUrl);
}
};
child.stdout.on("data", handleLog);
child.stderr.on("data", handleLog);
child.on("error", (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
cleanup();
reject(err);
});
child.on("exit", (code, signal) => {
cloudflaredProcess = null;
clearPid();
// Deliberate kill (restart/disable) — exit silently, no error noise
if (intentionalKill) {
intentionalKill = false;
clearTimeout(timeout);
cleanup();
if (!resolved) { resolved = true; reject(new Error("cloudflared killed")); }
return;
}
console.log(`[Tunnel] cloudflared exit code=${code} signal=${signal}`);
if (!resolved) {
resolved = true;
clearTimeout(timeout);
cleanup();
const tail = logTail.slice(-600).trim() || "(empty)";
if (code === 1) {
reject(new Error(`cloudflared quick tunnel exited (code 1). Common causes: (1) outbound port 7844 (TCP/UDP) blocked, (2) TryCloudflare service issue, (3) cannot reach 127.0.0.1:${localPort}, (4) protocol (http2/quic) blocked by network. Last log: ${tail}`));
} else if (code === 2) {
reject(new Error(`cloudflared exited (code 2). Bad arguments. Last log: ${tail}`));
} else {
reject(new Error(`cloudflared exited (code ${code}). Last log: ${tail}`));
}
return;
}
if (unexpectedExitHandler) unexpectedExitHandler();
cleanup();
});
});
}
// Kill cloudflared processes whose command line targets the given port (any host).
// Boundary check ensures :20128 doesn't match :201280 or :202128.
function killCloudflaredByPort(port) {
if (!port) return;
try {
if (IS_WINDOWS) {
const psCmd = `Get-CimInstance Win32_Process -Filter \\"Name='cloudflared.exe'\\" | Where-Object { $_.CommandLine -match ':${port}(\\D|$)' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
execSync(`${POWERSHELL_HIDDEN_COMMAND} "${psCmd}"`, { stdio: "ignore", windowsHide: true });
} else {
execSync(`pkill -f "cloudflared.*:${port}([^0-9]|$)" 2>/dev/null || true`, { stdio: "ignore", windowsHide: true });
}
} catch (e) { /* ignore */ }
}
export function killCloudflared(localPort) {
intentionalKill = true;
if (cloudflaredProcess) {
try {
cloudflaredProcess.kill();
} catch (e) { /* ignore */ }
cloudflaredProcess = null;
}
const pid = loadPid();
if (pid) {
try {
process.kill(pid);
} catch (e) { /* ignore */ }
clearPid();
}
killCloudflaredByPort(localPort);
}
export function isCloudflaredRunning() {
const pid = loadPid();
if (!pid) return false;
try {
process.kill(pid, 0);
return true;
} catch (e) {
return false;
}
}
-9
View File
@@ -1,9 +0,0 @@
// Cloudflare quick tunnel: DNS propagates fast, short timeouts OK
export const HEALTH_CHECK = {
intervalMs: 2000,
timeoutMs: 60000,
fetchTimeoutMs: 5000,
dnsTimeoutMs: 2000,
};
export const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us";
-151
View File
@@ -1,151 +0,0 @@
import { loadState, saveState, generateShortId } from "../shared/state.js";
import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js";
import { clearPid } from "./pid.js";
import { waitForHealth, probeUrlAlive } from "./healthCheck.js";
import { WORKER_URL } from "./config.js";
import { getSettings, updateSettings } from "@/lib/localDb";
const svc = {
cancelToken: { cancelled: false },
spawnInProgress: false,
lastRestartAt: 0,
activeLocalPort: null,
};
export function getTunnelService() { return svc; }
export function isTunnelManuallyDisabled() { return svc.cancelToken.cancelled; }
export function isTunnelReconnecting() { return svc.spawnInProgress; }
let onUnexpectedExit = null;
export function setTunnelUnexpectedExitCallback(cb) { onUnexpectedExit = cb; }
async function registerTunnelUrl(shortId, tunnelUrl) {
await fetch(`${WORKER_URL}/api/tunnel/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shortId, tunnelUrl })
});
}
function throwIfCancelled(token) {
if (token.cancelled) throw new Error("tunnel cancelled");
}
export async function enableTunnel(localPort = 20128) {
console.log(`[Tunnel] enable start (port=${localPort})`);
svc.cancelToken = { cancelled: false };
svc.activeLocalPort = localPort;
svc.spawnInProgress = true;
const token = svc.cancelToken;
try {
if (isCloudflaredRunning()) {
const existing = loadState();
if (existing?.tunnelUrl && existing?.shortId) {
const publicUrl = `https://r${existing.shortId}.abc-tunnel.us`;
// Reuse only if BOTH direct + public URL alive (avoid stale socket after network change)
const [directOk, publicOk] = await Promise.all([
probeUrlAlive(existing.tunnelUrl),
probeUrlAlive(publicUrl),
]);
if (directOk && publicOk) {
console.log(`[Tunnel] already running, reuse: ${existing.tunnelUrl}`);
return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true };
}
console.log(`[Tunnel] stale (direct=${directOk} public=${publicOk}), respawn`);
}
}
killCloudflared(localPort);
console.log("[Tunnel] killed existing cloudflared");
throwIfCancelled(token);
const existing = loadState();
const shortId = existing?.shortId || generateShortId();
const onUrlUpdate = async (url) => {
if (token.cancelled) return;
console.log(`[Tunnel] url updated: ${url}`);
await registerTunnelUrl(shortId, url);
saveState({ shortId, tunnelUrl: url });
await updateSettings({ tunnelEnabled: true, tunnelUrl: url });
};
// Register exit handler BEFORE spawn so it fires even on early exit
setUnexpectedExitHandler(() => {
console.warn("[Tunnel] cloudflared exited unexpectedly, scheduling respawn");
if (onUnexpectedExit) onUnexpectedExit();
});
const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate);
console.log(`[Tunnel] spawned: ${tunnelUrl}`);
throwIfCancelled(token);
const publicUrl = `https://r${shortId}.abc-tunnel.us`;
await registerTunnelUrl(shortId, tunnelUrl);
saveState({ shortId, tunnelUrl });
await updateSettings({ tunnelEnabled: true, tunnelUrl });
console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`);
// Verify publicUrl first (worker route is reliable; direct *.trycloudflare.com DNS may lag)
await waitForHealth(publicUrl, token);
console.log("[Tunnel] public URL healthy");
// Direct tunnel probe is best-effort: DNS for *.trycloudflare.com can be slow/blocked
if (!(await probeUrlAlive(tunnelUrl))) {
console.warn("[Tunnel] direct URL not reachable yet, continuing via publicUrl");
} else {
console.log("[Tunnel] direct URL healthy");
}
console.log("[Tunnel] enable success");
return { success: true, tunnelUrl, shortId, publicUrl };
} catch (e) {
// Suppress noise when spawn was deliberately killed (restart/disable superseded it)
if (!/cloudflared killed|tunnel cancelled/.test(e.message)) {
console.error(`[Tunnel] enable error: ${e.message}`);
}
throw e;
} finally {
svc.spawnInProgress = false;
}
}
export async function disableTunnel() {
console.log("[Tunnel] disable");
// Abort any in-flight enable so it cannot resurrect state after we clear it
svc.cancelToken.cancelled = true;
setUnexpectedExitHandler(null);
try { killCloudflared(svc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); }
clearPid();
const state = loadState();
if (state) saveState({ shortId: state.shortId, tunnelUrl: null });
await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
// Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress
svc.spawnInProgress = false;
svc.activeLocalPort = null;
return { success: true };
}
export async function getTunnelStatus() {
const settings = await getSettings();
const settingsEnabled = settings.tunnelEnabled === true;
const state = loadState();
const shortId = state?.shortId || "";
const publicUrl = shortId ? `https://r${shortId}.abc-tunnel.us` : "";
const tunnelUrl = state?.tunnelUrl || "";
// Lazy: skip PID probe entirely when user disabled tunnel
const running = settingsEnabled ? isCloudflaredRunning() : false;
return {
enabled: settingsEnabled && running,
settingsEnabled,
tunnelUrl,
shortId,
publicUrl,
running
};
}
-23
View File
@@ -1,23 +0,0 @@
import fs from "fs";
import path from "path";
import { TUNNEL_DIR, ensureTunnelDir } from "../shared/state.js";
const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
export function savePid(pid) {
ensureTunnelDir();
fs.writeFileSync(PID_FILE, pid.toString());
}
export function loadPid() {
try {
if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8"));
} catch { /* ignore */ }
return null;
}
export function clearPid() {
try {
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
} catch { /* ignore */ }
}
-53
View File
@@ -1,53 +0,0 @@
// Cloudflare service
export {
enableTunnel,
disableTunnel,
getTunnelStatus,
isTunnelManuallyDisabled,
isTunnelReconnecting,
getTunnelService,
setTunnelUnexpectedExitCallback,
} from "./cloudflare/manager.js";
export {
killCloudflared,
isCloudflaredRunning,
ensureCloudflared,
getDownloadStatus,
} from "./cloudflare/cloudflared.js";
export { probeUrlAlive as probeCloudflareAlive } from "./cloudflare/healthCheck.js";
// Tailscale service
export {
enableTailscale,
disableTailscale,
getTailscaleStatus,
isTailscaleReconnecting,
getTailscaleService,
} from "./tailscale/manager.js";
export {
isTailscaleInstalled,
isTailscaleRunning,
isTailscaleRunningStrict,
isTailscaleLoggedIn,
isTailscaleLoggedInStrict,
isSystemDaemonRunning,
isDaemonAlive,
startFunnel,
getTailscaleBin,
installTailscale,
startLogin,
startDaemonWithPassword,
TAILSCALE_SOCKET,
} from "./tailscale/tailscale.js";
export { probeUrlAlive as probeTailscaleAlive } from "./tailscale/healthCheck.js";
// Shared
export { loadState, generateShortId } from "./shared/state.js";
export { checkInternet } from "./shared/internetCheck.js";
export {
RESTART_COOLDOWN_MS,
NETWORK_SETTLE_MS,
WATCHDOG_INTERVAL_MS,
NETWORK_CHECK_INTERVAL_MS,
VIRTUAL_IFACE_REGEX,
} from "./shared/watchdogConfig.js";
-41
View File
@@ -1,41 +0,0 @@
import fs from "fs";
import path from "path";
import { DATA_DIR } from "@/lib/dataDir.js";
const TUNNEL_DIR = path.join(DATA_DIR, "tunnel");
const STATE_FILE = path.join(TUNNEL_DIR, "state.json");
const SHORT_ID_LENGTH = 6;
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
export function ensureTunnelDir() {
if (!fs.existsSync(TUNNEL_DIR)) fs.mkdirSync(TUNNEL_DIR, { recursive: true });
}
export function loadState() {
try {
if (fs.existsSync(STATE_FILE)) return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
} catch { /* ignore corrupt state */ }
return null;
}
export function saveState(state) {
ensureTunnelDir();
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
export function clearState() {
try {
if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
} catch { /* ignore */ }
}
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;
}
export { TUNNEL_DIR };
-8
View File
@@ -1,8 +0,0 @@
// Watchdog + network monitor timings (shared by both services)
export const RESTART_COOLDOWN_MS = 120000;
export const NETWORK_SETTLE_MS = 2500;
export const WATCHDOG_INTERVAL_MS = 60000;
export const NETWORK_CHECK_INTERVAL_MS = 5000;
// Skip virtual/transient interfaces (tailscale utun, AirDrop awdl, bridges) that flap and cause false netchange
export const VIRTUAL_IFACE_REGEX = /^(utun|awdl|llw|anpi|bridge|gif|stf|ipsec|ap|tun|tap|vmnet|veth|docker)/i;
-7
View File
@@ -1,7 +0,0 @@
// Tailscale Funnel: cert provisioning + *.ts.net DNS propagation slower → longer timeouts
export const HEALTH_CHECK = {
intervalMs: 2000,
timeoutMs: 180000,
fetchTimeoutMs: 8000,
dnsTimeoutMs: 3000,
};
-129
View File
@@ -1,129 +0,0 @@
import { loadState, generateShortId } from "../shared/state.js";
import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, isTailscaleLoggedInStrict, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js";
import { waitForHealth } from "./healthCheck.js";
import { getSettings, updateSettings } from "@/lib/localDb";
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
initDbHooks(getSettings, updateSettings);
const svc = {
cancelToken: { cancelled: false },
spawnInProgress: false,
lastRestartAt: 0,
activeLocalPort: null,
};
export function getTailscaleService() { return svc; }
export function isTailscaleReconnecting() { return svc.spawnInProgress; }
function throwIfCancelled(token) {
if (token.cancelled) throw new Error("tailscale cancelled");
}
export async function enableTailscale(localPort = 20128) {
console.log(`[Tailscale] enable start (port=${localPort})`);
svc.cancelToken = { cancelled: false };
svc.activeLocalPort = localPort;
svc.spawnInProgress = true;
const token = svc.cancelToken;
try {
const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
await startDaemonWithPassword(sudoPass);
console.log("[Tailscale] daemon ready");
throwIfCancelled(token);
const existing = loadState();
const shortId = existing?.shortId || generateShortId();
const tsHostname = shortId;
const loggedIn = await isTailscaleLoggedInStrict();
console.log(`[Tailscale] loggedIn=${loggedIn}`);
if (!loggedIn) {
const loginResult = await startLogin(tsHostname);
if (loginResult.authUrl) {
console.log(`[Tailscale] needs login, authUrl=${loginResult.authUrl}`);
return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
}
console.log("[Tailscale] login resolved alreadyLoggedIn");
}
throwIfCancelled(token);
stopFunnel();
let result;
try {
console.log("[Tailscale] starting funnel");
result = await startFunnel(localPort);
} catch (e) {
console.error(`[Tailscale] funnel error: ${e.message}`);
// Daemon not logged in / not ready → auto-trigger login flow so user stays in-app
if (/NoState|unexpected state|not logged in|Logged ?out|NeedsLogin/i.test(e.message || "")) {
console.log("[Tailscale] retry via startLogin");
const loginResult = await startLogin(tsHostname);
if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl };
}
throw e;
}
throwIfCancelled(token);
if (result.funnelNotEnabled) {
console.log(`[Tailscale] funnel not enabled, enableUrl=${result.enableUrl}`);
return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl };
}
// Strict probe: bypass cache so we don't false-negative on first invocation
if (!(await isTailscaleLoggedInStrict()) || !(await isTailscaleRunningStrict())) {
console.error("[Tailscale] strict probe failed (device removed?)");
stopFunnel();
return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." };
}
await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl });
console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`);
// Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails)
const hostname = new URL(result.tunnelUrl).hostname;
await provisionCert(hostname);
// Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating)
let reachableNow = false;
try {
await waitForHealth(result.tunnelUrl, token);
reachableNow = true;
} catch (he) {
if (!he.message.startsWith("Health check timeout")) throw he;
console.warn(`[Tailscale] health check timed out, will retry via watchdog`);
}
console.log(`[Tailscale] enable success (reachable=${reachableNow})`);
return { success: true, tunnelUrl: result.tunnelUrl };
} catch (e) {
console.error(`[Tailscale] enable error: ${e.message}`);
throw e;
} finally {
svc.spawnInProgress = false;
}
}
export async function disableTailscale() {
console.log("[Tailscale] disable");
svc.cancelToken.cancelled = true;
stopFunnel();
await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" });
return { success: true };
}
export async function getTailscaleStatus() {
const settings = await getSettings();
const settingsEnabled = settings.tailscaleEnabled === true;
const tunnelUrl = settings.tailscaleUrl || "";
// Skip probes entirely when disabled; check login before running (device removed = not logged in)
const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false;
const running = loggedIn ? isTailscaleRunning() : false;
return {
enabled: settingsEnabled && running,
settingsEnabled,
tunnelUrl,
running,
loggedIn
};
}
-859
View File
@@ -1,859 +0,0 @@
import fs from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import { execSync, exec, spawn } from "child_process";
import { promisify } from "util";
import { execWithPassword } from "@/mitm/dns/dnsConfig";
import { DATA_DIR } from "@/lib/dataDir.js";
const execAsync = promisify(exec);
const BIN_DIR = path.join(DATA_DIR, "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(DATA_DIR, "tailscale");
export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock");
const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET];
// System daemon socket (sudo install: apt/snap/systemd) — read-only status detection
const SYSTEM_TAILSCALE_SOCKET = IS_WINDOWS ? null : "/var/run/tailscale/tailscaled.sock";
const SYSTEM_SOCKET_FLAG = SYSTEM_TAILSCALE_SOCKET ? ["--socket", SYSTEM_TAILSCALE_SOCKET] : [];
// Well-known Windows install path
const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe";
// Common Unix install paths to probe synchronously (system tailscale)
const UNIX_TAILSCALE_CANDIDATES = [
"/usr/local/bin/tailscale",
"/opt/homebrew/bin/tailscale",
"/usr/sbin/tailscale", // apt package on Debian/Ubuntu
"/usr/bin/tailscale",
"/snap/bin/tailscale", // Snap package
];
// ─── Cache + background refresh (avoid blocking event loop on dead daemon) ──
const PROBE_TTL_MS = 10000;
const PROBE_TIMEOUT_MS = 1500;
const binCache = { value: undefined, fetchedAt: 0, refreshing: false };
const runningCache = { value: false, fetchedAt: 0, refreshing: false };
const loggedInCache = { value: false, fetchedAt: 0, refreshing: false };
const funnelUrlCache = { value: null, port: null, fetchedAt: 0, refreshing: false };
function fallbackBin() {
if (fs.existsSync(TAILSCALE_BIN)) return TAILSCALE_BIN;
if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) return WINDOWS_TAILSCALE_BIN;
if (!IS_WINDOWS) return UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)) || null;
return null;
}
function bgRefreshBin() {
if (binCache.refreshing) return;
binCache.refreshing = true;
const cmd = IS_WINDOWS ? "where tailscale 2>nul" : "which tailscale 2>/dev/null";
execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS, env: { ...process.env, PATH: EXTENDED_PATH } })
.then(({ stdout }) => {
const sys = stdout.trim();
binCache.value = sys || fallbackBin();
})
.catch(() => { binCache.value = fallbackBin(); })
.finally(() => {
binCache.fetchedAt = Date.now();
binCache.refreshing = false;
});
}
// Sync getter: returns cached value, triggers background refresh if stale
export function getTailscaleBin() {
if (Date.now() - binCache.fetchedAt > PROBE_TTL_MS) bgRefreshBin();
// First call: synchronously probe common install paths (no exec, no event-loop block)
if (binCache.value === undefined) {
if (fs.existsSync(TAILSCALE_BIN)) binCache.value = TAILSCALE_BIN;
else if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) binCache.value = WINDOWS_TAILSCALE_BIN;
else if (!IS_WINDOWS) {
const found = UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p));
binCache.value = found || null;
} else binCache.value = null;
}
return binCache.value;
}
export function isTailscaleInstalled() {
return getTailscaleBin() !== null;
}
/** Build tailscale CLI args with custom socket (no root needed) */
function tsArgs(...args) {
return [...SOCKET_FLAG, ...args];
}
// Async strict probe: authoritative, awaitable (never blocks event loop). Updates cache.
export async function isTailscaleLoggedInStrict() {
const bin = getTailscaleBin();
if (!bin) return false;
try {
const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH },
timeout: 5000
});
const json = JSON.parse(stdout);
// BackendState=Running + Self.Online=true → device still exists in tailnet
const loggedIn = json.BackendState === "Running" && json.Self?.Online === true;
loggedInCache.value = loggedIn;
loggedInCache.fetchedAt = Date.now();
return loggedIn;
} catch {
return false;
}
}
function bgRefreshLoggedIn() {
if (loggedInCache.refreshing) return;
const bin = getTailscaleBin();
if (!bin) {
loggedInCache.value = false;
loggedInCache.fetchedAt = Date.now();
return;
}
loggedInCache.refreshing = true;
// Dual-socket aware: probe custom socket first, then system socket
probeStatusAsync(bin)
.then((json) => {
loggedInCache.value = !!json && json.BackendState === "Running" && json.Self?.Online === true;
})
.catch(() => { loggedInCache.value = false; })
.finally(() => {
loggedInCache.fetchedAt = Date.now();
loggedInCache.refreshing = false;
});
}
// Probe `status --json` over custom then system socket. Resolves parsed JSON or null. Never blocks event loop.
async function probeStatusAsync(bin) {
for (const socketArgs of [SOCKET_FLAG, SYSTEM_SOCKET_FLAG]) {
try {
const { stdout } = await execAsync(`"${bin}" ${socketArgs.join(" ")} status --json`, {
windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS,
});
return JSON.parse(stdout);
} catch { /* try next socket */ }
}
return null;
}
// Sync getter: never blocks; returns last known state, refreshes in background
export function isTailscaleLoggedIn() {
if (Date.now() - loggedInCache.fetchedAt > PROBE_TTL_MS) bgRefreshLoggedIn();
return loggedInCache.value;
}
function bgRefreshRunning() {
if (runningCache.refreshing) return;
const bin = getTailscaleBin();
if (!bin) {
runningCache.value = false;
runningCache.fetchedAt = Date.now();
return;
}
runningCache.refreshing = true;
execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS })
.then(({ stdout }) => {
try {
const json = JSON.parse(stdout);
runningCache.value = Object.keys(json.AllowFunnel || {}).length > 0;
} catch { runningCache.value = false; }
})
.catch(() => { runningCache.value = false; })
.finally(() => {
runningCache.fetchedAt = Date.now();
runningCache.refreshing = false;
});
}
// Sync getter: never blocks; returns last known state, refreshes in background
export function isTailscaleRunning() {
if (Date.now() - runningCache.fetchedAt > PROBE_TTL_MS) bgRefreshRunning();
return runningCache.value;
}
// Async strict probe for hot user-initiated paths (enable/connect flow).
// Awaitable, never blocks event loop; updates cache as a side effect.
export async function isTailscaleRunningStrict() {
const bin = getTailscaleBin();
if (!bin) return false;
try {
const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, {
windowsHide: true,
timeout: PROBE_TIMEOUT_MS,
});
const json = JSON.parse(stdout);
const running = Object.keys(json.AllowFunnel || {}).length > 0;
runningCache.value = running;
runningCache.fetchedAt = Date.now();
return running;
} catch {
return false;
}
}
// Check if a system-level tailscaled is running (uses system socket, not 9Router's custom one).
export function isSystemDaemonRunning() {
if (IS_WINDOWS || !SYSTEM_TAILSCALE_SOCKET || !fs.existsSync(SYSTEM_TAILSCALE_SOCKET)) return false;
const bin = getTailscaleBin();
if (!bin) return false;
try {
const out = execSync(`"${bin}" ${SYSTEM_SOCKET_FLAG.join(" ")} status --json`, {
encoding: "utf8", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS,
});
return JSON.parse(out).BackendState === "Running";
} catch {
return false;
}
}
function bgRefreshFunnelUrl(port) {
if (funnelUrlCache.refreshing) return;
const bin = getTailscaleBin();
if (!bin) return;
funnelUrlCache.refreshing = true;
execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS })
.then(({ stdout }) => {
try {
const json = JSON.parse(stdout);
const dnsName = json.Self?.DNSName?.replace(/\.$/, "");
funnelUrlCache.value = dnsName ? `https://${dnsName}` : null;
} catch { /* keep prev */ }
})
.catch(() => { /* keep prev */ })
.finally(() => {
funnelUrlCache.port = port;
funnelUrlCache.fetchedAt = Date.now();
funnelUrlCache.refreshing = false;
});
}
/** Get actual funnel URL from Self.DNSName (sync, authoritative — avoids hostname-conflict suffix). */
function getActualFunnelUrl() {
const bin = getTailscaleBin();
if (!bin) return null;
try {
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
encoding: "utf8",
windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH },
timeout: 5000,
});
const json = JSON.parse(out);
const dnsName = json.Self?.DNSName?.replace(/\.$/, "");
return dnsName ? `https://${dnsName}` : null;
} catch { return null; }
}
/** Get funnel URL from tailscale status (cached, non-blocking) */
export function getTailscaleFunnelUrl(port) {
if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) {
bgRefreshFunnelUrl(port);
}
return funnelUrlCache.value;
}
/**
* 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);
return { success: true };
}
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/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`;
function hasBrew() {
try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; }
}
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"],
windowsHide: true,
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"],
windowsHide: true
});
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"],
windowsHide: true
});
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", windowsHide: true }); } 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) {
// Reject password containing newline → prevents stdin command injection
if (typeof sudoPassword !== "string" || sudoPassword.includes("\n")) {
throw new Error("Invalid sudo password");
}
log("Downloading install script...");
return new Promise((resolve, reject) => {
const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true
});
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...");
// Persist script to temp file → exec by path (NOT via stdin) → sh never reads attacker-controlled stdin
const tmpScript = path.join(os.tmpdir(), `tailscale-install-${crypto.randomBytes(8).toString("hex")}.sh`);
try {
fs.writeFileSync(tmpScript, scriptContent, { mode: 0o700 });
} catch (e) {
return reject(new Error(`Failed to write install script: ${e.message}`));
}
const cleanup = () => { try { fs.unlinkSync(tmpScript); } catch {} };
const child = spawn("sudo", ["-S", "sh", tmpScript], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
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) => {
cleanup();
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", (e) => { cleanup(); reject(e); });
child.stdin.write(`${sudoPassword}\n`);
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");
// Download MSI via curl.exe (built-in on Win10+) — no PowerShell window, streams progress
log("Downloading Tailscale installer...");
await new Promise((resolve, reject) => {
const child = spawn("curl.exe", ["-L", "-#", "-o", msiPath, msiUrl], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true
});
// curl outputs progress to stderr with -# flag
let lastPct = "";
child.stderr.on("data", (d) => {
const text = d.toString();
const match = text.match(/(\d+\.\d)%/);
if (match && match[1] !== lastPct) {
lastPct = match[1];
log(`Downloading... ${lastPct}%`);
}
});
child.on("close", (c) => c === 0 ? resolve() : reject(new Error("Download failed")));
child.on("error", reject);
});
// Install MSI with UAC elevation via PowerShell Start-Process -Verb RunAs
log("Installing Tailscale (UAC prompt may appear)...");
await new Promise((resolve, reject) => {
const args = `'/i','${msiPath}','TS_NOLAUNCH=true','/quiet','/norestart'`;
const child = spawn("powershell", [
"-NoProfile", "-NonInteractive", "-Command",
`Start-Process msiexec -ArgumentList ${args} -Verb RunAs -Wait`
], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); });
child.on("close", (c) => {
try { fs.unlinkSync(msiPath); } catch { /* ignore */ }
c === 0 ? resolve() : reject(new Error(`msiexec failed (code ${c})`));
});
child.on("error", reject);
});
// Verify tailscale.exe exists after install
log("Verifying installation...");
const maxWait = 10000;
const start = Date.now();
while (Date.now() - start < maxWait) {
if (fs.existsSync(WINDOWS_TAILSCALE_BIN)) {
log("Installation complete.");
return;
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error("Installation finished but tailscale.exe not found");
}
// Self-heal: if state dir/files were previously created by root (e.g. legacy sudo daemon),
// reclaim ownership recursively so the user-mode daemon can read/write state files.
async function ensureUserOwnedDir(dir) {
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
return;
}
const uid = process.getuid();
const gid = process.getgid();
// Walk dir + all entries to find any non-user-owned items
const needsChown = (() => {
const stack = [dir];
while (stack.length) {
const cur = stack.pop();
try {
const st = fs.statSync(cur);
if (st.uid !== uid) return true;
if (st.isDirectory()) {
for (const name of fs.readdirSync(cur)) stack.push(path.join(cur, name));
}
} catch { /* ignore */ }
}
return false;
})();
if (!needsChown) return;
// Try direct chown first (works if already owned). Fallback to passwordless sudo.
try {
execSync(`chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 });
} catch {
try { execSync(`sudo -n chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
}
} catch { /* ignore */ }
}
/** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */
function isDaemonTunMode() {
try {
const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim();
if (!ps) return null;
return !ps.includes("--tun=userspace-networking");
} catch { return null; }
}
/** Daemon process alive (independent of funnel state) — mirrors cloudflared PID check semantic. */
export function isDaemonAlive() {
return isDaemonTunMode() !== null;
}
/**
* Start tailscaled.
* - With sudoPassword: TUN mode (root) → Funnel TLS works
* - Without: userspace-networking fallback (no sudo, but Funnel TLS unstable)
* State always lives in ~/.9router/tailscale/ via --statedir.
*/
export async function startDaemonWithPassword(sudoPassword) {
if (IS_WINDOWS) {
// Windows: tailscale runs as a Windows Service. Start it then poll BackendState
// until daemon finishes init (avoids "NoState" errors when calling funnel/up too early).
const bin = getTailscaleBin();
console.log("[Tailscale] win: net start Tailscale");
try { execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 }); }
catch { /* may need admin, or already running */ }
if (!bin) return;
// Poll up to ~10s for backend to leave NoState
for (let i = 0; i < 20; i++) {
try {
const out = execSync(`"${bin}" status --json`, { encoding: "utf8", windowsHide: true, timeout: 2000 });
const j = JSON.parse(out);
if (j.BackendState && j.BackendState !== "NoState") {
console.log(`[Tailscale] win: BackendState=${j.BackendState} after ${i*500}ms`);
return;
}
} catch { /* daemon not ready */ }
await new Promise((r) => setTimeout(r, 500));
}
console.log("[Tailscale] win: BackendState still NoState after poll");
return;
}
const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running
// No password but a healthy TUN daemon already runs → keep TUN, never downgrade-kill it.
const wantTun = sudoPassword ? true : currentMode === true;
// Daemon already running in correct mode → reuse
if (currentMode !== null && currentMode === wantTun) {
try {
const bin = getTailscaleBin() || "tailscale";
execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
stdio: "ignore", windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 3000
});
return;
} catch { /* unresponsive, restart below */ }
}
// Mode mismatch or unresponsive → kill all daemons on our socket
try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
if (sudoPassword) {
try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ }
} else {
try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ }
}
await new Promise((r) => setTimeout(r, 1500));
// Reclaim folder ownership (previous root daemon may have locked it)
await ensureUserOwnedDir(TAILSCALE_DIR);
const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled";
const daemonArgs = [
`--socket=${TAILSCALE_SOCKET}`,
`--statedir=${TAILSCALE_DIR}`,
];
if (!wantTun) daemonArgs.push("--tun=userspace-networking");
if (wantTun) {
// TUN mode: spawn via sudo, password via stdin. Detached so it survives parent exit.
const child = spawn("sudo", ["-S", tailscaledBin, ...daemonArgs], {
detached: true,
stdio: ["pipe", "ignore", "ignore"],
cwd: os.tmpdir(),
env: { ...process.env, PATH: EXTENDED_PATH },
});
child.stdin.write(`${sudoPassword}\n`);
child.stdin.end();
child.unref();
} else {
const child = spawn(tailscaledBin, daemonArgs, {
detached: true,
stdio: "ignore",
cwd: os.tmpdir(),
env: { ...process.env, PATH: EXTENDED_PATH },
});
child.unref();
}
// Wait for socket ready
await new Promise((r) => setTimeout(r, 3000));
}
/** Best-effort: ensure daemon running (used for login flow) */
function ensureDaemon() {
startDaemonWithPassword("").catch(() => {});
}
/** Read AuthURL from `tailscale status --json` (Win exposes it there, not stdout). */
function getAuthUrlFromStatus() {
const bin = getTailscaleBin();
if (!bin) return null;
try {
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
encoding: "utf8", windowsHide: true, timeout: 2000
});
const j = JSON.parse(out);
if (j.AuthURL) return j.AuthURL;
return null;
} catch { return null; }
}
/**
* Run `tailscale up` and capture the auth URL for browser login.
* Resolves with { authUrl } or { alreadyLoggedIn: true }.
* On Windows, AuthURL comes from `status --json` (not stdout) — must poll status.
*/
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;
}
const args = tsArgs("up", "--accept-routes");
if (hostname) args.push(`--hostname=${hostname}`);
const child = spawn(bin, args, {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
windowsHide: true
});
let resolved = false;
let output = "";
const parseAuthUrl = (text) => {
const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9]+/);
return match ? match[0] : null;
};
const finishWithUrl = (url, source) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
clearInterval(statusPoll);
console.log(`[Tailscale] login authUrl detected (${source})`);
child.unref();
resolve({ authUrl: url });
};
// Poll status --json every 500ms — Windows exposes AuthURL only there
const statusPoll = setInterval(() => {
if (resolved) return;
const url = getAuthUrlFromStatus();
if (url) finishWithUrl(url, "status");
}, 500);
const timeout = setTimeout(() => {
if (resolved) return;
resolved = true;
clearInterval(statusPoll);
child.unref();
const url = parseAuthUrl(output) || getAuthUrlFromStatus();
if (url) resolve({ authUrl: url });
else reject(new Error("tailscale up timed out without auth URL"));
}, 15000);
const handleData = (data) => {
output += data.toString();
const url = parseAuthUrl(output);
if (url) finishWithUrl(url, "stdout");
};
child.stdout.on("data", handleData);
child.stderr.on("data", handleData);
child.on("error", (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
clearInterval(statusPoll);
console.error(`[Tailscale] login spawn error: ${err.message}`);
reject(err);
});
child.on("exit", (code) => {
if (resolved) return;
console.log(`[Tailscale] login exit code=${code}`);
// Don't trust exit code alone — Win `tailscale up` exits 0 even when not logged in.
// Let status poll continue until AuthURL appears or timeout.
const url = parseAuthUrl(output) || getAuthUrlFromStatus();
if (url) {
finishWithUrl(url, "exit");
return;
}
// Only resolve alreadyLoggedIn if status confirms BackendState=Running
if (isTailscaleLoggedIn()) {
resolved = true;
clearTimeout(timeout);
clearInterval(statusPoll);
resolve({ alreadyLoggedIn: true });
return;
}
// Otherwise keep polling — daemon may publish AuthURL shortly after exit
});
});
}
/** 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", windowsHide: true }); } catch (e) { /* ignore */ }
return new Promise((resolve, reject) => {
const child = spawn(bin, tsArgs("funnel", "--bg", `${port}`), {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true
});
let resolved = false;
let output = "";
const timeout = setTimeout(() => {
if (resolved) return;
resolved = true;
// --bg exits after setup, read actual hostname from status
const url = getActualFunnelUrl() || getTailscaleFunnelUrl(port);
if (url) resolve({ tunnelUrl: url });
else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`));
}, 30000);
// Always resolve via Self.DNSName to get the real hostname (avoids -1 suffix from conflicts)
const parseFunnelUrl = () => getActualFunnelUrl();
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();
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);
console.log(`[Tailscale] funnel exit code=${code} output="${output.trim().slice(0, 200)}"`);
const url = parseFunnelUrl() || 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);
});
});
}
/** Provision TLS cert for funnel domain (required before Funnel serves HTTPS). Best-effort. */
export async function provisionCert(hostname) {
const bin = getTailscaleBin();
if (!bin || !hostname) return;
const certsDir = path.join(TAILSCALE_DIR, "certs");
fs.mkdirSync(certsDir, { recursive: true });
const certFile = path.join(certsDir, `${hostname}.crt`);
const keyFile = path.join(certsDir, `${hostname}.key`);
try {
await execAsync(
`"${bin}" ${SOCKET_FLAG.join(" ")} cert --cert-file "${certFile}" --key-file "${keyFile}" "${hostname}"`,
{ windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 30000 }
);
console.log(`[Tailscale] cert provisioned for ${hostname}`);
} catch (e) {
console.warn(`[Tailscale] cert provision failed (non-fatal): ${e.message}`);
}
}
/** Stop tailscale funnel */
export function stopFunnel() {
const bin = getTailscaleBin();
if (!bin) return;
try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } 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", windowsHide: true, timeout: 3000 }); } catch { /* ignore */ }
// Check if still alive
try { execSync("pgrep -x tailscaled", { stdio: "ignore", windowsHide: true, 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 */ }
}
+4 -11
View File
@@ -139,13 +139,6 @@ const getPageInfo = (pathname) => {
icon: "extension",
breadcrumbs: [],
};
if (pathname.includes("/endpoint"))
return {
title: "Endpoint",
description: "API endpoint configuration",
icon: "api",
breadcrumbs: [],
};
if (pathname.includes("/profile"))
return {
title: "Settings",
@@ -224,7 +217,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
};
return (
<header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2 border-b border-border-subtle bg-surface/60 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20">
<header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2.5 border-b border-border-subtle bg-bg/88 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20">
{/* Mobile menu button */}
<div className="flex items-center gap-3 lg:hidden shrink-0">
{showMenuButton && (
@@ -265,7 +258,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
src={crumb.image}
alt={crumb.label}
size={28}
className="object-contain rounded max-w-[28px] max-h-[28px]"
className="max-h-7 max-w-7 rounded object-contain"
fallbackText={crumb.label.slice(0, 2).toUpperCase()}
/>
)}
@@ -301,7 +294,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
{/* Right actions */}
<div className="flex items-center gap-1 shrink-0">
{displayName && loginMethod === "OIDC" && (
<div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate">
<div className="hidden max-w-55 items-center truncate rounded-full border border-border bg-surface/70 px-3 py-1.5 text-xs text-text-muted sm:flex">
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
<span className="truncate">{displayName}</span>
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
@@ -327,7 +320,7 @@ function HeaderSearch() {
if (!visible) return null;
return (
<div className="relative w-[160px] sm:w-[220px]">
<div className="relative w-40 sm:w-55">
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-text-muted text-[16px] pointer-events-none">
search
</span>
+27 -27
View File
@@ -15,7 +15,7 @@ const VISIBLE_MEDIA_KINDS = ["embedding", "image", "video", "tts", "stt"];
const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" };
const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" },
{ href: "/dashboard", label: "Endpoint & Key", icon: "api" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns", adminOnly: true },
{ href: "/dashboard/models", label: "Models", icon: "view_list" },
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
@@ -56,8 +56,8 @@ export default function Sidebar({ onClose }) {
}, [fetchCurrentUser, user]);
const isActive = (href) => {
if (href === "/dashboard/endpoint") {
return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint");
if (href === "/dashboard") {
return pathname === "/dashboard";
}
return pathname.startsWith(href);
};
@@ -65,18 +65,18 @@ export default function Sidebar({ onClose }) {
return (
<>
<aside className="flex w-72 flex-col border-r border-border-subtle bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
<aside className="flex w-64 flex-col border-r border-border-subtle bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
{/* Traffic lights */}
<div className="flex items-center gap-2 px-6 pt-5 pb-2">
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
<div className="flex items-center gap-1.5 px-5 pt-4 pb-2">
<div className="size-2.5 rounded-full bg-[#ff6259]" />
<div className="size-2.5 rounded-full bg-[#ffc145]" />
<div className="size-2.5 rounded-full bg-[#2ecb71]" />
</div>
{/* Logo */}
<div className="px-6 py-4 flex flex-col gap-2">
<div className="px-5 py-3.5 flex flex-col gap-2">
<Link href="/dashboard" className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-[10px] bg-gradient-to-br from-brand-500 to-brand-700 shadow-[var(--shadow-warm)]">
<div className="flex size-9 items-center justify-center rounded-[10px] bg-linear-to-br from-brand-400 to-brand-700 shadow-warm ring-1 ring-brand-300/30">
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
</div>
<div className="flex flex-col">
@@ -88,16 +88,16 @@ export default function Sidebar({ onClose }) {
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-0.5 overflow-y-auto custom-scrollbar">
<nav className="flex-1 px-3 py-2 space-y-0.5 overflow-y-auto custom-scrollbar">
{navItems.filter((item) => !item.adminOnly || user?.role === "admin").map((item) => (
<Link
key={item.href}
href={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
isActive(item.href)
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -125,9 +125,9 @@ export default function Sidebar({ onClose }) {
<button
onClick={() => setMediaOpen((v) => !v)}
className={cn(
"w-full flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
pathname.startsWith("/dashboard/media-providers")
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -145,9 +145,9 @@ export default function Sidebar({ onClose }) {
href={`/dashboard/media-providers/${kind.id}`}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-4 py-1 rounded-lg transition-all group",
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all duration-200 group",
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -160,9 +160,9 @@ export default function Sidebar({ onClose }) {
href={COMBINED_WEB_ITEM.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-4 py-1 rounded-lg transition-all group",
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all duration-200 group",
pathname.startsWith(COMBINED_WEB_ITEM.href)
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -180,9 +180,9 @@ export default function Sidebar({ onClose }) {
href={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
isActive(item.href)
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -203,9 +203,9 @@ export default function Sidebar({ onClose }) {
href="/dashboard/users"
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
isActive("/dashboard/users")
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -224,9 +224,9 @@ export default function Sidebar({ onClose }) {
href={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
isActive(item.href)
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -248,9 +248,9 @@ export default function Sidebar({ onClose }) {
href="/dashboard/profile"
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
isActive("/dashboard/profile")
? "bg-primary/10 text-primary"
? "bg-primary/12 text-primary ring-1 ring-primary/15"
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
)}
>
@@ -38,8 +38,8 @@ export default function DashboardLayout({ children }) {
const removeNotification = useNotificationStore((state) => state.removeNotification);
return (
<div className="flex h-screen w-full overflow-hidden bg-bg">
<div className="fixed top-4 right-4 z-[80] flex w-[min(92vw,380px)] flex-col gap-2">
<div className="flex h-dvh w-full overflow-hidden bg-bg">
<div className="fixed top-4 right-4 z-80 flex w-[min(92vw,380px)] flex-col gap-2">
{notifications.map((n) => {
const style = getToastStyle(n.type);
return (
@@ -51,7 +51,7 @@ export default function DashboardLayout({ children }) {
<span className="material-symbols-outlined text-[18px] leading-5">{style.icon}</span>
<div className="min-w-0 flex-1">
{n.title ? <p className="text-xs font-semibold mb-0.5">{n.title}</p> : null}
<p className="text-xs whitespace-pre-wrap break-words">{n.message}</p>
<p className="text-xs whitespace-pre-wrap wrap-break-word">{n.message}</p>
</div>
{n.dismissible ? (
<button
@@ -71,7 +71,7 @@ export default function DashboardLayout({ children }) {
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/20 lg:hidden"
className="fixed inset-0 z-40 bg-slate-950/45 backdrop-blur-[2px] lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
@@ -91,11 +91,11 @@ export default function DashboardLayout({ children }) {
</div>
{/* Main content */}
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300 isolate">
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300 isolate bg-bg">
{/* Faint grid background */}
<div className="landing-grid absolute inset-0 pointer-events-none -z-10" aria-hidden="true" />
<Header key={pathname} onMenuClick={() => setSidebarOpen(true)} />
<div className={`flex-1 overflow-y-auto custom-scrollbar ${pathname === "/dashboard/basic-chat" ? "" : "p-6 lg:p-10"} ${pathname === "/dashboard/basic-chat" ? "flex flex-col overflow-hidden" : ""}`}>
<div className={`flex-1 overflow-y-auto custom-scrollbar ${pathname === "/dashboard/basic-chat" ? "" : "p-5 sm:p-6 lg:p-8 xl:p-10"} ${pathname === "/dashboard/basic-chat" ? "flex flex-col overflow-hidden" : ""}`}>
<div className={`${pathname === "/dashboard/basic-chat" ? "flex-1 w-full h-full flex flex-col" : "max-w-7xl mx-auto"}`}>{children}</div>
</div>
</main>
+1 -1
View File
@@ -145,7 +145,7 @@ export const CLI_TOOLS = {
requiresExternalUrl: true,
notes: [
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
{ type: "cloudCheck", text: "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings." },
{ type: "cloudCheck", text: "Cursor routes requests through its own server, so a local endpoint is not supported. Use a cloud endpoint instead." },
],
guideSteps: [
{ step: 1, title: "Open Settings", desc: "Go to Settings → Models" },
+105 -330
View File
@@ -1,330 +1,105 @@
import os from "os";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { existsSync } from "fs";
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
import {
enableTunnel, enableTailscale,
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
getTunnelService, getTailscaleService, setTunnelUnexpectedExitCallback,
killCloudflared, isCloudflaredRunning, ensureCloudflared,
isTailscaleRunning, isTailscaleRunningStrict, isDaemonAlive, startFunnel,
checkInternet,
RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS,
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
} from "@/lib/tunnel";
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
(function bootstrapMitm() {
if (!process.env.MITM_SERVER_PATH) {
try {
const thisFile = fileURLToPath(import.meta.url);
const appSrc = dirname(dirname(thisFile));
const candidate = join(appSrc, "mitm", "server.js");
if (existsSync(candidate)) process.env.MITM_SERVER_PATH = candidate;
} catch { /* ignore */ }
}
try { initDbHooks(getSettings, updateSettings); } catch { /* ignore */ }
})();
process.setMaxListeners(20);
// Defer heavy startup work so the first HTTP request (login → dashboard) isn't
// starved by DB cleanup, cloudflared download, lsof/DNS probes and OAuth pings.
const STARTUP_DEFER_MS = 3000;
// Survive Next.js hot reload
const g = global.__appSingleton ??= {
signalHandlersRegistered: false,
watchdogInterval: null,
networkMonitorInterval: null,
lastNetworkFingerprint: null,
lastWatchdogTick: Date.now(),
lastOnline: null,
mitmStartInProgress: false,
tunnelAutoResumed: false,
tailscaleAutoResumed: false,
};
export async function initializeApp() {
try {
// Register cleanup + exit-respawn callback immediately so signals and
// unexpected cloudflared exits are handled even during the deferred window.
if (!g.signalHandlersRegistered) {
const cleanup = () => {
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
try { killAllBridges(); } catch { /* best effort */ }
killCloudflared();
process.exit();
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("exit", () => { try { removeAllDNSEntriesSync(); } catch { /* ignore */ } });
g.signalHandlersRegistered = true;
}
setTunnelUnexpectedExitCallback(() => {
safeRestartTunnel("unexpected-exit").catch(() => {});
});
// Defer the heavy work — nothing here blocks incoming requests.
setTimeout(() => {
runHeavyStartup().catch((e) => console.error("[InitApp] deferred startup failed:", e.message));
}, STARTUP_DEFER_MS);
} catch (error) {
console.error("[InitApp] Error:", error);
}
}
async function runHeavyStartup() {
await cleanupProviderConnections();
const settings = await getSettings();
// Auto-resume tunnel (once per process)
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
g.tunnelAutoResumed = true;
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
}
// Auto-resume tailscale (once per process)
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
g.tailscaleAutoResumed = true;
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
}
if (settings.tunnelEnabled) ensureCloudflared().catch(() => {});
if (settings.mitmEnabled) {
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it.
syncMitmAliasCache().catch(() => {});
autoStartMitm(settings);
}
configureTunnelMonitoring(settings);
if (hasQuotaAutoPingEnabled(settings)) {
import("@/shared/services/quotaAutoPing")
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
.catch((e) => console.log("[AutoPing] scheduler start failed:", e.message));
}
}
function hasQuotaAutoPingEnabled(settings) {
return [settings?.claudeAutoPing, settings?.codexAutoPing]
.some((config) => Object.values(config?.connections || {}).some(Boolean));
}
async function autoStartMitm(settings) {
if (g.mitmStartInProgress) return;
g.mitmStartInProgress = true;
try {
if (!settings.mitmEnabled) return;
const mitmStatus = await getMitmStatus();
if (mitmStatus.running) return;
const password = await loadEncryptedPassword();
if (!password && process.platform !== "win32") {
console.log("[InitApp] MITM was enabled but no saved password found, skipping auto-start");
return;
}
const keys = await getApiKeys();
const activeKey = keys.find(k => k.isActive !== false);
console.log("[InitApp] MITM was enabled, auto-starting...");
await startMitm(activeKey?.key || "sk_9router", password);
console.log("[InitApp] MITM auto-started");
try {
await restoreToolDNS(password);
console.log("[InitApp] DNS restored from saved state");
} catch (e) {
console.log("[InitApp] DNS restore failed:", e.message);
}
} catch (err) {
console.log("[InitApp] MITM auto-start failed:", err.message);
} finally {
g.mitmStartInProgress = false;
}
}
// Cooldown only applies to repeating watchdog ticks (anti hammer-loop).
// Network/exit events are one-shot transitions → bypass to recover fast.
const FORCE_RESTART_REASONS = /^(startup|netchange|sleep|sleep\+netchange|online|unexpected-exit)$/;
// ─── Safe restart (4 guards: spawn / cooldown / alive / internet) ────────────
async function safeRestartTunnel(reason) {
const svc = getTunnelService();
const settings = await getSettings();
if (!settings.tunnelEnabled) return;
if (svc.cancelToken.cancelled) return;
if (svc.spawnInProgress) return;
const force = FORCE_RESTART_REASONS.test(reason);
// Process alive = trust cloudflared (self-reconnects via --retries 99, keeps same URL).
// Killing a live process on network change drops the tunnel and rotates the quick-tunnel URL.
if (isCloudflaredRunning()) return;
if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
console.log(`[Tunnel] degraded but cooldown active, skip (${reason})`);
return;
}
if (!await checkInternet()) return;
console.log(`[Tunnel] safeRestart (${reason}) — tunnel unreachable${force ? " [force]" : ""}`);
try {
await enableTunnel();
svc.lastRestartAt = Date.now();
console.log("[Tunnel] restart success");
} catch (err) {
if (!/cloudflared killed|tunnel cancelled/.test(err.message)) {
console.log("[Tunnel] restart failed:", err.message);
}
}
}
async function safeRestartTailscale(reason) {
const svc = getTailscaleService();
const settings = await getSettings();
if (!settings.tailscaleEnabled) return;
if (svc.cancelToken.cancelled) return;
if (svc.spawnInProgress) return;
// Tailscale daemon is OS-level with built-in reconnect; trust it when running (even on netchange).
// Startup uses strict probe — cached state is cold after process/dev reload.
const running = reason === "startup" ? await isTailscaleRunningStrict() : isTailscaleRunning();
if (running) return;
// Daemon alive but funnel dropped → recover funnel only; never full-restart (preserves login/daemon).
if (isDaemonAlive() && svc.activeLocalPort) {
try {
await startFunnel(svc.activeLocalPort);
svc.lastRestartAt = Date.now();
console.log("[Tailscale] funnel re-established (daemon alive)");
} catch (err) {
console.log("[Tailscale] funnel recovery failed:", err.message);
}
return;
}
const force = FORCE_RESTART_REASONS.test(reason);
if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
console.log(`[Tailscale] degraded but cooldown active, skip (${reason})`);
return;
}
if (!await checkInternet()) return;
console.log(`[Tailscale] safeRestart (${reason}) — daemon not running${force ? " [force]" : ""}`);
try {
await enableTailscale();
svc.lastRestartAt = Date.now();
console.log("[Tailscale] restart success");
} catch (err) {
console.log("[Tailscale] restart failed:", err.message);
}
}
// ─── Watchdog: 60s tick check both services ──────────────────────────────────
function startWatchdog() {
if (g.watchdogInterval) return;
g.watchdogInterval = setInterval(() => {
safeRestartTunnel("watchdog").catch(() => {});
safeRestartTailscale("watchdog").catch(() => {});
}, WATCHDOG_INTERVAL_MS);
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
}
function stopWatchdog() {
if (!g.watchdogInterval) return;
clearInterval(g.watchdogInterval);
g.watchdogInterval = null;
}
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
function getNetworkFingerprint() {
const interfaces = os.networkInterfaces();
const active = [];
for (const [name, addrs] of Object.entries(interfaces)) {
if (!addrs) continue;
if (VIRTUAL_IFACE_REGEX.test(name)) continue;
for (const addr of addrs) {
if (!addr.internal && addr.family === "IPv4") {
active.push(`${name}:${addr.address}`);
}
}
}
return active.sort().join("|");
}
function startNetworkMonitor() {
if (g.networkMonitorInterval) return;
g.lastNetworkFingerprint = getNetworkFingerprint();
g.lastWatchdogTick = Date.now();
g.lastOnline = null;
g.networkMonitorInterval = setInterval(async () => {
try {
const now = Date.now();
const elapsed = now - g.lastWatchdogTick;
g.lastWatchdogTick = now;
const currentFingerprint = getNetworkFingerprint();
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 6;
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
// Real reachability check (TCP 1.1.1.1:443) — not just interface presence
const online = await checkInternet();
const wasOffline = g.lastOnline === false;
g.lastOnline = online;
if (!online) return; // no internet → idle, don't restart
const onlineEdge = wasOffline; // offline → online transition
if (!networkChanged && !wasSleep && !onlineEdge) return;
// Wait for DHCP/DNS to settle before probing
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
const reason = onlineEdge ? "online"
: wasSleep && networkChanged ? "sleep+netchange"
: wasSleep ? "sleep" : "netchange";
safeRestartTunnel(reason).catch(() => {});
safeRestartTailscale(reason).catch(() => {});
} catch (err) {
console.log("[NetworkMonitor] error:", err.message);
}
}, NETWORK_CHECK_INTERVAL_MS);
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
}
function stopNetworkMonitor() {
if (!g.networkMonitorInterval) return;
clearInterval(g.networkMonitorInterval);
g.networkMonitorInterval = null;
g.lastNetworkFingerprint = null;
g.lastOnline = null;
}
export function configureTunnelMonitoring(settings) {
if (settings?.tunnelEnabled || settings?.tailscaleEnabled) {
startWatchdog();
startNetworkMonitor();
return;
}
stopWatchdog();
stopNetworkMonitor();
}
export default initializeApp;
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { existsSync } from "fs";
import { cleanupProviderConnections, getApiKeys, getSettings, updateSettings } from "@/lib/localDb";
import { getMitmStatus, initDbHooks, loadEncryptedPassword, removeAllDNSEntriesSync, restoreToolDNS, startMitm } from "@/mitm/manager";
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
(function bootstrapMitm() {
if (!process.env.MITM_SERVER_PATH) {
try {
const thisFile = fileURLToPath(import.meta.url);
const appSrc = dirname(dirname(thisFile));
const candidate = join(appSrc, "mitm", "server.js");
if (existsSync(candidate)) process.env.MITM_SERVER_PATH = candidate;
} catch { /* ignore */ }
}
try { initDbHooks(getSettings, updateSettings); } catch { /* ignore */ }
})();
process.setMaxListeners(20);
const STARTUP_DEFER_MS = 3000;
const g = global.__appSingleton ??= {
signalHandlersRegistered: false,
mitmStartInProgress: false,
};
export async function initializeApp() {
try {
if (!g.signalHandlersRegistered) {
const cleanup = () => {
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
try { killAllBridges(); } catch { /* best effort */ }
process.exit();
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("exit", () => { try { removeAllDNSEntriesSync(); } catch { /* ignore */ } });
g.signalHandlersRegistered = true;
}
setTimeout(() => {
runHeavyStartup().catch((error) => console.error("[InitApp] deferred startup failed:", error.message));
}, STARTUP_DEFER_MS);
} catch (error) {
console.error("[InitApp] Error:", error);
}
}
async function runHeavyStartup() {
await cleanupProviderConnections();
const settings = await getSettings();
if (settings.mitmEnabled) {
syncMitmAliasCache().catch(() => {});
autoStartMitm(settings);
}
if (hasQuotaAutoPingEnabled(settings)) {
import("@/shared/services/quotaAutoPing")
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
.catch((error) => console.log("[AutoPing] scheduler start failed:", error.message));
}
}
function hasQuotaAutoPingEnabled(settings) {
return [settings?.claudeAutoPing, settings?.codexAutoPing]
.some((config) => Object.values(config?.connections || {}).some(Boolean));
}
async function autoStartMitm(settings) {
if (g.mitmStartInProgress) return;
g.mitmStartInProgress = true;
try {
if (!settings.mitmEnabled) return;
const mitmStatus = await getMitmStatus();
if (mitmStatus.running) return;
const password = await loadEncryptedPassword();
if (!password && process.platform !== "win32") {
console.log("[InitApp] MITM was enabled but no saved password found, skipping auto-start");
return;
}
const keys = await getApiKeys();
const activeKey = keys.find((key) => key.isActive !== false);
console.log("[InitApp] MITM was enabled, auto-starting...");
await startMitm(activeKey?.key || "sk_9router", password);
console.log("[InitApp] MITM auto-started");
try {
await restoreToolDNS(password);
console.log("[InitApp] DNS restored from saved state");
} catch (error) {
console.log("[InitApp] DNS restore failed:", error.message);
}
} catch (error) {
console.log("[InitApp] MITM auto-start failed:", error.message);
} finally {
g.mitmStartInProgress = false;
}
}
export default initializeApp;
-6
View File
@@ -24,10 +24,6 @@ export function isLocalCliToolUrl(value) {
export function resolveCliToolBaseUrl({
appUrl = "",
requiresExternalUrl = false,
tunnelEnabled = false,
tunnelPublicUrl = "",
tailscaleEnabled = false,
tailscaleUrl = "",
cloudEnabled = false,
cloudUrl = "",
configuredBaseUrl = "",
@@ -40,8 +36,6 @@ export function resolveCliToolBaseUrl({
// Tools such as Cursor cannot call a loopback URL from their remote service.
if (requiresExternalUrl) {
if (tunnelEnabled && tunnelPublicUrl) return trimTrailingSlashes(tunnelPublicUrl);
if (tailscaleEnabled && tailscaleUrl) return trimTrailingSlashes(tailscaleUrl);
if (cloudEnabled && cloudUrl) return trimTrailingSlashes(cloudUrl);
}
+1 -10
View File
@@ -22,16 +22,7 @@ describe("CLI tool endpoint resolution", () => {
})).toBe("https://router.customer.example");
});
it("uses a public endpoint for externally hosted tools when the dashboard is local", () => {
expect(resolveCliToolBaseUrl({
appUrl: "http://localhost:20128",
requiresExternalUrl: true,
tunnelEnabled: true,
tunnelPublicUrl: "https://tunnel.example/",
cloudEnabled: true,
cloudUrl: "https://cloud.example",
})).toBe("https://tunnel.example");
it("uses the cloud endpoint for externally hosted tools when the dashboard is local", () => {
expect(resolveCliToolBaseUrl({
appUrl: "http://localhost:20128",
requiresExternalUrl: true,
+1 -1
View File
@@ -234,7 +234,7 @@ describe("dashboard guard local-only access", () => {
expect(response.body.error).toBe("Unauthorized");
});
it("rejects local-only route from a tunnel host", async () => {
it("rejects local-only route from a remote host", async () => {
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
host: "router.example.com",
}));
+27
View File
@@ -66,6 +66,33 @@ describe("Schema migrations", () => {
expect(JSON.parse(settings.data)).toEqual({ foo: "bar" });
});
it("removes legacy tunnel settings during upgrade", async () => {
const { getAdapter } = await import("@/lib/db/driver.js");
const db = await getAdapter();
db.run(
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
[JSON.stringify({
keep: "value",
tunnelEnabled: true,
tunnelUrl: "https://tunnel.example",
tunnelProvider: "cloudflare",
tailscaleEnabled: true,
tailscaleUrl: "https://machine.ts.net",
tunnelDashboardAccess: true,
})],
);
db.run(`UPDATE _meta SET value = '8' WHERE key = 'schemaVersion'`);
db.close?.();
delete global._dbAdapter;
vi.resetModules();
const { getAdapter: getAdapter2 } = await import("@/lib/db/driver.js");
const db2 = await getAdapter2();
const settings = db2.get(`SELECT data FROM settings WHERE id=1`);
expect(JSON.parse(settings.data)).toEqual({ keep: "value" });
});
it("fresh DB + legacy db.json → imports data automatically", async () => {
// Simulate user upgrading: place legacy JSON in DATA_DIR before first boot
const legacy = {