diff --git a/cli/cli.js b/cli/cli.js index b9e2f49d..7110a642 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -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"); diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js index 1872b480..420b5eca 100644 --- a/cli/src/cli/api/client.js +++ b/cli/src/cli/api/client.js @@ -430,34 +430,6 @@ async function validateProviderNode(data) { return makeRequest("POST", "/api/provider-nodes/validate", data); } -// ============================================================================ -// TUNNEL API -// ============================================================================ - -/** - * Get tunnel status - * @returns {Promise} { success, data: { enabled, tunnelUrl, shortId, running } } - */ -async function getTunnelStatus() { - return makeRequest("GET", "/api/tunnel/status"); -} - -/** - * Enable tunnel - * @returns {Promise} { success, data: { tunnelUrl, shortId } } - */ -async function enableTunnel() { - return makeRequest("POST", "/api/tunnel/enable"); -} - -/** - * Disable tunnel - * @returns {Promise} { 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, diff --git a/cli/src/cli/menus/settings.js b/cli/src/cli/menus/settings.js index ce779339..b8d89a83 100644 --- a/cli/src/cli/menus/settings.js +++ b/cli/src/cli/menus/settings.js @@ -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} 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 diff --git a/cli/src/cli/terminalUI.js b/cli/src/cli/terminalUI.js index 71e34006..d7c08aff 100644 --- a/cli/src/cli/terminalUI.js +++ b/cli/src/cli/terminalUI.js @@ -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}`; } /** diff --git a/cli/src/cli/utils/endpoint.js b/cli/src/cli/utils/endpoint.js index 20226e69..ca0fe5aa 100644 --- a/cli/src/cli/utils/endpoint.js +++ b/cli/src/cli/utils/endpoint.js @@ -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} 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} + */ +async function getEndpointColored(port) { + const { endpoint } = await getEndpoint(port); + return endpoint; +} + +module.exports = { getEndpoint, getEndpointColored }; \ No newline at end of file diff --git a/public/i18n/literals/fa.json b/public/i18n/literals/fa.json index b298fc57..7da6508a 100644 --- a/public/i18n/literals/fa.json +++ b/public/i18n/literals/fa.json @@ -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": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری", diff --git a/public/i18n/literals/th.json b/public/i18n/literals/th.json index 7d528201..4ea390a3 100644 --- a/public/i18n/literals/th.json +++ b/public/i18n/literals/th.json @@ -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 ทั่วโลก", diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json index 5a53d7a4..762fe16e 100644 --- a/public/i18n/literals/zh-CN.json +++ b/public/i18n/literals/zh-CN.json @@ -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": "全局打开/关闭请求详细信息记录", diff --git a/src/app/(dashboard)/dashboard/DashboardPage.module.css b/src/app/(dashboard)/dashboard/DashboardPage.module.css new file mode 100644 index 00000000..8046e82d --- /dev/null +++ b/src/app/(dashboard)/dashboard/DashboardPage.module.css @@ -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; + } +} diff --git a/src/app/(dashboard)/dashboard/DashboardPageClient.js b/src/app/(dashboard)/dashboard/DashboardPageClient.js new file mode 100644 index 00000000..81e60bf2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/DashboardPageClient.js @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "./endpoint/EndpointPageClient"; diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index 664bab40..cbbe8b54 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -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, diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js index c8c1b019..849dc51d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js @@ -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(() => { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js index b44d7131..0f7323d2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js @@ -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} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index d745d964..79170f09 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -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 (
{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; }; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index e0f36f12..78298b15 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -1,1306 +1,398 @@ -"use client"; - -import { useState, useEffect, useRef, useCallback } from "react"; -import PropTypes from "prop-types"; -import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal } from "@/shared/components"; -import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -import { - TUNNEL_BENEFITS, - TUNNEL_PING_INTERVAL_MS, - TUNNEL_PING_MAX_MS, - STATUS_POLL_FAST_MS, - REACHABLE_MISS_THRESHOLD, - CLIENT_PING_FAST_MS, -} from "./endpointConstants"; -import { clientPingUrl, clientPingAny } from "./endpointPing"; -import EndpointRow from "./components/EndpointRow"; -import StatusAlert from "./components/StatusAlert"; -import Tooltip from "./components/Tooltip"; -import SecurityWarning from "./components/SecurityWarning"; -import { formatVietnamDateTime } from "@/shared/utils/dateTime"; -export default function APIPageClient({ machineId, isAdmin }) { - const [keys, setKeys] = useState([]); - const [loading, setLoading] = useState(true); - const [showAddModal, setShowAddModal] = useState(false); - const [newKeyName, setNewKeyName] = useState(""); - const [createdKey, setCreatedKey] = useState(null); - const [confirmState, setConfirmState] = useState(null); - - const [requireApiKey, setRequireApiKey] = useState(false); - const [hasPassword, setHasPassword] = useState(true); - const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false); - - // Cloudflare Tunnel state - const [tunnelChecking, setTunnelChecking] = useState(true); - const [tunnelEnabled, setTunnelEnabled] = useState(false); - const [tunnelReachable, setTunnelReachable] = useState(false); - const [tunnelUrl, setTunnelUrl] = useState(""); - const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); - const [tunnelLoading, setTunnelLoading] = useState(false); - const [tunnelProgress, setTunnelProgress] = useState(""); - const [tunnelStatus, setTunnelStatus] = useState(null); - const [showEnableTunnelModal, setShowEnableTunnelModal] = useState(false); - const [showDisableTunnelModal, setShowDisableTunnelModal] = useState(false); - - // Tailscale state - const [tsEnabled, setTsEnabled] = useState(false); - const [tsReachable, setTsReachable] = useState(false); - const [tsUrl, setTsUrl] = useState(""); - const [tsLoading, setTsLoading] = useState(false); - const [tsProgress, setTsProgress] = useState(""); - const [tsStatus, setTsStatus] = useState(null); - const [tsAuthUrl, setTsAuthUrl] = useState(""); - const [tsAuthLabel, setTsAuthLabel] = useState(""); - const [tsInstalled, setTsInstalled] = useState(null); // null=checking, true/false - const [tsInstalling, setTsInstalling] = useState(false); - const [tsInstallLog, setTsInstallLog] = useState([]); - const [tsSudoPassword, setTsSudoPassword] = useState(""); - const [tsConnecting, setTsConnecting] = useState(false); - const [showTsModal, setShowTsModal] = useState(false); - const [showDisableTsModal, setShowDisableTsModal] = useState(false); - const tsLogRef = useRef(null); - - // Debounce reachable=false: server may briefly return false during background refresh. - // Only flip UI to "reconnecting" after N consecutive misses to avoid spinner flicker. - const tunnelMissRef = useRef(0); - const tsMissRef = useRef(0); - // Browser-side reachable cache (independent of backend DNS quirks) - const tunnelClientReachableRef = useRef(false); - const tsClientReachableRef = useRef(false); - // Track whether reachable=true was ever observed in this session. - // Distinguishes "Checking..." (initial cold cache) from "Reconnecting..." (lost connection). - const tunnelEverReachableRef = useRef(false); - const tsEverReachableRef = useRef(false); - const [tunnelEverReachable, setTunnelEverReachable] = useState(false); - const [tsEverReachable, setTsEverReachable] = useState(false); - - // API key visibility toggle state - const [visibleKeys, setVisibleKeys] = useState(new Set()); - - // Client-side local/remote detection (UI hint only, not a security gate) - const [isRemoteHost, setIsRemoteHost] = useState(false); - useEffect(() => { - if (typeof window !== "undefined") - setIsRemoteHost(!["localhost", "127.0.0.1", "::1"].includes(window.location.hostname)); - }, []); - - const { copied, copy } = useCopyToClipboard(); - - // Security gate: block remote exposure while dashboard uses the default password. - const isLoginUnsafe = !hasPassword; - const unsafeReason = "Change the default dashboard password before activating the tunnel."; - - // Auto-scroll install log - useEffect(() => { - if (tsLogRef.current) tsLogRef.current.scrollTop = tsLogRef.current.scrollHeight; - }, [tsInstallLog]); - - useEffect(() => { - fetchData(); - if (isAdmin) loadSettings(); - }, []); - - // Status poll: only while degraded (not yet reachable). Stop once healthy to avoid spam. - // Visibility re-check: refresh once when tab becomes visible. - useEffect(() => { - const anyEnabled = tunnelEnabled || tsEnabled; - if (!anyEnabled) return; - const tunnelHealthy = !tunnelEnabled || tunnelReachable; - const tsHealthy = !tsEnabled || tsReachable; - const allHealthy = tunnelHealthy && tsHealthy; - const onVisible = () => { if (!document.hidden) syncTunnelStatus(); }; - document.addEventListener("visibilitychange", onVisible); - if (allHealthy) return () => document.removeEventListener("visibilitychange", onVisible); - const timer = setInterval(() => { if (!document.hidden) syncTunnelStatus(); }, STATUS_POLL_FAST_MS); - return () => { - clearInterval(timer); - document.removeEventListener("visibilitychange", onVisible); - }; - }, [tunnelEnabled, tsEnabled, tunnelReachable, tsReachable]); - - // Browser-side periodic ping: probes tunnel/tailscale URLs directly so UI stays - // "reachable" even when backend DNS (1.1.1.1) hiccups on *.ts.net or *.trycloudflare.com. - // Adaptive: slow when healthy, fast when degraded; pause when tab hidden. - useEffect(() => { - const probeBoth = async () => { - if (document.hidden) return; - if (tunnelEnabled && (tunnelUrl || tunnelPublicUrl)) { - const ok = await clientPingAny(tunnelPublicUrl, tunnelUrl); - tunnelClientReachableRef.current = ok; - if (ok) { tunnelMissRef.current = 0; setTunnelReachable(true); if (!tunnelEverReachableRef.current) { tunnelEverReachableRef.current = true; setTunnelEverReachable(true); } } - else { tunnelMissRef.current += 1; if (tunnelMissRef.current >= REACHABLE_MISS_THRESHOLD) setTunnelReachable(false); } - } else { - tunnelClientReachableRef.current = false; - } - if (tsEnabled && tsUrl) { - const ok = await clientPingUrl(tsUrl); - tsClientReachableRef.current = ok; - if (ok) { tsMissRef.current = 0; setTsReachable(true); if (!tsEverReachableRef.current) { tsEverReachableRef.current = true; setTsEverReachable(true); } } - else { tsMissRef.current += 1; if (tsMissRef.current >= REACHABLE_MISS_THRESHOLD) setTsReachable(false); } - } else { - tsClientReachableRef.current = false; - } - }; - const anyEnabled = (tunnelEnabled && (tunnelUrl || tunnelPublicUrl)) || (tsEnabled && tsUrl); - if (!anyEnabled) return; - probeBoth(); - const tunnelHealthy = !tunnelEnabled || tunnelReachable; - const tsHealthy = !tsEnabled || tsReachable; - if (tunnelHealthy && tsHealthy) return; - const id = setInterval(probeBoth, CLIENT_PING_FAST_MS); - return () => clearInterval(id); - }, [tunnelEnabled, tunnelUrl, tunnelPublicUrl, tsEnabled, tsUrl, tunnelReachable, tsReachable]); - - // Client-side reachable only (server no longer probes; watchdog handles backend health). - // Miss-debounce: only flip to false after N consecutive misses. - const updateReachable = useCallback((_unused, clientRef, missRef, setter, everRef, everSetter) => { - const reachable = clientRef.current; - if (reachable) { - missRef.current = 0; - setter(true); - if (!everRef.current) { - everRef.current = true; - everSetter(true); - } - } else { - missRef.current += 1; - if (missRef.current >= REACHABLE_MISS_THRESHOLD) setter(false); - } - }, []); - - // Trust user intent (settingsEnabled): UI stays "enabled" while watchdog restarts process - const syncTunnelStatus = async () => { - try { - const statusRes = await fetch("/api/tunnel/status", { cache: "no-store" }); - if (!statusRes.ok) return; - const data = await statusRes.json(); - const tEnabled = data.tunnel?.settingsEnabled ?? data.tunnel?.enabled ?? false; - const tUrl = data.tunnel?.tunnelUrl || ""; - setTunnelUrl(tUrl); - setTunnelPublicUrl(data.tunnel?.publicUrl || ""); - setTunnelEnabled(tEnabled); - updateReachable(null, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); - - const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; - const tsUrlVal = data.tailscale?.tunnelUrl || ""; - setTsUrl(tsUrlVal); - setTsEnabled(tsEn); - updateReachable(null, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); - } catch { /* ignore poll errors */ } - }; - - const loadSettings = async () => { - setTunnelChecking(true); - try { - const [settingsRes, statusRes] = await Promise.all([ - fetch("/api/settings"), - fetch("/api/tunnel/status", { cache: "no-store" }) - ]); - if (settingsRes.ok) { - const data = await settingsRes.json(); - setRequireApiKey(data.requireApiKey || false); - setHasPassword(data.hasPassword || false); - setTunnelDashboardAccess(data.tunnelDashboardAccess || false); - } - if (statusRes.ok) { - const data = await statusRes.json(); - const tEnabled = data.tunnel?.settingsEnabled ?? data.tunnel?.enabled ?? false; - const tUrl = data.tunnel?.tunnelUrl || ""; - setTunnelUrl(tUrl); - setTunnelPublicUrl(data.tunnel?.publicUrl || ""); - setTunnelEnabled(tEnabled); - updateReachable(null, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); - - const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; - const tsUrlVal = data.tailscale?.tunnelUrl || ""; - setTsUrl(tsUrlVal); - setTsEnabled(tsEn); - updateReachable(null, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); - } - } catch (error) { - console.log("Error loading settings:", error); - } finally { - setTunnelChecking(false); - } - }; - - const handleTunnelDashboardAccess = async (value) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tunnelDashboardAccess: value }), - }); - if (res.ok) setTunnelDashboardAccess(value); - } catch (error) { - console.log("Error updating tunnelDashboardAccess:", error); - } - }; - - const handleRequireApiKey = async (value) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requireApiKey: value }), - }); - if (res.ok) setRequireApiKey(value); - } catch (error) { - console.log("Error updating requireApiKey:", error); - } - }; - - const fetchData = async () => { - try { - const keysRes = await fetch("/api/keys"); - const keysData = await keysRes.json(); - if (keysRes.ok) { - setKeys(keysData.keys || []); - } - } catch (error) { - console.log("Error fetching data:", error); - } finally { - setLoading(false); - } - }; - - // u2500u2500u2500 Cloudflare Tunnel handlers - // Ping tunnel health until reachable. Race multiple URLs (shortlink + direct) — 1 OK is enough. - const pingTunnelHealth = async (...urls) => { - setTunnelLoading(true); - setTunnelProgress("Waiting for tunnel ready..."); - const targets = urls.filter(Boolean).map((u) => `${u}/api/health`); - const start = Date.now(); - while (Date.now() - start < TUNNEL_PING_MAX_MS) { - await new Promise((r) => setTimeout(r, TUNNEL_PING_INTERVAL_MS)); - const ok = await Promise.any(targets.map(async (h) => { - const p = await fetch(h, { mode: "cors", cache: "no-store" }); - if (p.ok) return true; - throw new Error("not ready"); - })).catch(() => false); - if (ok) { - setTunnelEnabled(true); - setTunnelLoading(false); - setTunnelProgress(""); - return true; - } - // Every 5 pings (~10s), check if backend process still alive - if ((Date.now() - start) % 10000 < TUNNEL_PING_INTERVAL_MS) { - try { - const statusRes = await fetch("/api/tunnel/status"); - if (statusRes.ok) { - const status = await statusRes.json(); - if (!status.tunnel?.enabled) { - setTunnelStatus({ type: "error", message: "Tunnel process stopped unexpectedly." }); - setTunnelLoading(false); - setTunnelProgress(""); - return false; - } - } - } catch { /* ignore */ } - } - } - setTunnelStatus({ type: "error", message: "Tunnel created but not reachable. Please try again." }); - setTunnelLoading(false); - setTunnelProgress(""); - return false; - }; - - const handleEnableTunnel = async () => { - setShowEnableTunnelModal(false); - setTunnelLoading(true); - setTunnelStatus(null); - setTunnelProgress("Creating tunnel..."); - - // Poll download progress while enable request is pending - let polling = true; - const pollProgress = async () => { - while (polling) { - try { - const r = await fetch("/api/tunnel/status"); - if (r.ok) { - const s = await r.json(); - if (s.download?.downloading) { - setTunnelProgress(`Downloading cloudflared... ${s.download.progress}%`); - } else if (polling) { - setTunnelProgress("Creating tunnel..."); - } - } - } catch { /* ignore */ } - await new Promise((r) => setTimeout(r, 1000)); - } - }; - pollProgress(); - - try { - const res = await fetch("/api/tunnel/enable", { method: "POST" }); - polling = false; - const data = await res.json(); - if (!res.ok) { - setTunnelStatus({ type: "error", message: data.error || "Failed to enable tunnel" }); - return; - } - - const url = data.tunnelUrl; - if (!url) { - setTunnelStatus({ type: "error", message: "No tunnel URL returned" }); - return; - } - - setTunnelUrl(url); - setTunnelPublicUrl(data.publicUrl || ""); - await pingTunnelHealth(data.publicUrl, url); - } catch (error) { - setTunnelStatus({ type: "error", message: error.message }); - } finally { - polling = false; - setTunnelLoading(false); - setTunnelProgress(""); - } - }; - - const handleDisableTunnel = async () => { - setTunnelLoading(true); - setTunnelStatus(null); - try { - const res = await fetch("/api/tunnel/disable", { method: "POST" }); - const data = await res.json(); - if (res.ok) { - setTunnelEnabled(false); - setTunnelUrl(""); - setShowDisableTunnelModal(false); - setTunnelStatus({ type: "success", message: "Tunnel disabled" }); - } else { - setTunnelStatus({ type: "error", message: data.error || "Failed to disable tunnel" }); - } - } catch (error) { - setTunnelStatus({ type: "error", message: error.message }); - } finally { - setTunnelLoading(false); - } - }; - - // u2500u2500u2500 Tailscale handlers - const checkTailscaleInstalled = async () => { - setTsInstalled(null); - try { - const res = await fetch("/api/tunnel/tailscale-check"); - if (res.ok) { - const data = await res.json(); - setTsInstalled(data.installed); - return data; - } - } catch { /* ignore */ } - setTsInstalled(false); - return { installed: false }; - }; - - const handleInstallTailscale = async () => { - setTsInstalling(true); - setTsStatus(null); - setTsInstallLog([]); - try { - const res = await fetch("/api/tunnel/tailscale-install", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sudoPassword: tsSudoPassword }), - }); - setTsSudoPassword(""); - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split("\n\n"); - buffer = parts.pop() || ""; - for (const part of parts) { - const lines = part.split("\n"); - let event = "progress"; - let data = null; - for (const line of lines) { - if (line.startsWith("event: ")) event = line.slice(7).trim(); - if (line.startsWith("data: ")) { - try { data = JSON.parse(line.slice(6)); } catch { /* skip */ } - } - } - if (!data) continue; - if (event === "progress") { - setTsInstallLog((prev) => [...prev.slice(-50), data.message]); - } else if (event === "done") { - setTsInstalled(true); - setTsInstalling(false); - setShowTsModal(false); - handleConnectTailscale(); - return; - } else if (event === "error") { - setTsStatus({ type: "error", message: data.error || "Install failed" }); - } - } - } - } catch (e) { - setTsStatus({ type: "error", message: e.message }); - } finally { - setTsInstalling(false); - } - }; - - // Ping Tailscale health until reachable - const pingTsHealth = async (url) => { - setTsProgress("Waiting for Tailscale ready..."); - const healthUrl = `${url}/api/health`; - const start = Date.now(); - while (Date.now() - start < TUNNEL_PING_MAX_MS) { - await new Promise((r) => setTimeout(r, TUNNEL_PING_INTERVAL_MS)); - try { - const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); - if (ping.ok || ping.type === "opaque") return true; - } catch { /* not ready yet */ } - } - return false; - }; - - // Show inline login button instead of auto-opening popup (browsers block popups - // opened after async work because the user gesture is lost). - const requestUserAuth = (url, label) => { - setTsAuthUrl(url); - setTsAuthLabel(label); - }; - - const clearUserAuth = () => { - setTsAuthUrl(""); - setTsAuthLabel(""); - }; - - const handleConnectTailscale = async () => { - setShowTsModal(false); - setTsConnecting(true); - setTsLoading(true); - setTsStatus(null); - setTsProgress("Connecting..."); - clearUserAuth(); - try { - const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); - const data = await res.json(); - - if (res.ok && data.success) { - setTsUrl(data.tunnelUrl || ""); - const reachable = await pingTsHealth(data.tunnelUrl); - setTsEnabled(true); - setTsStatus(reachable ? null : { type: "warning", message: "Connected but not reachable yet." }); - return; - } - - if (data.needsLogin && data.authUrl) { - requestUserAuth(data.authUrl, "Open Login Page"); - setTsProgress("Login required — click \"Open Login Page\" to continue"); - for (let i = 0; i < 40; i++) { - await new Promise((r) => setTimeout(r, 3000)); - try { - const r2 = await fetch("/api/tunnel/tailscale-check"); - if (r2.ok) { - const check = await r2.json(); - if (check.loggedIn) { - clearUserAuth(); - setTsProgress("Starting funnel..."); - const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); - const data2 = await res2.json(); - if (res2.ok && data2.success) { - setTsUrl(data2.tunnelUrl || ""); - const ok2 = await pingTsHealth(data2.tunnelUrl); - setTsEnabled(true); - setTsStatus(ok2 ? null : { type: "warning", message: "Connected but not reachable yet." }); - } else if (data2.funnelNotEnabled && data2.enableUrl) { - await pollFunnelEnable(data2.enableUrl); - } else { - setTsStatus({ type: "error", message: data2.error || "Failed to start funnel" }); - } - return; - } - } - } catch { /* retry */ } - } - clearUserAuth(); - setTsStatus({ type: "error", message: "Login timed out. Please try again." }); - return; - } - - if (data.funnelNotEnabled && data.enableUrl) { - await pollFunnelEnable(data.enableUrl); - return; - } - - setTsStatus({ type: "error", message: data.error || "Failed to connect" }); - } catch (error) { - setTsStatus({ type: "error", message: error.message }); - } finally { - setTsLoading(false); - setTsConnecting(false); - setTsProgress(""); - clearUserAuth(); - } - }; - - const pollFunnelEnable = async (enableUrl) => { - requestUserAuth(enableUrl, "Open Funnel Settings"); - setTsProgress("Click \"Open Funnel Settings\" to enable Funnel..."); - for (let i = 0; i < 40; i++) { - await new Promise((r) => setTimeout(r, 3000)); - try { - const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); - const data = await res.json(); - if (res.ok && data.success) { - clearUserAuth(); - setTsUrl(data.tunnelUrl || ""); - const ok3 = await pingTsHealth(data.tunnelUrl); - setTsEnabled(true); - setTsStatus(ok3 ? null : { type: "warning", message: "Connected but not reachable yet." }); - return; - } - if (data.funnelNotEnabled) continue; - if (data.error) { - clearUserAuth(); - setTsStatus({ type: "error", message: data.error }); - return; - } - } catch { /* retry */ } - } - clearUserAuth(); - setTsStatus({ type: "error", message: "Timed out waiting for Funnel to be enabled." }); - }; - - const handleDisableTailscale = async () => { - setTsLoading(true); - setTsStatus(null); - try { - const res = await fetch("/api/tunnel/tailscale-disable", { method: "POST" }); - const data = await res.json(); - if (res.ok) { - setTsEnabled(false); - setTsUrl(""); - setShowDisableTsModal(false); - setTsStatus({ type: "success", message: "Tailscale disabled" }); - } else { - setTsStatus({ type: "error", message: data.error || "Failed to disable Tailscale" }); - } - } catch (e) { - setTsStatus({ type: "error", message: e.message }); - } finally { - setTsLoading(false); - } - }; - - const handleOpenTsModal = async () => { - setTsStatus(null); - setTsInstallLog([]); - const data = await checkTailscaleInstalled(); - if (data?.installed && data?.hasCachedPassword) { - handleConnectTailscale(); - } else { - setShowTsModal(true); - } - }; - - const handleCreateKey = async () => { - if (!newKeyName.trim()) return; - - try { - const res = await fetch("/api/keys", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newKeyName }), - }); - const data = await res.json(); - - if (res.ok) { - setCreatedKey(data.key); - await fetchData(); - setNewKeyName(""); - setShowAddModal(false); - } - } catch (error) { - console.log("Error creating key:", error); - } - }; - - const handleDeleteKey = async (id) => { - setConfirmState({ - title: "Delete API Key", - message: "Delete this API key?", - onConfirm: async () => { - setConfirmState(null); - try { - const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); - if (res.ok) { - setKeys(keys.filter((k) => k.id !== id)); - setVisibleKeys(prev => { - const next = new Set(prev); - next.delete(id); - return next; - }); - } - } catch (error) { - console.log("Error deleting key:", error); - } - } - }); - }; - - const handleToggleKey = async (id, isActive) => { - try { - const res = await fetch(`/api/keys/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isActive }), - }); - if (res.ok) { - setKeys(prev => prev.map(k => k.id === id ? { ...k, isActive } : k)); - } - } catch (error) { - console.log("Error toggling key:", error); - } - }; - - const maskKey = (fullKey) => { - if (!fullKey || fullKey.length <= 10) return fullKey || ""; - return fullKey.slice(0, 6) + "•".repeat(fullKey.length - 10) + fullKey.slice(-4); - }; - - const toggleKeyVisibility = (keyId) => { - setVisibleKeys(prev => { - const next = new Set(prev); - if (next.has(keyId)) next.delete(keyId); - else next.add(keyId); - return next; - }); - }; - - const [baseUrl, setBaseUrl] = useState("/v1"); - - // Hydration fix: Only access window on client side - useEffect(() => { - if (typeof window !== "undefined") { - setBaseUrl(`${window.location.origin}/v1`); - } - }, []); - - if (loading) { - return ( -
- - -
- ); - } - - const currentEndpoint = baseUrl; - - return ( -
- {/* Endpoint Card */} - -
-
- api -
-
-

API Endpoint

-

Use this address to connect compatible clients.

-
-
- - {/* Endpoint rows */} -
- {/* Local */} - - {/* Cloudflare Tunnel and Tailscale are administrator-managed endpoints. */} - {isAdmin && <> - {/* Cloudflare Tunnel */} -
- Tunnel - {tunnelEnabled && !tunnelLoading && tunnelReachable ? ( - <> - - - - - ) : tunnelEnabled && !tunnelLoading && !tunnelReachable ? ( - <> -
- progress_activity - {tunnelEverReachable ? "Tunnel reconnecting..." : "Tunnel checking..."} -
- - - ) : tunnelLoading ? ( - <> -
- progress_activity - {tunnelProgress || "Creating tunnel..."} -
- - - ) : tunnelStatus?.type === "error" ? ( - <> -
- error - {tunnelStatus.message} -
- - - ) : tunnelChecking ? ( - <> -
- progress_activity - Checking... -
- - - ) : ( - - )} -
- {/* Tailscale */} -
- Tailscale - {tsEnabled && !tsLoading && tsReachable ? ( - <> - - - - - ) : tsEnabled && !tsLoading && !tsReachable ? ( - <> -
- progress_activity - {tsEverReachable ? "Tailscale reconnecting..." : "Tailscale checking..."} -
- - - ) : (tsLoading || tsConnecting) ? ( - <> -
- progress_activity - {tsProgress || "Connecting..."} -
- {tsAuthUrl && ( - - )} - - - ) : tsStatus?.type === "error" ? ( - <> -
- error - {tsStatus.message} -
- - - ) : ( - - )} -
- } -
- - {/* Pre-enable security gate banner */} - {isAdmin && isLoginUnsafe && !tunnelEnabled && !tsEnabled && ( -
- -
- )} - - {/* Security warnings when tunnel or tailscale is active */} - {isAdmin && (tunnelEnabled || tsEnabled) && ( -
- {!requireApiKey && ( - - )} - {!hasPassword && ( - - )} -
- )} - - {/* Tunnel dashboard access option */} - {isAdmin && (tunnelEnabled || tsEnabled) && ( -
- handleTunnelDashboardAccess(!tunnelDashboardAccess)} - /> -
-

Allow dashboard access via tunnel

- -
-
- )} -
- - {/* API Keys */} - -
-
-
- vpn_key -
-
-

API Keys

-

Manage keys created from this account.

-
-
- -
- - {isAdmin &&
-
-

Require API key

-

- Requests without a valid key will be rejected -

-
- handleRequireApiKey(!requireApiKey)} - /> -
} - - {isAdmin && isRemoteHost && !requireApiKey && ( -
- -
- )} - - {keys.length === 0 ? ( -
-
- vpn_key -
-

No API keys yet

-

Create your first API key to get started

- -
- ) : ( -
- {keys.map((key) => ( -
-
-
-

{key.name}

- - {key.isActive === false ? "Paused" : "Active"} - -
-
- - {visibleKeys.has(key.id) ? key.key : maskKey(key.key)} - - - -
-
-

- Created
{formatVietnamDateTime(key.createdAt, { dateStyle: "medium" }) || "—"} -

-
- { - if (key.isActive && !checked) { - setConfirmState({ - title: "Pause API Key", - message: `Pause API key "${key.name}"?\n\nThis key will stop working immediately but can be resumed later.`, - onConfirm: async () => { - setConfirmState(null); - handleToggleKey(key.id, checked); - } - }); - } else { - handleToggleKey(key.id, checked); - } - }} - title={key.isActive ? "Pause key" : "Resume key"} - /> - -
-
- ))} -
- )} -
- - {/* Add Key Modal */} - { - setShowAddModal(false); - setNewKeyName(""); - }} - > -
- setNewKeyName(e.target.value)} - placeholder="Production Key" - /> -
- - -
-
-
- - {/* Created Key Modal */} - setCreatedKey(null)} - > -
-
-

- Save this key now! -

-

- This is the only time you will see this key. Store it securely. -

-
-
- - -
- -
-
- - {/* Enable Tunnel Modal */} - {isAdmin && setShowEnableTunnelModal(false)} - > -
-
-
- cloud_upload -
-

- Cloudflare Tunnel -

-

- Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere. -

-
-
-
- -
- {TUNNEL_BENEFITS.map((benefit) => ( -
- {benefit.icon} -

{benefit.title}

-

{benefit.desc}

-
- ))} -
- -

- Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s. -

- -
- - -
-
-
} - - {/* Disable Cloudflare Tunnel Modal */} - {isAdmin && !tunnelLoading && setShowDisableTunnelModal(false)} - > -
-

The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.

-
- - -
-
-
} - - {/* Tailscale Modal */} - {isAdmin && { if (!tsInstalling) { setShowTsModal(false); setTsSudoPassword(""); setTsStatus(null); } }} - > -
- {/* Checking state */} - {tsInstalled === null && ( -

- progress_activity - Checking... -

- )} - - {/* Not installed */} - {tsInstalled === false && !tsInstalling && ( -
-

Tailscale is not installed. Install it to enable Funnel.

-
- - -
-
- )} - - {/* Installing with progress log */} - {tsInstalling && ( -
-
- progress_activity - Installing Tailscale... -
- {tsInstallLog.length > 0 && ( -
- {tsInstallLog.map((line, i) => ( -
{line}
- ))} -
- )} -
- )} - - {/* Installed: show Connect button */} - {tsInstalled === true && !tsInstalling && ( -
-
- check_circle - Tailscale installed -
-
- - -
-
- )} - - {tsStatus && } -
-
} - - {/* Disable Tailscale Modal */} - {isAdmin && !tsLoading && setShowDisableTsModal(false)} - > -
-

Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.

-
- - -
-
-
} - - {/* Confirm Modal */} - setConfirmState(null)} - onConfirm={confirmState?.onConfirm} - title={confirmState?.title || "Confirm"} - message={confirmState?.message} - variant="danger" - /> -
- ); -} - - -APIPageClient.propTypes = { - machineId: PropTypes.string.isRequired, - isAdmin: PropTypes.bool.isRequired, -}; +"use client"; + +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import PropTypes from "prop-types"; +import { Button, Card, ConfirmModal, Input, Modal, Toggle } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { formatVietnamDateTime } from "@/shared/utils/dateTime"; +import EndpointRow from "./components/EndpointRow"; +import SecurityWarning from "./components/SecurityWarning"; +import styles from "../DashboardPage.module.css"; + +const subscribeToBrowserLocation = () => () => {}; +const getBaseUrl = () => `${window.location.origin}/v1`; +const getRemoteHost = () => !["localhost", "127.0.0.1", "::1"].includes(window.location.hostname); + +export default function APIPageClient({ isAdmin }) { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [settingsError, setSettingsError] = useState(""); + const [showAddModal, setShowAddModal] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [createdKey, setCreatedKey] = useState(null); + const [confirmState, setConfirmState] = useState(null); + const [requireApiKey, setRequireApiKey] = useState(false); + const [visibleKeys, setVisibleKeys] = useState(new Set()); + + const baseUrl = useSyncExternalStore(subscribeToBrowserLocation, getBaseUrl, () => "/v1"); + const isRemoteHost = useSyncExternalStore(subscribeToBrowserLocation, getRemoteHost, () => false); + const { copied, copy } = useCopyToClipboard(); + + const loadSettings = useCallback(async () => { + setSettingsError(""); + try { + const response = await fetch("/api/settings"); + if (!response.ok) throw new Error("Failed to load access settings"); + const data = await response.json(); + setRequireApiKey(data.requireApiKey || false); + } catch (error) { + console.log("Error loading settings:", error); + setSettingsError("We couldn't load access settings. Refresh the page to try again."); + } + }, []); + + const fetchKeys = useCallback(async () => { + setLoadError(""); + try { + const response = await fetch("/api/keys"); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || "Failed to fetch API keys"); + setKeys(data.keys || []); + } catch (error) { + console.log("Error fetching API keys:", error); + setLoadError("We couldn't load your API keys. Check your connection and try again."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const initialize = window.setTimeout(() => { + void fetchKeys(); + if (isAdmin) void loadSettings(); + }, 0); + return () => window.clearTimeout(initialize); + }, [fetchKeys, isAdmin, loadSettings]); + + const handleRequireApiKey = async (value) => { + try { + const response = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireApiKey: value }), + }); + if (response.ok) setRequireApiKey(value); + } catch (error) { + console.log("Error updating requireApiKey:", error); + } + }; + + const handleCreateKey = async () => { + if (!newKeyName.trim()) return; + + try { + const response = await fetch("/api/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newKeyName.trim() }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Failed to create API key"); + + setCreatedKey(data.key); + setNewKeyName(""); + setShowAddModal(false); + await fetchKeys(); + } catch (error) { + console.log("Error creating API key:", error); + setLoadError(error.message || "We couldn't create the API key. Try again."); + } + }; + + const handleDeleteKey = (id) => { + setConfirmState({ + title: "Delete API Key", + message: "Delete this API key?", + onConfirm: async () => { + setConfirmState(null); + try { + const response = await fetch(`/api/keys/${id}`, { method: "DELETE" }); + if (!response.ok) throw new Error("Failed to delete API key"); + setKeys((current) => current.filter((key) => key.id !== id)); + setVisibleKeys((current) => { + const next = new Set(current); + next.delete(id); + return next; + }); + } catch (error) { + console.log("Error deleting API key:", error); + setLoadError("We couldn't delete the API key. Try again."); + } + }, + }); + }; + + const handleToggleKey = async (id, isActive) => { + try { + const response = await fetch(`/api/keys/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (!response.ok) throw new Error("Failed to update API key"); + setKeys((current) => current.map((key) => (key.id === id ? { ...key, isActive } : key))); + } catch (error) { + console.log("Error toggling API key:", error); + setLoadError("We couldn't update the API key. Try again."); + } + }; + + const toggleKeyVisibility = (keyId) => { + setVisibleKeys((current) => { + const next = new Set(current); + if (next.has(keyId)) next.delete(keyId); + else next.add(keyId); + return next; + }); + }; + + const maskKey = (value) => { + if (!value || value.length <= 10) return value || ""; + return `${value.slice(0, 6)}${"•".repeat(value.length - 10)}${value.slice(-4)}`; + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + const postureCopy = !isAdmin + ? "Use the local gateway and manage the API keys assigned to this account." + : requireApiKey + ? "Local access is ready and API key enforcement is enabled." + : "Local access is ready. Enable API key enforcement to protect requests."; + + return ( +
+
+
+

Gateway control plane

+

Control how your clients reach 9Router.

+

Copy the local gateway and create credentials for the tools connected to this machine.

+
+ +
+

{copied ? "Value copied to clipboard." : ""}

+ + {loadError && ( +
+ {loadError} + +
+ )} + + +
+
+ api +
+
+

Local access

+

Use this OpenAI-compatible gateway from applications on this machine.

+
+
+ +
+ + +
+
+
+ vpn_key +
+
+

Credential registry

+

Keys assigned to this dashboard account.

+
+
+ +
+ + {isAdmin && ( +
+
+

Require API key

+

Reject requests that do not include an active key.

+
+ handleRequireApiKey(!requireApiKey)} /> +
+ )} + + {isAdmin && isRemoteHost && !requireApiKey && ( +
+ +
+ )} + + {settingsError && ( +
+ {settingsError} + +
+ )} + + {keys.length === 0 ? ( +
+
+
+ vpn_key +
+

No API keys yet

+

Create a named key for each client or environment that connects to this gateway.

+ +
+
+ ) : ( +
+ {keys.map((key) => ( +
+
+
+

{key.name}

+ + {key.isActive === false ? "Paused" : "Active"} + +
+
+ {visibleKeys.has(key.id) ? key.key : maskKey(key.key)} + + +
+
+

Created
{formatVietnamDateTime(key.createdAt, { dateStyle: "medium" }) || "-"}

+
+ { + if (key.isActive && !checked) { + setConfirmState({ + title: "Pause API Key", + message: `Pause API key "${key.name}"?\n\nThis key will stop working immediately but can be resumed later.`, + onConfirm: async () => { + setConfirmState(null); + await handleToggleKey(key.id, checked); + }, + }); + return; + } + void handleToggleKey(key.id, checked); + }} + title={key.isActive ? "Pause key" : "Resume key"} + /> + +
+
+ ))} +
+ )} +
+ + { + setShowAddModal(false); + setNewKeyName(""); + }} + > +
+ setNewKeyName(event.target.value)} placeholder="Production Key" /> +
+ + +
+
+
+ + setCreatedKey(null)}> +
+
+

Store this key securely

+

Copy it now and store it in your client configuration or credential manager.

+
+
+ + +
+ +
+
+ + setConfirmState(null)} + onConfirm={confirmState?.onConfirm} + title={confirmState?.title || "Confirm"} + message={confirmState?.message} + variant="danger" + /> +
+ ); +} + +APIPageClient.propTypes = { + isAdmin: PropTypes.bool.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js b/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js index ccdb6ba3..0f525811 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js +++ b/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js @@ -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 ( -
+
{label} diff --git a/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js b/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js index c9726ea0..1f82bb55 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js +++ b/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js @@ -4,8 +4,14 @@ export default function Tooltip({ text }) { return ( - help - + + {text} diff --git a/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js b/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js index ac10b76b..53ced7e1 100644 --- a/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js +++ b/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js @@ -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" }, diff --git a/src/app/(dashboard)/dashboard/endpoint/endpointPing.js b/src/app/(dashboard)/dashboard/endpoint/endpointPing.js deleted file mode 100644 index 5c522cdc..00000000 --- a/src/app/(dashboard)/dashboard/endpoint/endpointPing.js +++ /dev/null @@ -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); - })); - }); -} diff --git a/src/app/(dashboard)/dashboard/endpoint/page.js b/src/app/(dashboard)/dashboard/endpoint/page.js deleted file mode 100644 index f90fcbc8..00000000 --- a/src/app/(dashboard)/dashboard/endpoint/page.js +++ /dev/null @@ -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 ; -} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js index 5580d759..176eba39 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js @@ -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 }) {
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 && ( - - )}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js index 815528d7..526217ee 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js @@ -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 }) { {endpoint}{apiPath} - {tunnelEndpoint && ( - - )}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js index 18f2a4f6..80e61a08 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js @@ -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 }) { {endpoint}/v1/audio/transcriptions - {tunnelEndpoint && ( - - )}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js index e0191903..9127074a 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js @@ -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 }) { {endpoint}/v1/audio/speech - {tunnelEndpoint && ( - - )}
@@ -457,7 +438,7 @@ export function TtsExampleCard({ providerId }) {
{curlSnippet}
- {error &&

{error}

} + {error &&

{error}

} {/* Audio player */} {audioUrl ? ( diff --git a/src/app/(dashboard)/dashboard/page.js b/src/app/(dashboard)/dashboard/page.js index 6a94a1fe..33cc70e7 100644 --- a/src/app/(dashboard)/dashboard/page.js +++ b/src/app/(dashboard)/dashboard/page.js @@ -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 ; + const user = await getCurrentDashboardUser(); + return ; } diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js index b91558e5..ef50e335 100644 --- a/src/app/api/auth/login/route.js +++ b/src/app/api/auth/login/route.js @@ -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 }); } diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index 845a4387..f1cc019c 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -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; diff --git a/src/app/api/tunnel/disable/route.js b/src/app/api/tunnel/disable/route.js deleted file mode 100644 index 4c15245a..00000000 --- a/src/app/api/tunnel/disable/route.js +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/tunnel/enable/route.js b/src/app/api/tunnel/enable/route.js deleted file mode 100644 index 05d55fc0..00000000 --- a/src/app/api/tunnel/enable/route.js +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js deleted file mode 100644 index 8b8f8053..00000000 --- a/src/app/api/tunnel/status/route.js +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/tunnel/tailscale-check/route.js b/src/app/api/tunnel/tailscale-check/route.js deleted file mode 100644 index f1a830ab..00000000 --- a/src/app/api/tunnel/tailscale-check/route.js +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/tunnel/tailscale-disable/route.js b/src/app/api/tunnel/tailscale-disable/route.js deleted file mode 100644 index 5258e3dd..00000000 --- a/src/app/api/tunnel/tailscale-disable/route.js +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/tunnel/tailscale-enable/route.js b/src/app/api/tunnel/tailscale-enable/route.js deleted file mode 100644 index 11e2d5d6..00000000 --- a/src/app/api/tunnel/tailscale-enable/route.js +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/tunnel/tailscale-install/route.js b/src/app/api/tunnel/tailscale-install/route.js deleted file mode 100644 index f53c5eec..00000000 --- a/src/app/api/tunnel/tailscale-install/route.js +++ /dev/null @@ -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", - }, - }); -} diff --git a/src/app/globals.css b/src/app/globals.css index fdc219ba..9c4f8bd3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 */ diff --git a/src/app/layout.js b/src/app/layout.js index 2f9ca5c9..2456d49d 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -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"; diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index a02a8fa3..6eec8a70 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -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) { diff --git a/src/lib/db/migrations/009-remove-tunnel-settings.js b/src/lib/db/migrations/009-remove-tunnel-settings.js new file mode 100644 index 00000000..d3236c54 --- /dev/null +++ b/src/lib/db/migrations/009-remove-tunnel-settings.js @@ -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; diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js index 716038f5..5571d7fb 100644 --- a/src/lib/db/migrations/index.js +++ b/src/lib/db/migrations/index.js @@ -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; diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 5d9ac332..23f36feb 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -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)] diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index ac841f31..87c73297 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -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; diff --git a/src/lib/tunnel/cloudflare/cloudflared.js b/src/lib/tunnel/cloudflare/cloudflared.js deleted file mode 100644 index 38e4bf74..00000000 --- a/src/lib/tunnel/cloudflare/cloudflared.js +++ /dev/null @@ -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; - } -} diff --git a/src/lib/tunnel/cloudflare/config.js b/src/lib/tunnel/cloudflare/config.js deleted file mode 100644 index 2cc3f9db..00000000 --- a/src/lib/tunnel/cloudflare/config.js +++ /dev/null @@ -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"; diff --git a/src/lib/tunnel/cloudflare/manager.js b/src/lib/tunnel/cloudflare/manager.js deleted file mode 100644 index e15b47b2..00000000 --- a/src/lib/tunnel/cloudflare/manager.js +++ /dev/null @@ -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 - }; -} diff --git a/src/lib/tunnel/cloudflare/pid.js b/src/lib/tunnel/cloudflare/pid.js deleted file mode 100644 index 919837c5..00000000 --- a/src/lib/tunnel/cloudflare/pid.js +++ /dev/null @@ -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 */ } -} diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js deleted file mode 100644 index 3ffab372..00000000 --- a/src/lib/tunnel/index.js +++ /dev/null @@ -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"; diff --git a/src/lib/tunnel/shared/state.js b/src/lib/tunnel/shared/state.js deleted file mode 100644 index 6a161814..00000000 --- a/src/lib/tunnel/shared/state.js +++ /dev/null @@ -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 }; diff --git a/src/lib/tunnel/shared/watchdogConfig.js b/src/lib/tunnel/shared/watchdogConfig.js deleted file mode 100644 index 0274997b..00000000 --- a/src/lib/tunnel/shared/watchdogConfig.js +++ /dev/null @@ -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; diff --git a/src/lib/tunnel/tailscale/config.js b/src/lib/tunnel/tailscale/config.js deleted file mode 100644 index 3195ad69..00000000 --- a/src/lib/tunnel/tailscale/config.js +++ /dev/null @@ -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, -}; diff --git a/src/lib/tunnel/tailscale/manager.js b/src/lib/tunnel/tailscale/manager.js deleted file mode 100644 index 7fe40e7e..00000000 --- a/src/lib/tunnel/tailscale/manager.js +++ /dev/null @@ -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 - }; -} diff --git a/src/lib/tunnel/tailscale/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js deleted file mode 100644 index 467ad286..00000000 --- a/src/lib/tunnel/tailscale/tailscale.js +++ /dev/null @@ -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 */ } -} diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 585a2fb3..e6f5bc61 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -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 ( -
+
{/* Mobile menu button */}
{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 */}
{displayName && loginMethod === "OIDC" && ( -
+
person {displayName} @@ -327,7 +320,7 @@ function HeaderSearch() { if (!visible) return null; return ( -
+
search diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 19bff0a1..b0286900 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -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 ( <> -