mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat: remove tunnel and tailscale feature
This commit is contained in:
+2
-53
@@ -150,54 +150,14 @@ function killByPidFile(pidFile) {
|
|||||||
} catch { }
|
} 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
|
// Kill all 9router processes
|
||||||
function killAllAppProcesses(appPort) {
|
function killAllAppProcesses(appPort) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
// Background: MITM + tunnel/cloudflared run on separate ports/processes —
|
// MITM runs on a separate process and does not block the critical path.
|
||||||
// killing them doesn't free the app port, so don't block the critical path.
|
|
||||||
// Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
|
// Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
try { killProxyByPidFile(); } catch {}
|
try { killProxyByPidFile(); } catch {}
|
||||||
try { killTunnelByPidFile(); } catch {}
|
|
||||||
try { killCloudflaredByAppPort(appPort); } catch {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
@@ -438,20 +398,11 @@ killAllAppProcesses(port)
|
|||||||
async function showInterfaceMenu() {
|
async function showInterfaceMenu() {
|
||||||
const { selectMenu } = require("./src/cli/utils/input");
|
const { selectMenu } = require("./src/cli/utils/input");
|
||||||
const { clearScreen } = require("./src/cli/utils/display");
|
const { clearScreen } = require("./src/cli/utils/display");
|
||||||
const { getEndpoint } = require("./src/cli/utils/endpoint");
|
|
||||||
|
|
||||||
clearScreen();
|
clearScreen();
|
||||||
|
|
||||||
const displayHost = getDisplayHost();
|
const displayHost = getDisplayHost();
|
||||||
|
|
||||||
// Detect tunnel/local mode for server URL display
|
const serverUrl = `http://${displayHost}:${port}`;
|
||||||
let serverUrl;
|
|
||||||
try {
|
|
||||||
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
|
||||||
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
|
|
||||||
} catch (e) {
|
|
||||||
serverUrl = `http://${displayHost}:${port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
|
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
|
||||||
|
|
||||||
@@ -529,8 +480,6 @@ function startServer() {
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
// Kill MIT server (privileged process) via PID file
|
// Kill MIT server (privileged process) via PID file
|
||||||
killProxyByPidFile();
|
killProxyByPidFile();
|
||||||
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
|
|
||||||
killTunnelByPidFile();
|
|
||||||
// Kill server process directly
|
// Kill server process directly
|
||||||
if (server.pid) {
|
if (server.pid) {
|
||||||
process.kill(server.pid, "SIGKILL");
|
process.kill(server.pid, "SIGKILL");
|
||||||
|
|||||||
@@ -430,34 +430,6 @@ async function validateProviderNode(data) {
|
|||||||
return makeRequest("POST", "/api/provider-nodes/validate", data);
|
return makeRequest("POST", "/api/provider-nodes/validate", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TUNNEL API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tunnel status
|
|
||||||
* @returns {Promise<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
|
|
||||||
*/
|
|
||||||
async function getTunnelStatus() {
|
|
||||||
return makeRequest("GET", "/api/tunnel/status");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable tunnel
|
|
||||||
* @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
|
|
||||||
*/
|
|
||||||
async function enableTunnel() {
|
|
||||||
return makeRequest("POST", "/api/tunnel/enable");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable tunnel
|
|
||||||
* @returns {Promise<Object>} { success, data: { success } }
|
|
||||||
*/
|
|
||||||
async function disableTunnel() {
|
|
||||||
return makeRequest("POST", "/api/tunnel/disable");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EXPORTS
|
// EXPORTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -501,11 +473,6 @@ module.exports = {
|
|||||||
updateSettings,
|
updateSettings,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
|
||||||
// Tunnel
|
|
||||||
getTunnelStatus,
|
|
||||||
enableTunnel,
|
|
||||||
disableTunnel,
|
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
getModels,
|
getModels,
|
||||||
getAvailableModels,
|
getAvailableModels,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const COLORS = {
|
|||||||
const DEFAULT_PASSWORD = "123456";
|
const DEFAULT_PASSWORD = "123456";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show settings menu (tunnel + RTK + reset password)
|
* Show settings menu (RTK + reset password)
|
||||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||||
*/
|
*/
|
||||||
async function showSettingsMenu(breadcrumb = []) {
|
async function showSettingsMenu(breadcrumb = []) {
|
||||||
@@ -26,15 +26,7 @@ async function showSettingsMenu(breadcrumb = []) {
|
|||||||
headerContent: async (data) => {
|
headerContent: async (data) => {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
|
||||||
// Tunnel section
|
lines.push(" Endpoint: http://localhost:20128/v1");
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RTK section
|
// RTK section
|
||||||
const rtkOn = data?.settings?.rtkEnabled !== false;
|
const rtkOn = data?.settings?.rtkEnabled !== false;
|
||||||
@@ -50,24 +42,12 @@ async function showSettingsMenu(breadcrumb = []) {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
},
|
},
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
const [tunnelRes, settingsRes] = await Promise.all([
|
const settingsRes = await api.getSettings();
|
||||||
api.getTunnelStatus(),
|
|
||||||
api.getSettings()
|
|
||||||
]);
|
|
||||||
return {
|
return {
|
||||||
tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {},
|
|
||||||
settings: settingsRes.success ? (settingsRes.data || {}) : {}
|
settings: settingsRes.success ? (settingsRes.data || {}) : {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
label: "Tunnel ON",
|
|
||||||
action: async () => { await enableTunnel(); return true; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Tunnel OFF",
|
|
||||||
action: async () => { await disableTunnel(); return true; }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: (d) => {
|
label: (d) => {
|
||||||
const on = d?.settings?.rtkEnabled !== false;
|
const on = d?.settings?.rtkEnabled !== false;
|
||||||
@@ -118,42 +98,6 @@ async function resetAuthMode() {
|
|||||||
await pause();
|
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
|
* Toggle RTK (Token Saver) via API
|
||||||
* @param {boolean} currentlyOn
|
* @param {boolean} currentlyOn
|
||||||
|
|||||||
@@ -17,16 +17,8 @@ const COLORS = {
|
|||||||
let cachedHeader = "";
|
let cachedHeader = "";
|
||||||
let fetchingHeader = false;
|
let fetchingHeader = false;
|
||||||
|
|
||||||
function renderHeader(port, keys, tunnel) {
|
function renderHeader(port, keys) {
|
||||||
const tunnelEnabled = tunnel && tunnel.enabled === true;
|
const lines = [`Endpoint: http://localhost:${port}/v1`];
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
if (!keys || keys.length === 0) {
|
if (!keys || keys.length === 0) {
|
||||||
lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
|
lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -40,13 +32,9 @@ async function refreshHeaderBg(port) {
|
|||||||
if (fetchingHeader) return;
|
if (fetchingHeader) return;
|
||||||
fetchingHeader = true;
|
fetchingHeader = true;
|
||||||
try {
|
try {
|
||||||
const [keysResult, tunnelResult] = await Promise.all([
|
const keysResult = await api.getApiKeys();
|
||||||
api.getApiKeys(),
|
|
||||||
api.getTunnelStatus()
|
|
||||||
]);
|
|
||||||
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
|
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
|
||||||
const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
|
cachedHeader = renderHeader(port, keys);
|
||||||
cachedHeader = renderHeader(port, keys, tunnel);
|
|
||||||
} finally {
|
} finally {
|
||||||
fetchingHeader = false;
|
fetchingHeader = false;
|
||||||
}
|
}
|
||||||
@@ -55,7 +43,7 @@ async function refreshHeaderBg(port) {
|
|||||||
function getHeader(port) {
|
function getHeader(port) {
|
||||||
// Kick off background refresh; return cache (or placeholder on first call).
|
// Kick off background refresh; return cache (or placeholder on first call).
|
||||||
refreshHeaderBg(port);
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,32 +1,20 @@
|
|||||||
const api = require("../api/client");
|
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
reset: "\x1b[0m",
|
|
||||||
green: "\x1b[32m"
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get endpoint URL based on tunnel status
|
* Get the local gateway endpoint.
|
||||||
* @param {number} port - Local server port
|
* @param {number} port - Local server port
|
||||||
* @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
|
* @returns {Promise<{endpoint: string}>}
|
||||||
*/
|
*/
|
||||||
async function getEndpoint(port) {
|
async function getEndpoint(port) {
|
||||||
const result = await api.getTunnelStatus();
|
return { endpoint: `http://localhost:${port}/v1` };
|
||||||
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
|
* Get the local gateway endpoint for terminal output.
|
||||||
* @param {number} port - Local server port
|
* @param {number} port - Local server port
|
||||||
* @returns {Promise<string>} Colored endpoint string
|
* @returns {Promise<string>}
|
||||||
*/
|
*/
|
||||||
async function getEndpointColored(port) {
|
async function getEndpointColored(port) {
|
||||||
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
const { endpoint } = await getEndpoint(port);
|
||||||
return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
|
return endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getEndpoint, getEndpointColored };
|
module.exports = { getEndpoint, getEndpointColored };
|
||||||
@@ -101,7 +101,6 @@
|
|||||||
"All providers": "همه ارائهدهندگان",
|
"All providers": "همه ارائهدهندگان",
|
||||||
"All rates are in": "همه نرخها بر حسب",
|
"All rates are in": "همه نرخها بر حسب",
|
||||||
"All selected currently unbound": "همه موارد انتخاب شده در حال حاضر بدون اتصال هستند",
|
"All selected currently unbound": "همه موارد انتخاب شده در حال حاضر بدون اتصال هستند",
|
||||||
"Allow dashboard access via tunnel": "اجازه دسترسی به داشبورد از طریق تونل",
|
|
||||||
"Allow either password or OIDC.": "اجازه ورود با رمز عبور یا OIDC را بدهید.",
|
"Allow either password or OIDC.": "اجازه ورود با رمز عبور یا OIDC را بدهید.",
|
||||||
"An error occurred": "خطایی رخ داد",
|
"An error occurred": "خطایی رخ داد",
|
||||||
"An error occurred. Please try again.": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
"An error occurred. Please try again.": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||||
@@ -115,7 +114,6 @@
|
|||||||
"Apply Proxy": "اعمال پروکسی",
|
"Apply Proxy": "اعمال پروکسی",
|
||||||
"Applying...": "در حال اعمال...",
|
"Applying...": "در حال اعمال...",
|
||||||
"Are you sure you want to close the proxy server?": "آیا مطمئن هستید که میخواهید سرور پروکسی را ببندید؟",
|
"Are you sure you want to close the proxy server?": "آیا مطمئن هستید که میخواهید سرور پروکسی را ببندید؟",
|
||||||
"Are you sure you want to disable the tunnel?": "آیا مطمئن هستید که میخواهید تونل را غیرفعال کنید؟",
|
|
||||||
"Attempting to reconnect...": "در حال تلاش برای اتصال مجدد...",
|
"Attempting to reconnect...": "در حال تلاش برای اتصال مجدد...",
|
||||||
"Audio File": "فایل صوتی",
|
"Audio File": "فایل صوتی",
|
||||||
"Auth Mode": "حالت احراز هویت",
|
"Auth Mode": "حالت احراز هویت",
|
||||||
@@ -228,7 +226,6 @@
|
|||||||
"Closing in": "در حال بسته شدن در",
|
"Closing in": "در حال بسته شدن در",
|
||||||
"Cloud Sync": "همگامسازی ابری",
|
"Cloud Sync": "همگامسازی ابری",
|
||||||
"Cloudflare Relay": "Cloudflare Relay",
|
"Cloudflare Relay": "Cloudflare Relay",
|
||||||
"Cloudflare Tunnel": "تونل Cloudflare",
|
|
||||||
"Cloudflare Workers AI": "Cloudflare Workers AI",
|
"Cloudflare Workers AI": "Cloudflare Workers AI",
|
||||||
"Codex CLI - Manual Configuration": "Codex CLI - پیکربندی دستی",
|
"Codex CLI - Manual Configuration": "Codex CLI - پیکربندی دستی",
|
||||||
"Codex CLI not detected locally": "Codex CLI در سیستم محلی شناسایی نشد",
|
"Codex CLI not detected locally": "Codex CLI در سیستم محلی شناسایی نشد",
|
||||||
@@ -328,7 +325,6 @@
|
|||||||
"Currently using accounts in priority order (Fill First).": "در حال حاضر از حسابها به ترتیب اولویت استفاده میکند (ابتدا پر کردن).",
|
"Currently using accounts in priority order (Fill First).": "در حال حاضر از حسابها به ترتیب اولویت استفاده میکند (ابتدا پر کردن).",
|
||||||
"Cursor AI Code Editor": "ویرایشگر کد هوش مصنوعی Cursor",
|
"Cursor AI Code Editor": "ویرایشگر کد هوش مصنوعی Cursor",
|
||||||
"Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE شناسایی نشد. لطفاً توکنهای خود را به صورت دستی بچسبانید.",
|
"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": "سفارشی",
|
||||||
"Custom Pricing:": "قیمتگذاری سفارشی:",
|
"Custom Pricing:": "قیمتگذاری سفارشی:",
|
||||||
"Custom Providers (OpenAI/Anthropic Compatible)": "ارائهدهندگان سفارشی (سازگار با OpenAI/Anthropic)",
|
"Custom Providers (OpenAI/Anthropic Compatible)": "ارائهدهندگان سفارشی (سازگار با OpenAI/Anthropic)",
|
||||||
@@ -388,8 +384,6 @@
|
|||||||
"Dimensions": "ابعاد",
|
"Dimensions": "ابعاد",
|
||||||
"Disable": "غیرفعالسازی",
|
"Disable": "غیرفعالسازی",
|
||||||
"Disable All": "غیرفعالسازی همه",
|
"Disable All": "غیرفعالسازی همه",
|
||||||
"Disable Tailscale": "غیرفعالسازی Tailscale",
|
|
||||||
"Disable Tunnel": "غیرفعالسازی تونل",
|
|
||||||
"Disable connections with depleted quota on the current page": "غیرفعالسازی اتصالات با سهمیه تمام شده در صفحه فعلی",
|
"Disable connections with depleted quota on the current page": "غیرفعالسازی اتصالات با سهمیه تمام شده در صفحه فعلی",
|
||||||
"Disable provider": "غیرفعالسازی ارائهدهنده",
|
"Disable provider": "غیرفعالسازی ارائهدهنده",
|
||||||
"Disable this model": "غیرفعالسازی این مدل",
|
"Disable this model": "غیرفعالسازی این مدل",
|
||||||
@@ -423,7 +417,6 @@
|
|||||||
"Enable DNS to edit model mappings": "برای ویرایش نگاشتهای مدل، DNS را فعال کنید",
|
"Enable DNS to edit model mappings": "برای ویرایش نگاشتهای مدل، DNS را فعال کنید",
|
||||||
"Enable Observability": "فعالسازی مشاهدهپذیری",
|
"Enable Observability": "فعالسازی مشاهدهپذیری",
|
||||||
"Enable OpenAI API": "فعالسازی OpenAI API",
|
"Enable OpenAI API": "فعالسازی OpenAI API",
|
||||||
"Enable Tunnel": "فعالسازی تونل",
|
|
||||||
"Enable connections that still have quota on the current page": "فعالسازی اتصالاتی که هنوز در صفحه فعلی سهمیه دارند",
|
"Enable connections that still have quota on the current page": "فعالسازی اتصالاتی که هنوز در صفحه فعلی سهمیه دارند",
|
||||||
"Enable provider": "فعالسازی ارائهدهنده",
|
"Enable provider": "فعالسازی ارائهدهنده",
|
||||||
"Enable proxy for OAuth + provider outbound requests.": "فعالسازی پروکسی برای درخواستهای خروجی OAuth + ارائهدهنده.",
|
"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 Cline VS Code extension or CLI from": "افزونه یا CLI Cline VS Code را از نصب کنید",
|
||||||
"Install Kilo Code from": "Kilo Code را از نصب کنید",
|
"Install Kilo Code from": "Kilo Code را از نصب کنید",
|
||||||
"Install Qwen Code": "نصب Qwen Code",
|
"Install Qwen Code": "نصب Qwen Code",
|
||||||
"Install Tailscale": "نصب Tailscale",
|
|
||||||
"Install command:": "دستور نصب:",
|
"Install command:": "دستور نصب:",
|
||||||
"Install jcode to enable automatic configuration:": "jcode را نصب کنید تا پیکربندی خودکار فعال شود:",
|
"Install jcode to enable automatic configuration:": "jcode را نصب کنید تا پیکربندی خودکار فعال شود:",
|
||||||
"Install the Amp CLI using the package manager supported by your environment.": "Amp CLI را با استفاده از مدیر بسته پشتیبانی شده توسط محیط خود نصب کنید.",
|
"Install the Amp CLI using the package manager supported by your environment.": "Amp CLI را با استفاده از مدیر بسته پشتیبانی شده توسط محیط خود نصب کنید.",
|
||||||
"Install then click Start:": "نصب کنید سپس روی شروع کلیک کنید:",
|
"Install then click Start:": "نصب کنید سپس روی شروع کلیک کنید:",
|
||||||
"Install via npm:": "نصب از طریق npm:",
|
"Install via npm:": "نصب از طریق npm:",
|
||||||
"Installation Guide": "راهنمای نصب",
|
"Installation Guide": "راهنمای نصب",
|
||||||
"Installing Tailscale...": "در حال نصب Tailscale...",
|
|
||||||
"Interactive diagram visible on desktop": "نمودار تعاملی در دسکتاپ قابل مشاهده است",
|
"Interactive diagram visible on desktop": "نمودار تعاملی در دسکتاپ قابل مشاهده است",
|
||||||
"Intercept CLI tool traffic and route through 9Router": "ترافیک ابزار CLI را رهگیری کرده و از طریق 9Router مسیردهی کنید",
|
"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 مسیردهی مجدد کنید.",
|
"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 Headroom separately at the configured URL, then recheck.": "Headroom را به صورت جداگانه در آدرس پیکربندی شده راهاندازی کنید، سپس دوباره بررسی کنید.",
|
||||||
"Start MITM": "راهاندازی MITM",
|
"Start MITM": "راهاندازی MITM",
|
||||||
"Start Server": "راهاندازی سرور",
|
"Start Server": "راهاندازی سرور",
|
||||||
"Start Tunnel": "راهاندازی تونل",
|
|
||||||
"Start a conversation": "شروع یک گفتگو",
|
"Start a conversation": "شروع یک گفتگو",
|
||||||
"Starting 9Router...": "در حال راهاندازی 9Router...",
|
"Starting 9Router...": "در حال راهاندازی 9Router...",
|
||||||
"Status": "وضعیت",
|
"Status": "وضعیت",
|
||||||
@@ -1129,11 +1119,6 @@
|
|||||||
"Sync settings across devices with optional cloud storage.": "همگامسازی تنظیمات بین دستگاهها با ذخیرهسازی اختیاری ابری.",
|
"Sync settings across devices with optional cloud storage.": "همگامسازی تنظیمات بین دستگاهها با ذخیرهسازی اختیاری ابری.",
|
||||||
"System": "سیستم",
|
"System": "سیستم",
|
||||||
"TTFT:": "TTFT:",
|
"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": "درخواست هدف",
|
"Target Request": "درخواست هدف",
|
||||||
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
|
"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…": "تولید متن به تصویر از طریق DALL-E، Imagen، FLUX، MiniMax، SDWebUI…",
|
"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 proxy server has been stopped.": "سرور پروکسی متوقف شده است.",
|
||||||
"The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "درخواست فوراً توسط OpenAI، Anthropic، Gemini یا دیگران برآورده میشود.",
|
"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 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": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی",
|
||||||
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی. امن، قابل مشاهده و مقیاسپذیر.",
|
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی. امن، قابل مشاهده و مقیاسپذیر.",
|
||||||
@@ -1210,9 +1193,6 @@
|
|||||||
"Trust Cert": "اعتماد به گواهی",
|
"Trust Cert": "اعتماد به گواهی",
|
||||||
"Trusted": "معتمد",
|
"Trusted": "معتمد",
|
||||||
"Try Again": "دوباره تلاش کنید",
|
"Try Again": "دوباره تلاش کنید",
|
||||||
"Tunnel": "تونل",
|
|
||||||
"Tunnel connected!": "تونل متصل شد!",
|
|
||||||
"Tunnel disabled": "تونل غیرفعال شد",
|
|
||||||
"Turn off Empty": "خاموش کردن حسابهای خالی",
|
"Turn off Empty": "خاموش کردن حسابهای خالی",
|
||||||
"Turn on Available": "روشن کردن حسابهای موجود",
|
"Turn on Available": "روشن کردن حسابهای موجود",
|
||||||
"Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری",
|
"Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری",
|
||||||
|
|||||||
@@ -101,7 +101,6 @@
|
|||||||
"All providers": "ผู้ให้บริการทั้งหมด",
|
"All providers": "ผู้ให้บริการทั้งหมด",
|
||||||
"All rates are in": "อัตราทั้งหมดเป็น",
|
"All rates are in": "อัตราทั้งหมดเป็น",
|
||||||
"All selected currently unbound": "ที่เลือกทั้งหมดยังไม่ได้เชื่อมต่อ",
|
"All selected currently unbound": "ที่เลือกทั้งหมดยังไม่ได้เชื่อมต่อ",
|
||||||
"Allow dashboard access via tunnel": "อนุญาตให้เข้าถึง dashboard ผ่าน tunnel",
|
|
||||||
"Allow either password or OIDC.": "อนุญาตทั้งรหัสผ่านหรือ OIDC",
|
"Allow either password or OIDC.": "อนุญาตทั้งรหัสผ่านหรือ OIDC",
|
||||||
"An error occurred": "เกิดข้อผิดพลาด",
|
"An error occurred": "เกิดข้อผิดพลาด",
|
||||||
"An error occurred. Please try again.": "เกิดข้อผิดพลาด กรุณาลองใหม่",
|
"An error occurred. Please try again.": "เกิดข้อผิดพลาด กรุณาลองใหม่",
|
||||||
@@ -115,7 +114,6 @@
|
|||||||
"Apply Proxy": "ใช้ Proxy",
|
"Apply Proxy": "ใช้ Proxy",
|
||||||
"Applying...": "กำลังใช้...",
|
"Applying...": "กำลังใช้...",
|
||||||
"Are you sure you want to close the proxy server?": "คุณแน่ใจหรือว่าต้องการปิด proxy server?",
|
"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...": "กำลังพยายามเชื่อมต่อใหม่...",
|
"Attempting to reconnect...": "กำลังพยายามเชื่อมต่อใหม่...",
|
||||||
"Audio File": "ไฟล์เสียง",
|
"Audio File": "ไฟล์เสียง",
|
||||||
"Auth Mode": "โหมดยืนยันตัวตน",
|
"Auth Mode": "โหมดยืนยันตัวตน",
|
||||||
@@ -228,7 +226,6 @@
|
|||||||
"Closing in": "ปิดใน",
|
"Closing in": "ปิดใน",
|
||||||
"Cloud Sync": "Cloud Sync",
|
"Cloud Sync": "Cloud Sync",
|
||||||
"Cloudflare Relay": "Cloudflare Relay",
|
"Cloudflare Relay": "Cloudflare Relay",
|
||||||
"Cloudflare Tunnel": "Cloudflare Tunnel",
|
|
||||||
"Cloudflare Workers AI": "Cloudflare Workers AI",
|
"Cloudflare Workers AI": "Cloudflare Workers AI",
|
||||||
"Codex CLI - Manual Configuration": "Codex CLI - กำหนดค่าด้วยตนเอง",
|
"Codex CLI - Manual Configuration": "Codex CLI - กำหนดค่าด้วยตนเอง",
|
||||||
"Codex CLI not detected locally": "ไม่พบ Codex CLI บนเครื่อง",
|
"Codex CLI not detected locally": "ไม่พบ Codex CLI บนเครื่อง",
|
||||||
@@ -328,7 +325,6 @@
|
|||||||
"Currently using accounts in priority order (Fill First).": "ใช้บัญชีตามลำดับความสำคัญ (เติมก่อน)",
|
"Currently using accounts in priority order (Fill First).": "ใช้บัญชีตามลำดับความสำคัญ (เติมก่อน)",
|
||||||
"Cursor AI Code Editor": "Cursor AI Code Editor",
|
"Cursor AI Code Editor": "Cursor AI Code Editor",
|
||||||
"Cursor IDE not detected. Please paste your tokens manually.": "ไม่พบ Cursor IDE กรุณาวาง tokens ด้วยตนเอง",
|
"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": "กำหนดเอง",
|
||||||
"Custom Pricing:": "กำหนดราคาเอง:",
|
"Custom Pricing:": "กำหนดราคาเอง:",
|
||||||
"Custom Providers (OpenAI/Anthropic Compatible)": "Custom Providers (OpenAI/Anthropic Compatible)",
|
"Custom Providers (OpenAI/Anthropic Compatible)": "Custom Providers (OpenAI/Anthropic Compatible)",
|
||||||
@@ -388,8 +384,6 @@
|
|||||||
"Dimensions": "มิติ",
|
"Dimensions": "มิติ",
|
||||||
"Disable": "ปิดใช้งาน",
|
"Disable": "ปิดใช้งาน",
|
||||||
"Disable All": "ปิดทั้งหมด",
|
"Disable All": "ปิดทั้งหมด",
|
||||||
"Disable Tailscale": "ปิด Tailscale",
|
|
||||||
"Disable Tunnel": "ปิด Tunnel",
|
|
||||||
"Disable connections with depleted quota on the current page": "ปิดการเชื่อมต่อที่ quota หมดบนหน้าปัจจุบัน",
|
"Disable connections with depleted quota on the current page": "ปิดการเชื่อมต่อที่ quota หมดบนหน้าปัจจุบัน",
|
||||||
"Disable provider": "ปิด provider",
|
"Disable provider": "ปิด provider",
|
||||||
"Disable this model": "ปิดโมเดลนี้",
|
"Disable this model": "ปิดโมเดลนี้",
|
||||||
@@ -423,7 +417,6 @@
|
|||||||
"Enable DNS to edit model mappings": "เปิดใช้งาน DNS เพื่อแก้ไข model mappings",
|
"Enable DNS to edit model mappings": "เปิดใช้งาน DNS เพื่อแก้ไข model mappings",
|
||||||
"Enable Observability": "เปิดใช้งาน Observability",
|
"Enable Observability": "เปิดใช้งาน Observability",
|
||||||
"Enable OpenAI API": "เปิดใช้งาน OpenAI API",
|
"Enable OpenAI API": "เปิดใช้งาน OpenAI API",
|
||||||
"Enable Tunnel": "เปิดใช้งาน Tunnel",
|
|
||||||
"Enable connections that still have quota on the current page": "เปิดการเชื่อมต่อที่ยังมี quota บนหน้าปัจจุบัน",
|
"Enable connections that still have quota on the current page": "เปิดการเชื่อมต่อที่ยังมี quota บนหน้าปัจจุบัน",
|
||||||
"Enable provider": "เปิด provider",
|
"Enable provider": "เปิด provider",
|
||||||
"Enable proxy for OAuth + provider outbound requests.": "เปิด proxy สำหรับ OAuth + provider outbound requests",
|
"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 Cline VS Code extension or CLI from": "ติดตั้ง Cline VS Code extension หรือ CLI จาก",
|
||||||
"Install Kilo Code from": "ติดตั้ง Kilo Code จาก",
|
"Install Kilo Code from": "ติดตั้ง Kilo Code จาก",
|
||||||
"Install Qwen Code": "ติดตั้ง Qwen Code",
|
"Install Qwen Code": "ติดตั้ง Qwen Code",
|
||||||
"Install Tailscale": "ติดตั้ง Tailscale",
|
|
||||||
"Install command:": "คำสั่งติดตั้ง:",
|
"Install command:": "คำสั่งติดตั้ง:",
|
||||||
"Install jcode to enable automatic configuration:": "ติดตั้ง jcode เพื่อเปิดใช้งานการกำหนดค่าอัตโนมัติ:",
|
"Install jcode to enable automatic configuration:": "ติดตั้ง jcode เพื่อเปิดใช้งานการกำหนดค่าอัตโนมัติ:",
|
||||||
"Install the Amp CLI using the package manager supported by your environment.": "ติดตั้ง Amp CLI โดยใช้ package manager ที่รองรับในสภาพแวดล้อมของคุณ",
|
"Install the Amp CLI using the package manager supported by your environment.": "ติดตั้ง Amp CLI โดยใช้ package manager ที่รองรับในสภาพแวดล้อมของคุณ",
|
||||||
"Install then click Start:": "ติดตั้งแล้วคลิกเริ่ม:",
|
"Install then click Start:": "ติดตั้งแล้วคลิกเริ่ม:",
|
||||||
"Install via npm:": "ติดตั้งผ่าน npm:",
|
"Install via npm:": "ติดตั้งผ่าน npm:",
|
||||||
"Installation Guide": "คู่มือการติดตั้ง",
|
"Installation Guide": "คู่มือการติดตั้ง",
|
||||||
"Installing Tailscale...": "กำลังติดตั้ง Tailscale...",
|
|
||||||
"Interactive diagram visible on desktop": "แผนภาพแบบ interactive ที่มองเห็นบนเดสก์ท็อป",
|
"Interactive diagram visible on desktop": "แผนภาพแบบ interactive ที่มองเห็นบนเดสก์ท็อป",
|
||||||
"Intercept CLI tool traffic and route through 9Router": "ดักจับ CLI tool traffic แล้วส่งต่อผ่าน 9Router",
|
"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",
|
"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 Headroom separately at the configured URL, then recheck.": "เริ่ม Headroom แยกต่างหากที่ URL ที่กำหนด แล้วตรวจสอบอีกครั้ง",
|
||||||
"Start MITM": "เริ่ม MITM",
|
"Start MITM": "เริ่ม MITM",
|
||||||
"Start Server": "เริ่มเซิร์ฟเวอร์",
|
"Start Server": "เริ่มเซิร์ฟเวอร์",
|
||||||
"Start Tunnel": "เริ่ม Tunnel",
|
|
||||||
"Start a conversation": "เริ่มการสนทนา",
|
"Start a conversation": "เริ่มการสนทนา",
|
||||||
"Starting 9Router...": "กำลังเริ่ม 9Router...",
|
"Starting 9Router...": "กำลังเริ่ม 9Router...",
|
||||||
"Status": "สถานะ",
|
"Status": "สถานะ",
|
||||||
@@ -1129,11 +1119,6 @@
|
|||||||
"Sync settings across devices with optional cloud storage.": "ซิงค์การตั้งค่าผ่านอุปกรณ์ต่างๆ ด้วย cloud storage ทางเลือก",
|
"Sync settings across devices with optional cloud storage.": "ซิงค์การตั้งค่าผ่านอุปกรณ์ต่างๆ ด้วย cloud storage ทางเลือก",
|
||||||
"System": "ระบบ",
|
"System": "ระบบ",
|
||||||
"TTFT:": "TTFT:",
|
"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",
|
"Target Request": "Target Request",
|
||||||
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com",
|
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com",
|
||||||
"Temperature": "Temperature",
|
"Temperature": "Temperature",
|
||||||
@@ -1161,10 +1146,8 @@
|
|||||||
"Text to Image combo": "Text to Image combo",
|
"Text to Image combo": "Text to Image combo",
|
||||||
"Text-to-Speech": "Text-to-Speech",
|
"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...",
|
"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 proxy server has been stopped.": "Proxy server ถูกหยุดแล้ว",
|
||||||
"The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "Request ได้รับการตอบสนองจาก OpenAI, Anthropic, Gemini หรือผู้ให้บริการอื่นทันที",
|
"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 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": "Interface เดียวสำหรับ AI infrastructure สมัยใหม่",
|
||||||
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "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",
|
"Trust Cert": "Trust Cert",
|
||||||
"Trusted": "เชื่อถือแล้ว",
|
"Trusted": "เชื่อถือแล้ว",
|
||||||
"Try Again": "ลองอีกครั้ง",
|
"Try Again": "ลองอีกครั้ง",
|
||||||
"Tunnel": "Tunnel",
|
|
||||||
"Tunnel connected!": "Tunnel เชื่อมต่อแล้ว!",
|
|
||||||
"Tunnel disabled": "ปิด Tunnel แล้ว",
|
|
||||||
"Turn off Empty": "ปิด Empty",
|
"Turn off Empty": "ปิด Empty",
|
||||||
"Turn on Available": "เปิด Available",
|
"Turn on Available": "เปิด Available",
|
||||||
"Turn request detail recording on/off globally": "เปิด/ปิดการบันทึก request details ทั่วโลก",
|
"Turn request detail recording on/off globally": "เปิด/ปิดการบันทึก request details ทั่วโลก",
|
||||||
|
|||||||
@@ -101,7 +101,6 @@
|
|||||||
"All providers": "所有提供商",
|
"All providers": "所有提供商",
|
||||||
"All rates are in": "所有费率均在",
|
"All rates are in": "所有费率均在",
|
||||||
"All selected currently unbound": "所有选中项当前未绑定",
|
"All selected currently unbound": "所有选中项当前未绑定",
|
||||||
"Allow dashboard access via tunnel": "允许通过隧道访问仪表盘",
|
|
||||||
"Allow either password or OIDC.": "允许密码或 OIDC 登录。",
|
"Allow either password or OIDC.": "允许密码或 OIDC 登录。",
|
||||||
"An error occurred": "发生错误",
|
"An error occurred": "发生错误",
|
||||||
"An error occurred. Please try again.": "发生错误,请重试。",
|
"An error occurred. Please try again.": "发生错误,请重试。",
|
||||||
@@ -115,7 +114,6 @@
|
|||||||
"Apply Proxy": "应用代理",
|
"Apply Proxy": "应用代理",
|
||||||
"Applying...": "应用中...",
|
"Applying...": "应用中...",
|
||||||
"Are you sure you want to close the proxy server?": "您确定要关闭代理服务器吗?",
|
"Are you sure you want to close the proxy server?": "您确定要关闭代理服务器吗?",
|
||||||
"Are you sure you want to disable the tunnel?": "您确定要禁用隧道吗?",
|
|
||||||
"Attempting to reconnect...": "正在尝试重新连接...",
|
"Attempting to reconnect...": "正在尝试重新连接...",
|
||||||
"Audio File": "音频文件",
|
"Audio File": "音频文件",
|
||||||
"Auth Mode": "认证模式",
|
"Auth Mode": "认证模式",
|
||||||
@@ -228,7 +226,6 @@
|
|||||||
"Closing in": "即将关闭",
|
"Closing in": "即将关闭",
|
||||||
"Cloud Sync": "云端同步",
|
"Cloud Sync": "云端同步",
|
||||||
"Cloudflare Relay": "Cloudflare Relay",
|
"Cloudflare Relay": "Cloudflare Relay",
|
||||||
"Cloudflare Tunnel": "Cloudflare 隧道",
|
|
||||||
"Cloudflare Workers AI": "Cloudflare Workers AI",
|
"Cloudflare Workers AI": "Cloudflare Workers AI",
|
||||||
"Codex CLI - Manual Configuration": "Codex CLI - 手动配置",
|
"Codex CLI - Manual Configuration": "Codex CLI - 手动配置",
|
||||||
"Codex CLI not detected locally": "未在本地检测到 Codex CLI",
|
"Codex CLI not detected locally": "未在本地检测到 Codex CLI",
|
||||||
@@ -328,7 +325,6 @@
|
|||||||
"Currently using accounts in priority order (Fill First).": "当前按优先级顺序使用账号(优先填满)。",
|
"Currently using accounts in priority order (Fill First).": "当前按优先级顺序使用账号(优先填满)。",
|
||||||
"Cursor AI Code Editor": "Cursor AI 代码编辑器",
|
"Cursor AI Code Editor": "Cursor AI 代码编辑器",
|
||||||
"Cursor IDE not detected. Please paste your tokens manually.": "未检测到Cursor IDE。请手动粘贴您的令牌。",
|
"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": "自定义",
|
||||||
"Custom Pricing:": "定制定价:",
|
"Custom Pricing:": "定制定价:",
|
||||||
"Custom Providers (OpenAI/Anthropic Compatible)": "自定义提供商(OpenAI/Anthropic 兼容)",
|
"Custom Providers (OpenAI/Anthropic Compatible)": "自定义提供商(OpenAI/Anthropic 兼容)",
|
||||||
@@ -388,8 +384,6 @@
|
|||||||
"Dimensions": "维度",
|
"Dimensions": "维度",
|
||||||
"Disable": "禁用",
|
"Disable": "禁用",
|
||||||
"Disable All": "全部禁用",
|
"Disable All": "全部禁用",
|
||||||
"Disable Tailscale": "禁用 Tailscale",
|
|
||||||
"Disable Tunnel": "禁用隧道",
|
|
||||||
"Disable connections with depleted quota on the current page": "禁用当前页面上配额已耗尽的连接",
|
"Disable connections with depleted quota on the current page": "禁用当前页面上配额已耗尽的连接",
|
||||||
"Disable provider": "禁用提供商",
|
"Disable provider": "禁用提供商",
|
||||||
"Disable this model": "禁用此模型",
|
"Disable this model": "禁用此模型",
|
||||||
@@ -422,7 +416,6 @@
|
|||||||
"Enable DNS to edit model mappings": "启用 DNS 以编辑模型映射",
|
"Enable DNS to edit model mappings": "启用 DNS 以编辑模型映射",
|
||||||
"Enable Observability": "启用可观察性",
|
"Enable Observability": "启用可观察性",
|
||||||
"Enable OpenAI API": "启用 OpenAI API",
|
"Enable OpenAI API": "启用 OpenAI API",
|
||||||
"Enable Tunnel": "启用隧道",
|
|
||||||
"Enable connections that still have quota on the current page": "启用当前页面上仍有配额的连接",
|
"Enable connections that still have quota on the current page": "启用当前页面上仍有配额的连接",
|
||||||
"Enable provider": "启用提供商",
|
"Enable provider": "启用提供商",
|
||||||
"Enable proxy for OAuth + provider outbound requests.": "为 OAuth + 提供商出站请求启用代理。",
|
"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 Cline VS Code extension or CLI from": "从以下位置安装 Cline VS Code 扩展或 CLI",
|
||||||
"Install Kilo Code from": "从以下位置安装 Kilo Code",
|
"Install Kilo Code from": "从以下位置安装 Kilo Code",
|
||||||
"Install Qwen Code": "安装 Qwen Code",
|
"Install Qwen Code": "安装 Qwen Code",
|
||||||
"Install Tailscale": "安装 Tailscale",
|
|
||||||
"Install command:": "安装命令:",
|
"Install command:": "安装命令:",
|
||||||
"Install jcode to enable automatic configuration:": "安装 jcode 以启用自动配置:",
|
"Install jcode to enable automatic configuration:": "安装 jcode 以启用自动配置:",
|
||||||
"Install the Amp CLI using the package manager supported by your environment.": "使用您环境支持的包管理器安装 Amp CLI。",
|
"Install the Amp CLI using the package manager supported by your environment.": "使用您环境支持的包管理器安装 Amp CLI。",
|
||||||
"Install then click Start:": "安装后点击启动:",
|
"Install then click Start:": "安装后点击启动:",
|
||||||
"Install via npm:": "通过 npm 安装:",
|
"Install via npm:": "通过 npm 安装:",
|
||||||
"Installation Guide": "安装指南",
|
"Installation Guide": "安装指南",
|
||||||
"Installing Tailscale...": "正在安装 Tailscale...",
|
|
||||||
"Interactive diagram visible on desktop": "桌面上可见的交互式图表",
|
"Interactive diagram visible on desktop": "桌面上可见的交互式图表",
|
||||||
"Intercept CLI tool traffic and route through 9Router": "拦截 CLI 工具流量并通过 9Router 路由",
|
"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 重新路由模型。",
|
"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 Headroom separately at the configured URL, then recheck.": "在配置的 URL 上单独启动 Headroom,然后重新检查。",
|
||||||
"Start MITM": "启动中间人",
|
"Start MITM": "启动中间人",
|
||||||
"Start Server": "启动服务器",
|
"Start Server": "启动服务器",
|
||||||
"Start Tunnel": "开始隧道",
|
|
||||||
"Start a conversation": "开始一个对话",
|
"Start a conversation": "开始一个对话",
|
||||||
"Starting 9Router...": "正在启动 9Router...",
|
"Starting 9Router...": "正在启动 9Router...",
|
||||||
"Status": "状态",
|
"Status": "状态",
|
||||||
@@ -1128,11 +1118,6 @@
|
|||||||
"Sync settings across devices with optional cloud storage.": "通过可选的云存储在设备间同步设置。",
|
"Sync settings across devices with optional cloud storage.": "通过可选的云存储在设备间同步设置。",
|
||||||
"System": "系统",
|
"System": "系统",
|
||||||
"TTFT:": "TTFT:",
|
"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。",
|
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com。",
|
||||||
"Temperature": "温度",
|
"Temperature": "温度",
|
||||||
@@ -1160,10 +1145,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…": "通过 DALL-E、Imagen、FLUX、MiniMax、SDWebUI 等进行文本到图像生成。",
|
"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 proxy server has been stopped.": "代理服务器已停止。",
|
||||||
"The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "请求由 OpenAI、Anthropic、Gemini 或其他提供商即时响应。",
|
"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 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": "现代 AI 基础设施的统一接口",
|
||||||
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "现代人工智能基础设施的统一接口。安全、可观察且可扩展。",
|
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "现代人工智能基础设施的统一接口。安全、可观察且可扩展。",
|
||||||
@@ -1209,9 +1192,6 @@
|
|||||||
"Trust Cert": "信任证书",
|
"Trust Cert": "信任证书",
|
||||||
"Trusted": "已信任",
|
"Trusted": "已信任",
|
||||||
"Try Again": "再试一次",
|
"Try Again": "再试一次",
|
||||||
"Tunnel": "隧道",
|
|
||||||
"Tunnel connected!": "隧道连通!",
|
|
||||||
"Tunnel disabled": "隧道已禁用",
|
|
||||||
"Turn off Empty": "关闭空账号",
|
"Turn off Empty": "关闭空账号",
|
||||||
"Turn on Available": "开启可用账号",
|
"Turn on Available": "开启可用账号",
|
||||||
"Turn request detail recording on/off globally": "全局打开/关闭请求详细信息记录",
|
"Turn request detail recording on/off globally": "全局打开/关闭请求详细信息记录",
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
.console {
|
||||||
|
--console-canvas: #f7f8fa;
|
||||||
|
--console-panel: #f1f3f6;
|
||||||
|
--console-panel-strong: #ffffff;
|
||||||
|
--console-ink: #171d27;
|
||||||
|
--console-muted: #657084;
|
||||||
|
--console-rule: #dce2e9;
|
||||||
|
--console-signal: #ed6548;
|
||||||
|
--console-signal-soft: rgba(237, 101, 72, 0.12);
|
||||||
|
--console-shadow: 0 18px 44px -30px rgba(32, 44, 62, 0.3);
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
color: var(--console-ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
inset: -2rem -1.5rem auto;
|
||||||
|
height: 20rem;
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 74% 4%, rgba(237, 101, 72, 0.12), transparent 30%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 82%);
|
||||||
|
mask-image: linear-gradient(to bottom, black, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .console {
|
||||||
|
--console-canvas: #0f1115;
|
||||||
|
--console-panel: #151a21;
|
||||||
|
--console-panel-strong: #1a2029;
|
||||||
|
--console-ink: #f1f5f9;
|
||||||
|
--console-muted: #a5b0bf;
|
||||||
|
--console-rule: #2d3744;
|
||||||
|
--console-signal: #f17052;
|
||||||
|
--console-signal-soft: rgba(241, 112, 82, 0.15);
|
||||||
|
--console-shadow: 0 22px 52px -32px rgba(0, 0, 0, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .console::before {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 74% 4%, rgba(241, 112, 82, 0.07), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(33, 40, 51, 0.42), transparent 82%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem);
|
||||||
|
align-items: end;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0.75rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionKicker {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--console-muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
max-width: 17ch;
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
color: var(--console-ink);
|
||||||
|
font-size: clamp(1.8rem, 3.4vw, 2.75rem);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
line-height: 1.02;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
max-width: 58ch;
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
color: var(--console-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posture {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 1rem 1.1rem 1.05rem;
|
||||||
|
border: 1px solid var(--console-rule);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--console-panel-strong) 92%, transparent);
|
||||||
|
box-shadow: var(--console-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.postureHeading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--console-ink);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.postureIndicator {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
background: var(--console-signal);
|
||||||
|
box-shadow: 0 0 0 0.2rem var(--console-signal-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.postureCopy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--console-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessPanel,
|
||||||
|
.keysPanel {
|
||||||
|
border: 1px solid var(--console-rule) !important;
|
||||||
|
border-radius: 1rem !important;
|
||||||
|
background: color-mix(in srgb, var(--console-panel-strong) 94%, transparent) !important;
|
||||||
|
box-shadow: var(--console-shadow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessPanel {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessPanel::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 5.5rem;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 2.7rem;
|
||||||
|
width: 1px;
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to bottom, var(--console-signal), var(--console-rule) 28%, var(--console-rule));
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: -1.5rem -1.5rem 1.25rem;
|
||||||
|
padding: 1.25rem 1.5rem 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--console-rule);
|
||||||
|
border-radius: 1rem 1rem 0 0;
|
||||||
|
background: linear-gradient(112deg, color-mix(in srgb, var(--console-panel) 96%, transparent), color-mix(in srgb, var(--console-panel-strong) 80%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.5rem;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 1.5rem;
|
||||||
|
height: 1px;
|
||||||
|
content: "";
|
||||||
|
background: linear-gradient(to right, var(--console-signal), transparent 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelIcon {
|
||||||
|
display: grid;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--console-signal) 35%, var(--console-rule));
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--console-signal-soft);
|
||||||
|
color: var(--console-signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeLocal,
|
||||||
|
.routeRemote {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
border-color: var(--console-rule) !important;
|
||||||
|
border-radius: 0.75rem !important;
|
||||||
|
background: color-mix(in srgb, var(--console-panel) 82%, transparent) !important;
|
||||||
|
transition: transform 180ms ease, border-color 180ms ease, background-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeRemote {
|
||||||
|
border: 1px solid var(--console-rule);
|
||||||
|
padding: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeLocal:hover,
|
||||||
|
.routeRemote:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--console-signal) 44%, var(--console-rule)) !important;
|
||||||
|
background: color-mix(in srgb, var(--console-panel-strong) 92%, transparent) !important;
|
||||||
|
transform: translateX(0.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeLocal::before,
|
||||||
|
.routeRemote::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: -1.05rem;
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
content: "";
|
||||||
|
border: 2px solid var(--console-panel-strong);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
background: var(--console-signal);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeRemote::before {
|
||||||
|
background: var(--console-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeRemote[data-route-state="connected"]::before {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeRemote[data-route-state="checking"]::before {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeRemote[data-route-state="error"]::before {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeInput :global(input) {
|
||||||
|
border-color: transparent !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--console-ink) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keysPanel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyRegistry {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--console-rule) !important;
|
||||||
|
border-radius: 0.75rem !important;
|
||||||
|
background: color-mix(in srgb, var(--console-panel) 68%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyRegistry > div {
|
||||||
|
border-color: var(--console-rule) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyRegistry > div:hover {
|
||||||
|
background: var(--console-panel) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityPolicy {
|
||||||
|
border: 1px solid var(--console-rule) !important;
|
||||||
|
border-radius: 0.75rem !important;
|
||||||
|
background: color-mix(in srgb, var(--console-panel) 74%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signalButton {
|
||||||
|
box-shadow: 0 10px 18px -14px color-mix(in srgb, var(--console-signal) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionIcon {
|
||||||
|
color: var(--console-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionIcon:hover {
|
||||||
|
color: var(--console-signal) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionIcon:focus-visible,
|
||||||
|
.routeButton:focus-visible,
|
||||||
|
.console :global(button):focus-visible,
|
||||||
|
.console :global(a):focus-visible {
|
||||||
|
outline: 2px solid var(--console-signal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: grid;
|
||||||
|
min-height: 17rem;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyGlyph {
|
||||||
|
display: grid;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--console-signal) 32%, var(--console-rule));
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: var(--console-signal-soft);
|
||||||
|
color: var(--console-signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonIntro,
|
||||||
|
.skeletonPanel {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonIntro {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 18rem;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonPanel {
|
||||||
|
min-height: 18rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonLine {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.console {
|
||||||
|
gap: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console::before {
|
||||||
|
inset: -1.25rem -1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro,
|
||||||
|
.skeletonIntro {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
max-width: 21ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posture {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessPanel::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeLocal::before,
|
||||||
|
.routeRemote::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
margin: -1.5rem -1.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.routeLocal,
|
||||||
|
.routeRemote {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeLocal:hover,
|
||||||
|
.routeRemote:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export { default } from "./endpoint/EndpointPageClient";
|
||||||
@@ -15,10 +15,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
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 [apiKeys, setApiKeys] = useState([]);
|
||||||
const [availableModels, setAvailableModels] = useState([]);
|
const [availableModels, setAvailableModels] = useState([]);
|
||||||
const [initialConfig, setInitialConfig] = useState(null);
|
const [initialConfig, setInitialConfig] = useState(null);
|
||||||
@@ -27,10 +23,9 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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/providers"),
|
||||||
fetch("/api/settings"),
|
fetch("/api/settings"),
|
||||||
fetch("/api/tunnel/status"),
|
|
||||||
fetch("/api/keys"),
|
fetch("/api/keys"),
|
||||||
fetch("/api/models/connected", { cache: "no-store" }),
|
fetch("/api/models/connected", { cache: "no-store" }),
|
||||||
fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, { 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();
|
const data = await settingsRes.json();
|
||||||
setCloudEnabled(data.cloudEnabled || false);
|
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) {
|
if (keysRes.ok) {
|
||||||
const data = await keysRes.json();
|
const data = await keysRes.json();
|
||||||
setApiKeys(data.keys || []);
|
setApiKeys(data.keys || []);
|
||||||
@@ -91,10 +79,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
appUrl: typeof window !== "undefined" ? window.location.origin : "",
|
appUrl: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
configuredBaseUrl: CONFIGURED_BASE_URL,
|
configuredBaseUrl: CONFIGURED_BASE_URL,
|
||||||
requiresExternalUrl: tool?.requiresExternalUrl === true,
|
requiresExternalUrl: tool?.requiresExternalUrl === true,
|
||||||
tunnelEnabled,
|
|
||||||
tunnelPublicUrl,
|
|
||||||
tailscaleEnabled,
|
|
||||||
tailscaleUrl,
|
|
||||||
cloudEnabled,
|
cloudEnabled,
|
||||||
cloudUrl: CLOUD_URL,
|
cloudUrl: CLOUD_URL,
|
||||||
});
|
});
|
||||||
@@ -106,10 +90,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
toolId,
|
toolId,
|
||||||
baseUrl: getBaseUrl(),
|
baseUrl: getBaseUrl(),
|
||||||
apiKeys,
|
apiKeys,
|
||||||
tunnelEnabled,
|
|
||||||
tunnelPublicUrl,
|
|
||||||
tailscaleEnabled,
|
|
||||||
tailscaleUrl,
|
|
||||||
activeProviders: getActiveProviders(),
|
activeProviders: getActiveProviders(),
|
||||||
availableModels,
|
availableModels,
|
||||||
cloudEnabled,
|
cloudEnabled,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const writeSavedPresets = (presets) => {
|
|||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(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 opts = [];
|
||||||
const wrap = (url) => (withV1 ? ensureCliToolV1Endpoint(url) : (url || "").replace(/\/+$/, ""));
|
const wrap = (url) => (withV1 ? ensureCliToolV1Endpoint(url) : (url || "").replace(/\/+$/, ""));
|
||||||
const runtimeUrl = wrap(appUrl);
|
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}`);
|
const fallbackLocalUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`);
|
||||||
opts.push({ value: "local", label: fallbackLocalUrl, url: fallbackLocalUrl });
|
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) {
|
if (cloudEnabled && cloudUrl) {
|
||||||
const u = wrap(cloudUrl);
|
const u = wrap(cloudUrl);
|
||||||
opts.push({ value: "cloud", label: u, url: u });
|
opts.push({ value: "cloud", label: u, url: u });
|
||||||
@@ -58,10 +50,6 @@ export default function BaseUrlSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
appUrl = "",
|
appUrl = "",
|
||||||
requiresExternalUrl = false,
|
requiresExternalUrl = false,
|
||||||
tunnelEnabled = false,
|
|
||||||
tunnelPublicUrl = "",
|
|
||||||
tailscaleEnabled = false,
|
|
||||||
tailscaleUrl = "",
|
|
||||||
cloudEnabled = false,
|
cloudEnabled = false,
|
||||||
cloudUrl = "",
|
cloudUrl = "",
|
||||||
withV1 = true,
|
withV1 = true,
|
||||||
@@ -74,8 +62,8 @@ export default function BaseUrlSelect({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => buildOptions({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
|
() => buildOptions({ appUrl, requiresExternalUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
|
||||||
[appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
|
[appUrl, requiresExternalUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveMode = useMemo(() => {
|
const effectiveMode = useMemo(() => {
|
||||||
|
|||||||
@@ -173,10 +173,6 @@ export default function ConfigGeneratorCard({
|
|||||||
activeProviders,
|
activeProviders,
|
||||||
availableModels = [],
|
availableModels = [],
|
||||||
cloudEnabled,
|
cloudEnabled,
|
||||||
tunnelEnabled,
|
|
||||||
tunnelPublicUrl,
|
|
||||||
tailscaleEnabled,
|
|
||||||
tailscaleUrl,
|
|
||||||
initialConfig,
|
initialConfig,
|
||||||
onSaveConfig,
|
onSaveConfig,
|
||||||
}) {
|
}) {
|
||||||
@@ -366,10 +362,6 @@ export default function ConfigGeneratorCard({
|
|||||||
value={customBaseUrl}
|
value={customBaseUrl}
|
||||||
onChange={setCustomBaseUrl}
|
onChange={setCustomBaseUrl}
|
||||||
appUrl={baseUrl}
|
appUrl={baseUrl}
|
||||||
tunnelEnabled={tunnelEnabled}
|
|
||||||
tunnelPublicUrl={tunnelPublicUrl}
|
|
||||||
tailscaleEnabled={tailscaleEnabled}
|
|
||||||
tailscaleUrl={tailscaleUrl}
|
|
||||||
cloudEnabled={cloudEnabled}
|
cloudEnabled={cloudEnabled}
|
||||||
cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL}
|
cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import ApiKeySelect from "./ApiKeySelect";
|
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 [copiedField, setCopiedField] = useState(null);
|
||||||
const [showModelModal, setShowModelModal] = useState(false);
|
const [showModelModal, setShowModelModal] = useState(false);
|
||||||
const [modelValue, setModelValue] = useState("");
|
const [modelValue, setModelValue] = useState("");
|
||||||
@@ -184,11 +184,11 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 mb-4">
|
<div className="flex flex-col gap-2 mb-4">
|
||||||
{tool.notes.map((note, index) => {
|
{tool.notes.map((note, index) => {
|
||||||
// Skip cloudCheck note if tunnel or cloud is enabled
|
// Skip cloudCheck note if the cloud endpoint is enabled.
|
||||||
if (note.type === "cloudCheck" && (cloudEnabled || tunnelEnabled)) return null;
|
if (note.type === "cloudCheck" && cloudEnabled) return null;
|
||||||
|
|
||||||
const isWarning = note.type === "warning";
|
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 bgClass = "bg-blue-500/10 border-blue-500/30";
|
||||||
let textClass = "text-blue-600 dark:text-blue-400";
|
let textClass = "text-blue-600 dark:text-blue-400";
|
||||||
@@ -219,7 +219,7 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canShowGuide = () => {
|
const canShowGuide = () => {
|
||||||
if (tool.requiresExternalUrl && !cloudEnabled && !tunnelEnabled) return false;
|
if (tool.requiresExternalUrl && !cloudEnabled) return false;
|
||||||
if (tool.requiresCloud && !cloudEnabled) return false;
|
if (tool.requiresCloud && !cloudEnabled) return false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Input } from "@/shared/components";
|
import { Input } from "@/shared/components";
|
||||||
|
import { cn } from "@/shared/utils/cn";
|
||||||
|
|
||||||
/** Reusable endpoint row component */
|
/** 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 (
|
return (
|
||||||
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-border-subtle bg-surface-2/40 p-2.5">
|
<div className={cn("grid grid-cols-[5.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-border-subtle bg-surface-2/40 p-2.5", className)}>
|
||||||
<span className={`rounded-md px-2 py-1 text-center font-mono text-[11px] font-medium tracking-wide ${
|
<span className={`rounded-md px-2 py-1 text-center font-mono text-[11px] font-medium tracking-wide ${
|
||||||
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||||
}`}>{label}</span>
|
}`}>{label}</span>
|
||||||
<Input value={url} readOnly className="min-w-0 font-mono text-sm" />
|
<Input value={url} readOnly className="min-w-0 font-mono text-sm" />
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onCopy(url, copyId)}
|
onClick={() => onCopy(url, copyId)}
|
||||||
className="grid size-9 place-items-center rounded-lg text-text-muted transition-colors hover:bg-primary/10 hover:text-primary"
|
className="grid size-9 place-items-center rounded-lg text-text-muted transition-colors hover:bg-primary/10 hover:text-primary"
|
||||||
title={copied === copyId ? "Copied" : "Copy endpoint"}
|
title={copied === copyId ? "Copied" : "Copy endpoint"}
|
||||||
|
aria-label={copied === copyId ? "Endpoint copied" : `Copy ${label} endpoint`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
|
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
export default function Tooltip({ text }) {
|
export default function Tooltip({ text }) {
|
||||||
return (
|
return (
|
||||||
<span className="relative group inline-flex items-center">
|
<span className="relative group inline-flex items-center">
|
||||||
<span className="material-symbols-outlined text-[14px] text-text-muted cursor-help">help</span>
|
<button
|
||||||
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
type="button"
|
||||||
|
className="material-symbols-outlined text-[14px] text-text-muted cursor-help rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||||
|
aria-label={text}
|
||||||
|
>
|
||||||
|
help
|
||||||
|
</button>
|
||||||
|
<span role="tooltip" className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity shadow-lg">
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
export const WENYAN_LOCALES = ["zh-CN", "zh-TW"];
|
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 = [
|
export const CAVEMAN_LEVELS = [
|
||||||
{ id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
|
{ id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
|
||||||
{ id: "full", label: "Full", desc: "Drop articles, fragments OK" },
|
{ id: "full", label: "Full", desc: "Drop articles, fragments OK" },
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { CLIENT_PING_TIMEOUT_MS } from "./endpointConstants";
|
|
||||||
|
|
||||||
// Browser-side health probe: must reach origin (not just CF/TS edge).
|
|
||||||
// cors mode → res.ok=false for 5xx (e.g. Cloudflare 530 when origin dead).
|
|
||||||
// /api/health route sets Access-Control-Allow-Origin: * → CORS works through tunnel.
|
|
||||||
export async function clientPingUrl(url) {
|
|
||||||
if (!url) return false;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${url}/api/health`, {
|
|
||||||
mode: "cors",
|
|
||||||
cache: "no-store",
|
|
||||||
signal: AbortSignal.timeout(CLIENT_PING_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
return res.ok;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Race multiple URLs: resolve true as soon as any one passes ping.
|
|
||||||
export async function clientPingAny(...urls) {
|
|
||||||
const checks = urls.filter(Boolean).map(clientPingUrl);
|
|
||||||
if (!checks.length) return false;
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let pending = checks.length;
|
|
||||||
checks.forEach((p) => p.then((ok) => {
|
|
||||||
if (ok) resolve(true);
|
|
||||||
else if (--pending === 0) resolve(false);
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { getMachineId } from "@/shared/utils/machine";
|
|
||||||
import { getCurrentDashboardUser } from "@/lib/auth/currentUser";
|
|
||||||
import EndpointPageClient from "./EndpointPageClient";
|
|
||||||
|
|
||||||
export default async function EndpointPage() {
|
|
||||||
const [machineId, user] = await Promise.all([getMachineId(), getCurrentDashboardUser()]);
|
|
||||||
return <EndpointPageClient machineId={machineId} isAdmin={user?.role !== "user"} />;
|
|
||||||
}
|
|
||||||
+2
-21
@@ -27,9 +27,7 @@ export function EmbeddingExampleCard({ providerId, customAlias }) {
|
|||||||
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
|
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
|
||||||
const [dimensions, setDimensions] = useState("");
|
const [dimensions, setDimensions] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [useTunnel, setUseTunnel] = useState(false);
|
|
||||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -42,13 +40,9 @@ export function EmbeddingExampleCard({ providerId, customAlias }) {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||||
.catch(() => {});
|
.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}` : "";
|
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
||||||
|
|
||||||
// Build request body — include dimensions only if user provided a positive number
|
// Build request body — include dimensions only if user provided a positive number
|
||||||
@@ -135,23 +129,10 @@ export function EmbeddingExampleCard({ providerId, customAlias }) {
|
|||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||||
<input
|
<input
|
||||||
value={endpoint}
|
value={endpoint}
|
||||||
onChange={(e) => useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
|
onChange={(e) => setLocalEndpoint(e.target.value)}
|
||||||
className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
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"
|
placeholder="http://localhost:3000"
|
||||||
/>
|
/>
|
||||||
{/* Tunnel toggle — only show if tunnel URL is available */}
|
|
||||||
{tunnelEndpoint && (
|
|
||||||
<button
|
|
||||||
onClick={() => setUseTunnel((v) => !v)}
|
|
||||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
|
||||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
|
||||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
|
||||||
Tunnel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|||||||
+1
-19
@@ -54,9 +54,7 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
(safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
|
(safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
|
||||||
);
|
);
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [useTunnel, setUseTunnel] = useState(false);
|
|
||||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [progress, setProgress] = useState(null); // { stage, bytesReceived }
|
const [progress, setProgress] = useState(null); // { stage, bytesReceived }
|
||||||
const [partialImage, setPartialImage] = useState(null);
|
const [partialImage, setPartialImage] = useState(null);
|
||||||
@@ -75,10 +73,6 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||||
.catch(() => {});
|
.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
|
// Load active connections of this provider for pinning
|
||||||
fetch("/api/providers/client")
|
fetch("/api/providers/client")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -92,7 +86,7 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
// Safe to early-return now that all hooks are declared
|
// Safe to early-return now that all hooks are declared
|
||||||
if (!kindConfig || !exConfig) return null;
|
if (!kindConfig || !exConfig) return null;
|
||||||
|
|
||||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
const endpoint = localEndpoint;
|
||||||
const apiPath = kindConfig.endpoint.path;
|
const apiPath = kindConfig.endpoint.path;
|
||||||
// webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present.
|
// webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present.
|
||||||
const modelFull = !needsModel
|
const modelFull = !needsModel
|
||||||
@@ -257,18 +251,6 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||||
{endpoint}{apiPath}
|
{endpoint}{apiPath}
|
||||||
</span>
|
</span>
|
||||||
{tunnelEndpoint && (
|
|
||||||
<button
|
|
||||||
onClick={() => setUseTunnel((v) => !v)}
|
|
||||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
|
||||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
|
||||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
|
||||||
Tunnel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|||||||
+1
-19
@@ -24,9 +24,7 @@ export function SttExampleCard({ providerId }) {
|
|||||||
const [responseFormat, setResponseFormat] = useState("json");
|
const [responseFormat, setResponseFormat] = useState("json");
|
||||||
const [temperature, setTemperature] = useState("");
|
const [temperature, setTemperature] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [useTunnel, setUseTunnel] = useState(false);
|
|
||||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [latency, setLatency] = useState(null);
|
const [latency, setLatency] = useState(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
@@ -40,10 +38,6 @@ export function SttExampleCard({ providerId }) {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
fetch("/api/tunnel/status")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
|
||||||
.catch(() => {});
|
|
||||||
const loadCustom = () => {
|
const loadCustom = () => {
|
||||||
fetch("/api/models/custom", { cache: "no-store" })
|
fetch("/api/models/custom", { cache: "no-store" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -62,7 +56,7 @@ export function SttExampleCard({ providerId }) {
|
|||||||
};
|
};
|
||||||
}, [providerAlias]);
|
}, [providerAlias]);
|
||||||
|
|
||||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
const endpoint = localEndpoint;
|
||||||
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
||||||
|
|
||||||
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\
|
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\
|
||||||
@@ -139,18 +133,6 @@ export function SttExampleCard({ providerId }) {
|
|||||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||||
{endpoint}/v1/audio/transcriptions
|
{endpoint}/v1/audio/transcriptions
|
||||||
</span>
|
</span>
|
||||||
{tunnelEndpoint && (
|
|
||||||
<button
|
|
||||||
onClick={() => setUseTunnel((v) => !v)}
|
|
||||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
|
||||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
|
||||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
|
||||||
Tunnel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|||||||
+2
-21
@@ -41,9 +41,7 @@ export function TtsExampleCard({ providerId }) {
|
|||||||
// Form state
|
// Form state
|
||||||
const [input, setInput] = useState("Hello, this is a text to speech test.");
|
const [input, setInput] = useState("Hello, this is a text to speech test.");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [useTunnel, setUseTunnel] = useState(false);
|
|
||||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
|
||||||
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
|
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
|
||||||
const [audioUrl, setAudioUrl] = useState("");
|
const [audioUrl, setAudioUrl] = useState("");
|
||||||
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
|
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
|
||||||
@@ -68,11 +66,6 @@ export function TtsExampleCard({ providerId }) {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||||
.catch(() => {});
|
.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
|
// Pre-select default voice based on provider config
|
||||||
if (config.voiceSource === "hardcoded") {
|
if (config.voiceSource === "hardcoded") {
|
||||||
const defaultModel = config.hasModelSelector && config.modelKey
|
const defaultModel = config.hasModelSelector && config.modelKey
|
||||||
@@ -171,7 +164,7 @@ export function TtsExampleCard({ providerId }) {
|
|||||||
)
|
)
|
||||||
: languages;
|
: languages;
|
||||||
|
|
||||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
const endpoint = localEndpoint;
|
||||||
// For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice
|
// For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice
|
||||||
const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice;
|
const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice;
|
||||||
const modelFull = (() => {
|
const modelFull = (() => {
|
||||||
@@ -243,18 +236,6 @@ export function TtsExampleCard({ providerId }) {
|
|||||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||||
{endpoint}/v1/audio/speech
|
{endpoint}/v1/audio/speech
|
||||||
</span>
|
</span>
|
||||||
{tunnelEndpoint && (
|
|
||||||
<button
|
|
||||||
onClick={() => setUseTunnel((v) => !v)}
|
|
||||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
|
||||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
|
||||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
|
||||||
Tunnel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="API Key">
|
<Row label="API Key">
|
||||||
@@ -457,7 +438,7 @@ export function TtsExampleCard({ providerId }) {
|
|||||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
{error && <p className="text-xs text-red-500 wrap-break-word">{error}</p>}
|
||||||
|
|
||||||
{/* Audio player */}
|
{/* Audio player */}
|
||||||
{audioUrl ? (
|
{audioUrl ? (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getMachineId } from "@/shared/utils/machine";
|
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() {
|
export default async function DashboardPage() {
|
||||||
const machineId = await getMachineId();
|
const user = await getCurrentDashboardUser();
|
||||||
return <EndpointPageClient machineId={machineId} />;
|
return <DashboardPageClient isAdmin={user?.role === "admin"} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default.";
|
||||||
const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
|
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) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const ip = getClientIp(request);
|
const ip = getClientIp(request);
|
||||||
@@ -31,11 +24,6 @@ export async function POST(request) {
|
|||||||
const { username, password } = await request.json();
|
const { username, password } = await request.json();
|
||||||
const settings = await getSettings();
|
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)) {
|
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
|
||||||
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export async function PATCH(request) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(body, "requireApiKey") ||
|
Object.prototype.hasOwnProperty.call(body, "requireApiKey") ||
|
||||||
Object.prototype.hasOwnProperty.call(body, "tunnelDashboardAccess") ||
|
|
||||||
TOKEN_SAVER_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key))
|
TOKEN_SAVER_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key))
|
||||||
) {
|
) {
|
||||||
let user;
|
let user;
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getTunnelStatus, getTailscaleStatus, getDownloadStatus } from "@/lib/tunnel";
|
|
||||||
|
|
||||||
const STATUS_CACHE_TTL_MS = 3000; // coalesce rapid polls; underlying probes already cache 10s
|
|
||||||
|
|
||||||
// Survive hot reload; one cache per process. Only tunnel/tailscale probes are cached —
|
|
||||||
// download progress stays live so the enable/download UI updates smoothly.
|
|
||||||
const statusCache = (global.__tunnelStatusCache ??= { value: null, fetchedAt: 0 });
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
let probes = statusCache.value;
|
|
||||||
if (!probes || Date.now() - statusCache.fetchedAt >= STATUS_CACHE_TTL_MS) {
|
|
||||||
const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
|
|
||||||
probes = { tunnel, tailscale };
|
|
||||||
statusCache.value = probes;
|
|
||||||
statusCache.fetchedAt = Date.now();
|
|
||||||
}
|
|
||||||
const download = getDownloadStatus();
|
|
||||||
return NextResponse.json({ ...probes, download });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Tunnel status error:", error);
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import os from "os";
|
|
||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { isTailscaleInstalled, isTailscaleLoggedIn, isSystemDaemonRunning, getTailscaleBin, TAILSCALE_SOCKET } from "@/lib/tunnel";
|
|
||||||
import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager";
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`;
|
|
||||||
const PROBE_TIMEOUT_MS = 1500;
|
|
||||||
|
|
||||||
async function hasBrew() {
|
|
||||||
try {
|
|
||||||
await execAsync("which brew", { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS });
|
|
||||||
return true;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isCustomDaemonRunning() {
|
|
||||||
const bin = getTailscaleBin();
|
|
||||||
if (!bin) return false;
|
|
||||||
try {
|
|
||||||
await execAsync(`"${bin}" --socket ${TAILSCALE_SOCKET} status --json`, {
|
|
||||||
windowsHide: true,
|
|
||||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
|
||||||
timeout: PROBE_TIMEOUT_MS
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
await execAsync("pgrep -x tailscaled", { windowsHide: true, timeout: PROBE_TIMEOUT_MS });
|
|
||||||
return true;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const installed = isTailscaleInstalled();
|
|
||||||
const platform = os.platform();
|
|
||||||
// Run independent probes in parallel — none blocks the event loop
|
|
||||||
const [brewAvailable, customDaemonRunning, systemDaemonRunning] = await Promise.all([
|
|
||||||
platform === "darwin" ? hasBrew() : Promise.resolve(false),
|
|
||||||
installed ? isCustomDaemonRunning() : Promise.resolve(false),
|
|
||||||
installed ? Promise.resolve(isSystemDaemonRunning()) : Promise.resolve(false),
|
|
||||||
]);
|
|
||||||
const daemonRunning = customDaemonRunning || systemDaemonRunning;
|
|
||||||
const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
|
|
||||||
const hasCachedPassword = !!(getCachedPassword() || await loadEncryptedPassword());
|
|
||||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning, customDaemonRunning, systemDaemonRunning, hasCachedPassword });
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { disableTailscale } from "@/lib/tunnel";
|
|
||||||
import { getSettings } from "@/lib/localDb";
|
|
||||||
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const result = await disableTailscale();
|
|
||||||
getSettings()
|
|
||||||
.then(configureTunnelMonitoring)
|
|
||||||
.catch((error) => console.warn("Tailscale monitor update failed:", error.message));
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Tailscale disable error:", error);
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { enableTailscale } from "@/lib/tunnel";
|
|
||||||
import { getSettings } from "@/lib/localDb";
|
|
||||||
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const result = await enableTailscale();
|
|
||||||
getSettings()
|
|
||||||
.then(configureTunnelMonitoring)
|
|
||||||
.catch((error) => console.warn("Tailscale monitor start failed:", error.message));
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Tailscale enable error:", error.message);
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import os from "os";
|
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { installTailscale, loadState, generateShortId } from "@/lib/tunnel";
|
|
||||||
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
|
|
||||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
|
||||||
|
|
||||||
initDbHooks(getSettings, updateSettings);
|
|
||||||
|
|
||||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
|
|
||||||
|
|
||||||
function hasBrew() {
|
|
||||||
try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request) {
|
|
||||||
const body = await request.json().catch(() => ({}));
|
|
||||||
const platform = os.platform();
|
|
||||||
const isWindows = platform === "win32";
|
|
||||||
const isBrew = platform === "darwin" && hasBrew();
|
|
||||||
const needsPassword = !isWindows && !isBrew;
|
|
||||||
|
|
||||||
const sudoPassword = body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || "";
|
|
||||||
|
|
||||||
if (needsPassword && !sudoPassword.trim()) {
|
|
||||||
return new Response(JSON.stringify({ error: "Sudo password is required" }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortId = loadState()?.shortId || generateShortId();
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
async start(controller) {
|
|
||||||
let closed = false;
|
|
||||||
const send = (event, data) => {
|
|
||||||
if (closed) return;
|
|
||||||
try {
|
|
||||||
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
||||||
} catch {
|
|
||||||
closed = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await installTailscale(sudoPassword, shortId, (msg) => {
|
|
||||||
send("progress", { message: msg });
|
|
||||||
});
|
|
||||||
send("done", { success: true, authUrl: result?.authUrl || null });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Tailscale install error:", error);
|
|
||||||
const msg = error.message?.includes("incorrect password") || error.message?.includes("Sorry")
|
|
||||||
? "Wrong sudo password"
|
|
||||||
: error.message;
|
|
||||||
send("error", { error: msg });
|
|
||||||
} finally {
|
|
||||||
if (!closed) { try { controller.close(); } catch {} }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+94
-95
@@ -8,43 +8,42 @@
|
|||||||
.fonts-loaded .material-symbols-outlined { visibility: visible; }
|
.fonts-loaded .material-symbols-outlined { visibility: visible; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
9Router palette — adopted from 9remote_private/web
|
9Router palette — cool slate control plane with coral accent
|
||||||
Brand orange (dark) / soft coral (light), neutral warm bases
|
============================================================ */
|
||||||
============================================================ */
|
|
||||||
:root {
|
:root {
|
||||||
/* Brand scale (light) - centered on #E56A4A */
|
/* Brand scale (light) — coral is reserved for primary actions and emphasis. */
|
||||||
--color-brand-50: #fdf1ed;
|
--color-brand-50: #fff1ed;
|
||||||
--color-brand-100: #fadccf;
|
--color-brand-100: #ffdfd5;
|
||||||
--color-brand-200: #f4b59c;
|
--color-brand-200: #ffc0ad;
|
||||||
--color-brand-300: #ee8d6a;
|
--color-brand-300: #ff9a7d;
|
||||||
--color-brand-400: #ea7855;
|
--color-brand-400: #fb7759;
|
||||||
--color-brand-500: #E56A4A;
|
--color-brand-500: #ed6548;
|
||||||
--color-brand-600: #cc5236;
|
--color-brand-600: #d84c31;
|
||||||
--color-brand-700: #a64027;
|
--color-brand-700: #b33c27;
|
||||||
--color-brand-800: #7a2f1d;
|
--color-brand-800: #8f3324;
|
||||||
--color-brand-900: #4d1e12;
|
--color-brand-900: #762f24;
|
||||||
|
|
||||||
/* Primary (legacy alias for backward compat with existing components) */
|
/* Primary (legacy alias for backward compat with existing components) */
|
||||||
--color-primary: var(--color-brand-500);
|
--color-primary: var(--color-brand-500);
|
||||||
--color-primary-hover: var(--color-brand-600);
|
--color-primary-hover: var(--color-brand-600);
|
||||||
|
|
||||||
/* Surfaces & backgrounds (light) */
|
/* Surfaces & backgrounds (light) — a cool neutral family. */
|
||||||
--color-bg: #FDFAF6;
|
--color-bg: #f7f8fa;
|
||||||
--color-bg-alt: #F7F3EE;
|
--color-bg-alt: #eef1f5;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-surface-2: #f4f4f5;
|
--color-surface-2: #f1f3f6;
|
||||||
--color-surface-3: #e7e7e9;
|
--color-surface-3: #e4e8ed;
|
||||||
--color-sidebar: rgba(244, 241, 236, 0.85);
|
--color-sidebar: rgba(247, 248, 250, 0.86);
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--color-border: #e5e7eb;
|
--color-border: #dce2e9;
|
||||||
--color-border-subtle: #f1f1f3;
|
--color-border-subtle: #e9edf2;
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--color-text: #0a0a0a;
|
--color-text: #171d27;
|
||||||
--color-text-main: #0a0a0a;
|
--color-text-main: #171d27;
|
||||||
--color-text-muted: #6B7280;
|
--color-text-muted: #657084;
|
||||||
--color-text-subtle: #9CA3AF;
|
--color-text-subtle: #98a2b3;
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--color-danger: #cf222e;
|
--color-danger: #cf222e;
|
||||||
@@ -57,63 +56,63 @@
|
|||||||
--radius-brand-lg: 14px;
|
--radius-brand-lg: 14px;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.04);
|
--shadow-soft: 0 1px 2px 0 rgba(32, 44, 62, 0.05);
|
||||||
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.18);
|
--shadow-warm: 0 8px 18px -12px rgba(216, 76, 49, 0.42);
|
||||||
--shadow-elevated: 0 12px 28px -4px rgba(60, 50, 45, 0.06);
|
--shadow-elevated: 0 14px 34px -14px rgba(32, 44, 62, 0.16);
|
||||||
--shadow-elev:
|
--shadow-elev:
|
||||||
inset 0 1px 0 0 rgba(255,255,255,0.8),
|
inset 0 1px 0 0 rgba(255,255,255,0.88),
|
||||||
0 1px 2px rgba(15,23,42,0.04),
|
0 1px 2px rgba(32, 44, 62, 0.05),
|
||||||
0 12px 36px -8px rgba(15,23,42,0.10);
|
0 14px 36px -14px rgba(32, 44, 62, 0.16);
|
||||||
--shadow-focus: 0 0 0 3px rgba(229,106,74,0.18);
|
--shadow-focus: 0 0 0 3px rgba(237, 101, 72, 0.2);
|
||||||
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* Brand scale (dark) - centered on #E56A4A, same as light for consistency */
|
/* Same brand hue, calibrated for a dark developer control plane. */
|
||||||
--color-brand-50: #fdf1ed;
|
--color-brand-50: #fff1ed;
|
||||||
--color-brand-100: #fadccf;
|
--color-brand-100: #ffdfd5;
|
||||||
--color-brand-200: #f4b59c;
|
--color-brand-200: #ffc0ad;
|
||||||
--color-brand-300: #ee8d6a;
|
--color-brand-300: #ffad96;
|
||||||
--color-brand-400: #ea7855;
|
--color-brand-400: #fb8569;
|
||||||
--color-brand-500: #E56A4A;
|
--color-brand-500: #f17052;
|
||||||
--color-brand-600: #cc5236;
|
--color-brand-600: #dc563a;
|
||||||
--color-brand-700: #a64027;
|
--color-brand-700: #b94530;
|
||||||
--color-brand-800: #7a2f1d;
|
--color-brand-800: #913829;
|
||||||
--color-brand-900: #4d1e12;
|
--color-brand-900: #773025;
|
||||||
|
|
||||||
--color-primary: #E56A4A;
|
--color-primary: var(--color-brand-500);
|
||||||
--color-primary-hover: #cc5236;
|
--color-primary-hover: var(--color-brand-400);
|
||||||
|
|
||||||
/* Surfaces (dark - Claude-like neutral warm) */
|
/* Surfaces (dark) — all greys share a restrained blue-slate undertone. */
|
||||||
--color-bg: #1a1a1a;
|
--color-bg: #0f1115;
|
||||||
--color-bg-alt: #1F1F1E;
|
--color-bg-alt: #141820;
|
||||||
--color-surface: #262626;
|
--color-surface: #191e26;
|
||||||
--color-surface-2: #303030;
|
--color-surface-2: #212833;
|
||||||
--color-surface-3: #3a3a3a;
|
--color-surface-3: #2a3340;
|
||||||
--color-sidebar: rgba(30, 30, 30, 0.85);
|
--color-sidebar: rgba(15, 17, 21, 0.88);
|
||||||
|
|
||||||
--color-border: #333333;
|
--color-border: #2d3744;
|
||||||
--color-border-subtle: #2a2a2a;
|
--color-border-subtle: #222b36;
|
||||||
|
|
||||||
--color-text: #ededed;
|
--color-text: #f1f5f9;
|
||||||
--color-text-main: #ededed;
|
--color-text-main: #f1f5f9;
|
||||||
--color-text-muted: #9ca3af;
|
--color-text-muted: #a5b0bf;
|
||||||
--color-text-subtle: #6b7280;
|
--color-text-subtle: #708096;
|
||||||
|
|
||||||
--color-danger: #ef4444;
|
--color-danger: #ef4444;
|
||||||
--color-success: #22c55e;
|
--color-success: #22c55e;
|
||||||
--color-warning: #fbbf24;
|
--color-warning: #fbbf24;
|
||||||
--color-info: #60a5fa;
|
--color-info: #60a5fa;
|
||||||
|
|
||||||
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.3);
|
--shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.28);
|
||||||
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.25);
|
--shadow-warm: 0 10px 22px -15px rgba(241, 112, 82, 0.5);
|
||||||
--shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.45);
|
--shadow-elevated: 0 18px 38px -18px rgba(0, 0, 0, 0.62);
|
||||||
--shadow-elev:
|
--shadow-elev:
|
||||||
inset 0 1px 0 0 rgba(255,255,255,0.06),
|
inset 0 1px 0 0 rgba(255,255,255,0.05),
|
||||||
0 1px 2px rgba(0,0,0,0.4),
|
0 1px 2px rgba(0,0,0,0.42),
|
||||||
0 16px 48px -8px rgba(0,0,0,0.55);
|
0 18px 44px -18px rgba(0,0,0,0.64);
|
||||||
--shadow-focus: 0 0 0 3px rgba(229, 106, 74, 0.18);
|
--shadow-focus: 0 0 0 3px rgba(241, 112, 82, 0.22);
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@@ -171,18 +170,18 @@ select {
|
|||||||
--color-info: var(--color-info);
|
--color-info: var(--color-info);
|
||||||
|
|
||||||
/* Static fallbacks (explicit per-mode usage if needed) */
|
/* Static fallbacks (explicit per-mode usage if needed) */
|
||||||
--color-bg-light: #FCFBF9;
|
--color-bg-light: #f7f8fa;
|
||||||
--color-bg-dark: #1a1a1a;
|
--color-bg-dark: #0f1115;
|
||||||
--color-surface-light: #ffffff;
|
--color-surface-light: #ffffff;
|
||||||
--color-surface-dark: #262626;
|
--color-surface-dark: #191e26;
|
||||||
--color-sidebar-light: #F4F1EC;
|
--color-sidebar-light: #f7f8fa;
|
||||||
--color-sidebar-dark: #1F1F1E;
|
--color-sidebar-dark: #0f1115;
|
||||||
--color-border-light: #e5e7eb;
|
--color-border-light: #dce2e9;
|
||||||
--color-border-dark: #333333;
|
--color-border-dark: #2d3744;
|
||||||
--color-text-main-light: #0a0a0a;
|
--color-text-main-light: #171d27;
|
||||||
--color-text-main-dark: #ededed;
|
--color-text-main-dark: #f1f5f9;
|
||||||
--color-text-muted-light: #6B7280;
|
--color-text-muted-light: #657084;
|
||||||
--color-text-muted-dark: #9ca3af;
|
--color-text-muted-dark: #a5b0bf;
|
||||||
|
|
||||||
/* Radius */
|
/* Radius */
|
||||||
--radius-brand: var(--radius-brand);
|
--radius-brand: var(--radius-brand);
|
||||||
@@ -195,8 +194,8 @@ select {
|
|||||||
--shadow-elev: var(--shadow-elev);
|
--shadow-elev: var(--shadow-elev);
|
||||||
--shadow-focus: var(--shadow-focus);
|
--shadow-focus: var(--shadow-focus);
|
||||||
|
|
||||||
/* Font - Inter primary, Apple system fallback */
|
/* System UI keeps the operational surface crisp and fast to load. */
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI Variable', 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base */
|
/* Base */
|
||||||
@@ -210,11 +209,11 @@ body {
|
|||||||
|
|
||||||
/* Selection - brand-tinted */
|
/* Selection - brand-tinted */
|
||||||
::selection {
|
::selection {
|
||||||
background-color: rgba(229, 106, 74, 0.25);
|
background-color: rgba(237, 101, 72, 0.25);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.dark ::selection {
|
.dark ::selection {
|
||||||
background-color: rgba(229, 106, 74, 0.3);
|
background-color: rgba(241, 112, 82, 0.3);
|
||||||
color: var(--color-brand-300);
|
color: var(--color-brand-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,19 +240,19 @@ input, textarea {
|
|||||||
/* Thin horizontal scrollbar - brand colored */
|
/* Thin horizontal scrollbar - brand colored */
|
||||||
.scroll-thin-x {
|
.scroll-thin-x {
|
||||||
scrollbar-width: thin;
|
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 {
|
.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 { height: 3px; }
|
||||||
.scroll-thin-x::-webkit-scrollbar-track { background: transparent; }
|
.scroll-thin-x::-webkit-scrollbar-track { background: transparent; }
|
||||||
.scroll-thin-x::-webkit-scrollbar-thumb {
|
.scroll-thin-x::-webkit-scrollbar-thumb {
|
||||||
background: rgba(229, 106, 74, 0.55);
|
background: rgba(237, 101, 72, 0.55);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.dark .scroll-thin-x::-webkit-scrollbar-thumb {
|
.dark .scroll-thin-x::-webkit-scrollbar-thumb {
|
||||||
background: rgba(229, 106, 74, 0.55);
|
background: rgba(241, 112, 82, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reusable elevated card */
|
/* Reusable elevated card */
|
||||||
@@ -280,7 +279,7 @@ input, textarea {
|
|||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
.dark .bg-vibrancy {
|
.dark .bg-vibrancy {
|
||||||
background: rgba(38, 38, 38, 0.72);
|
background: rgba(15, 17, 21, 0.74);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* macOS Traffic Lights */
|
/* macOS Traffic Lights */
|
||||||
@@ -421,25 +420,25 @@ button:disabled,
|
|||||||
.dot-grid-bg {
|
.dot-grid-bg {
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.10) 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(229, 106, 74, 0.06) 0%, transparent 40%);
|
radial-gradient(circle at 85% 80%, rgba(237, 101, 72, 0.05) 0%, transparent 40%);
|
||||||
}
|
}
|
||||||
.dark .dot-grid-bg {
|
.dark .dot-grid-bg {
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.18) 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(229, 106, 74, 0.10) 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-style faint grid overlay (use absolute pos inside relative parent) */
|
||||||
.landing-grid {
|
.landing-grid {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, var(--color-accent) 1px, transparent 1px),
|
linear-gradient(to right, var(--color-border) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, var(--color-accent) 1px, transparent 1px);
|
linear-gradient(to bottom, var(--color-border) 1px, transparent 1px);
|
||||||
background-size: 40px 40px;
|
background-size: 44px 44px;
|
||||||
opacity: 0.08;
|
opacity: 0.28;
|
||||||
}
|
}
|
||||||
.dark .landing-grid {
|
.dark .landing-grid {
|
||||||
opacity: 0.04;
|
opacity: 0.16;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* React Flow controls: match app theme */
|
/* React Flow controls: match app theme */
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import "material-symbols/outlined.css";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
||||||
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
|
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 { initConsoleLogCapture } from "@/lib/consoleLogBuffer";
|
||||||
import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider";
|
import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider";
|
||||||
|
|
||||||
|
|||||||
+2
-40
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
import { getDashboardAuthSession, verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
import { getDashboardAuthSession, verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
||||||
|
|
||||||
@@ -47,7 +47,6 @@ const ADMIN_ONLY_PATHS = [
|
|||||||
"/api/providers",
|
"/api/providers",
|
||||||
"/api/provider-nodes",
|
"/api/provider-nodes",
|
||||||
"/api/oauth",
|
"/api/oauth",
|
||||||
"/api/tunnel",
|
|
||||||
"/api/headroom",
|
"/api/headroom",
|
||||||
"/api/pxpipe",
|
"/api/pxpipe",
|
||||||
"/api/media-providers",
|
"/api/media-providers",
|
||||||
@@ -83,18 +82,11 @@ const PROTECTED_API_PATHS = [
|
|||||||
"/api/tags",
|
"/api/tags",
|
||||||
"/api/mcp",
|
"/api/mcp",
|
||||||
"/api/translator",
|
"/api/translator",
|
||||||
"/api/tunnel",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Routes that spawn child processes or read host secrets — restrict to localhost.
|
// Routes that spawn child processes or read host secrets — restrict to localhost.
|
||||||
const LOCAL_ONLY_PATHS = [
|
const LOCAL_ONLY_PATHS = [
|
||||||
"/api/mcp/",
|
"/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/cursor/auto-import",
|
||||||
"/api/oauth/kiro/auto-import",
|
"/api/oauth/kiro/auto-import",
|
||||||
"/api/auth/reset-password",
|
"/api/auth/reset-password",
|
||||||
@@ -160,7 +152,7 @@ async function canAccessPublicLlmApi(request) {
|
|||||||
|
|
||||||
async function canAccessLocalOnlyRoute(request) {
|
async function canAccessLocalOnlyRoute(request) {
|
||||||
if (await hasValidCliToken(request)) return true;
|
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;
|
if (isLocalRequest(request) && await isAuthenticated(request)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -188,15 +180,6 @@ async function isAdmin(request) {
|
|||||||
return user?.isActive === true && user.role === "admin";
|
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) {
|
async function isAuthenticated(request) {
|
||||||
return hasValidToken(request);
|
return hasValidToken(request);
|
||||||
}
|
}
|
||||||
@@ -270,27 +253,6 @@ export async function proxy(request) {
|
|||||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
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
|
// Verify JWT token
|
||||||
const token = request.cookies.get("auth_token")?.value;
|
const token = request.cookies.get("auth_token")?.value;
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const REMOVED_SETTING_KEYS = [
|
||||||
|
"tunnelEnabled",
|
||||||
|
"tunnelUrl",
|
||||||
|
"tunnelProvider",
|
||||||
|
"tailscaleEnabled",
|
||||||
|
"tailscaleUrl",
|
||||||
|
"tunnelDashboardAccess",
|
||||||
|
];
|
||||||
|
|
||||||
|
const removeTunnelSettingsMigration = {
|
||||||
|
version: 9,
|
||||||
|
name: "remove-tunnel-settings",
|
||||||
|
up(db) {
|
||||||
|
const row = db.get("SELECT data FROM settings WHERE id = 1");
|
||||||
|
if (!row?.data) return;
|
||||||
|
|
||||||
|
let settings;
|
||||||
|
try {
|
||||||
|
settings = JSON.parse(row.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const key of REMOVED_SETTING_KEYS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(settings, key)) {
|
||||||
|
delete settings[key];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
db.run("UPDATE settings SET data = ? WHERE id = 1", [JSON.stringify(settings)]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default removeTunnelSettingsMigration;
|
||||||
@@ -9,8 +9,9 @@ import m005 from "./005-usage-user-attribution.js";
|
|||||||
import m006 from "./006-combo-owners.js";
|
import m006 from "./006-combo-owners.js";
|
||||||
import m007 from "./007-admin-provider-connections.js";
|
import m007 from "./007-admin-provider-connections.js";
|
||||||
import m008 from "./008-user-token-limits.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() {
|
export function latestVersion() {
|
||||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
|||||||
|
|
||||||
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
||||||
const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
|
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 = {
|
const DEFAULT_SETTINGS = {
|
||||||
cloudEnabled: false,
|
cloudEnabled: false,
|
||||||
tunnelEnabled: false,
|
|
||||||
tunnelUrl: "",
|
|
||||||
tunnelProvider: "cloudflare",
|
|
||||||
tailscaleEnabled: false,
|
|
||||||
tailscaleUrl: "",
|
|
||||||
stickyRoundRobinLimit: 3,
|
stickyRoundRobinLimit: 3,
|
||||||
providerStrategies: {},
|
providerStrategies: {},
|
||||||
quotaVisibility: {},
|
quotaVisibility: {},
|
||||||
comboStrategy: "fallback",
|
comboStrategy: "fallback",
|
||||||
comboStickyRoundRobinLimit: 1,
|
comboStickyRoundRobinLimit: 1,
|
||||||
comboStrategies: {},
|
comboStrategies: {},
|
||||||
tunnelDashboardAccess: true,
|
|
||||||
authMode: "password",
|
authMode: "password",
|
||||||
oidcIssuerUrl: "",
|
oidcIssuerUrl: "",
|
||||||
oidcClientId: "",
|
oidcClientId: "",
|
||||||
@@ -51,12 +53,18 @@ const DEFAULT_SETTINGS = {
|
|||||||
async function readRaw() {
|
async function readRaw() {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
|
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
|
// Merge raw settings with defaults; backward-compat for missing keys
|
||||||
function mergeWithDefaults(raw) {
|
function mergeWithDefaults(raw) {
|
||||||
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
|
const merged = { ...DEFAULT_SETTINGS, ...removeTunnelSettings(raw) };
|
||||||
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
|
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
|
||||||
if (merged[key] === undefined) {
|
if (merged[key] === undefined) {
|
||||||
if (
|
if (
|
||||||
@@ -84,8 +92,8 @@ export async function updateSettings(updates) {
|
|||||||
let next;
|
let next;
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
|
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
|
||||||
const current = row ? parseJson(row.data, {}) : {};
|
const current = removeTunnelSettings(row ? parseJson(row.data, {}) : {});
|
||||||
next = { ...current, ...updates };
|
next = removeTunnelSettings({ ...current, ...updates });
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
|
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
|
||||||
[stringifyJson(next)]
|
[stringifyJson(next)]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
// 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.
|
// 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 = `
|
export const PRAGMA_SQL = `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 */ }
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 */ }
|
|
||||||
}
|
|
||||||
@@ -139,13 +139,6 @@ const getPageInfo = (pathname) => {
|
|||||||
icon: "extension",
|
icon: "extension",
|
||||||
breadcrumbs: [],
|
breadcrumbs: [],
|
||||||
};
|
};
|
||||||
if (pathname.includes("/endpoint"))
|
|
||||||
return {
|
|
||||||
title: "Endpoint",
|
|
||||||
description: "API endpoint configuration",
|
|
||||||
icon: "api",
|
|
||||||
breadcrumbs: [],
|
|
||||||
};
|
|
||||||
if (pathname.includes("/profile"))
|
if (pathname.includes("/profile"))
|
||||||
return {
|
return {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -224,7 +217,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2 border-b border-border-subtle bg-surface/60 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20">
|
<header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2.5 border-b border-border-subtle bg-bg/88 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20">
|
||||||
{/* Mobile menu button */}
|
{/* Mobile menu button */}
|
||||||
<div className="flex items-center gap-3 lg:hidden shrink-0">
|
<div className="flex items-center gap-3 lg:hidden shrink-0">
|
||||||
{showMenuButton && (
|
{showMenuButton && (
|
||||||
@@ -265,7 +258,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
|||||||
src={crumb.image}
|
src={crumb.image}
|
||||||
alt={crumb.label}
|
alt={crumb.label}
|
||||||
size={28}
|
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()}
|
fallbackText={crumb.label.slice(0, 2).toUpperCase()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -301,7 +294,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
|||||||
{/* Right actions */}
|
{/* Right actions */}
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{displayName && loginMethod === "OIDC" && (
|
{displayName && loginMethod === "OIDC" && (
|
||||||
<div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate">
|
<div className="hidden max-w-55 items-center truncate rounded-full border border-border bg-surface/70 px-3 py-1.5 text-xs text-text-muted sm:flex">
|
||||||
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
|
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
|
||||||
<span className="truncate">{displayName}</span>
|
<span className="truncate">{displayName}</span>
|
||||||
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||||
@@ -327,7 +320,7 @@ function HeaderSearch() {
|
|||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-[160px] sm:w-[220px]">
|
<div className="relative w-40 sm:w-55">
|
||||||
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-text-muted text-[16px] pointer-events-none">
|
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-text-muted text-[16px] pointer-events-none">
|
||||||
search
|
search
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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 COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" };
|
||||||
|
|
||||||
const navItems = [
|
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/providers", label: "Providers", icon: "dns", adminOnly: true },
|
||||||
{ href: "/dashboard/models", label: "Models", icon: "view_list" },
|
{ href: "/dashboard/models", label: "Models", icon: "view_list" },
|
||||||
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
|
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
|
||||||
@@ -56,8 +56,8 @@ export default function Sidebar({ onClose }) {
|
|||||||
}, [fetchCurrentUser, user]);
|
}, [fetchCurrentUser, user]);
|
||||||
|
|
||||||
const isActive = (href) => {
|
const isActive = (href) => {
|
||||||
if (href === "/dashboard/endpoint") {
|
if (href === "/dashboard") {
|
||||||
return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint");
|
return pathname === "/dashboard";
|
||||||
}
|
}
|
||||||
return pathname.startsWith(href);
|
return pathname.startsWith(href);
|
||||||
};
|
};
|
||||||
@@ -65,18 +65,18 @@ export default function Sidebar({ onClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="flex w-72 flex-col border-r border-border-subtle bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
|
<aside className="flex w-64 flex-col border-r border-border-subtle bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
|
||||||
{/* Traffic lights */}
|
{/* Traffic lights */}
|
||||||
<div className="flex items-center gap-2 px-6 pt-5 pb-2">
|
<div className="flex items-center gap-1.5 px-5 pt-4 pb-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
<div className="size-2.5 rounded-full bg-[#ff6259]" />
|
||||||
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
<div className="size-2.5 rounded-full bg-[#ffc145]" />
|
||||||
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
|
<div className="size-2.5 rounded-full bg-[#2ecb71]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="px-6 py-4 flex flex-col gap-2">
|
<div className="px-5 py-3.5 flex flex-col gap-2">
|
||||||
<Link href="/dashboard" className="flex items-center gap-3">
|
<Link href="/dashboard" className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center size-9 rounded-[10px] bg-gradient-to-br from-brand-500 to-brand-700 shadow-[var(--shadow-warm)]">
|
<div className="flex size-9 items-center justify-center rounded-[10px] bg-linear-to-br from-brand-400 to-brand-700 shadow-warm ring-1 ring-brand-300/30">
|
||||||
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
|
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -88,16 +88,16 @@ export default function Sidebar({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-2 space-y-0.5 overflow-y-auto custom-scrollbar">
|
<nav className="flex-1 px-3 py-2 space-y-0.5 overflow-y-auto custom-scrollbar">
|
||||||
{navItems.filter((item) => !item.adminOnly || user?.role === "admin").map((item) => (
|
{navItems.filter((item) => !item.adminOnly || user?.role === "admin").map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -125,9 +125,9 @@ export default function Sidebar({ onClose }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setMediaOpen((v) => !v)}
|
onClick={() => setMediaOpen((v) => !v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
pathname.startsWith("/dashboard/media-providers")
|
pathname.startsWith("/dashboard/media-providers")
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -145,9 +145,9 @@ export default function Sidebar({ onClose }) {
|
|||||||
href={`/dashboard/media-providers/${kind.id}`}
|
href={`/dashboard/media-providers/${kind.id}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-4 py-1 rounded-lg transition-all group",
|
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
|
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -160,9 +160,9 @@ export default function Sidebar({ onClose }) {
|
|||||||
href={COMBINED_WEB_ITEM.href}
|
href={COMBINED_WEB_ITEM.href}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-4 py-1 rounded-lg transition-all group",
|
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
pathname.startsWith(COMBINED_WEB_ITEM.href)
|
pathname.startsWith(COMBINED_WEB_ITEM.href)
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -180,9 +180,9 @@ export default function Sidebar({ onClose }) {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -203,9 +203,9 @@ export default function Sidebar({ onClose }) {
|
|||||||
href="/dashboard/users"
|
href="/dashboard/users"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
isActive("/dashboard/users")
|
isActive("/dashboard/users")
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -224,9 +224,9 @@ export default function Sidebar({ onClose }) {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -248,9 +248,9 @@ export default function Sidebar({ onClose }) {
|
|||||||
href="/dashboard/profile"
|
href="/dashboard/profile"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all duration-200 group",
|
||||||
isActive("/dashboard/profile")
|
isActive("/dashboard/profile")
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/12 text-primary ring-1 ring-primary/15"
|
||||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export default function DashboardLayout({ children }) {
|
|||||||
const removeNotification = useNotificationStore((state) => state.removeNotification);
|
const removeNotification = useNotificationStore((state) => state.removeNotification);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-bg">
|
<div className="flex h-dvh w-full overflow-hidden bg-bg">
|
||||||
<div className="fixed top-4 right-4 z-[80] flex w-[min(92vw,380px)] flex-col gap-2">
|
<div className="fixed top-4 right-4 z-80 flex w-[min(92vw,380px)] flex-col gap-2">
|
||||||
{notifications.map((n) => {
|
{notifications.map((n) => {
|
||||||
const style = getToastStyle(n.type);
|
const style = getToastStyle(n.type);
|
||||||
return (
|
return (
|
||||||
@@ -51,7 +51,7 @@ export default function DashboardLayout({ children }) {
|
|||||||
<span className="material-symbols-outlined text-[18px] leading-5">{style.icon}</span>
|
<span className="material-symbols-outlined text-[18px] leading-5">{style.icon}</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{n.title ? <p className="text-xs font-semibold mb-0.5">{n.title}</p> : null}
|
{n.title ? <p className="text-xs font-semibold mb-0.5">{n.title}</p> : null}
|
||||||
<p className="text-xs whitespace-pre-wrap break-words">{n.message}</p>
|
<p className="text-xs whitespace-pre-wrap wrap-break-word">{n.message}</p>
|
||||||
</div>
|
</div>
|
||||||
{n.dismissible ? (
|
{n.dismissible ? (
|
||||||
<button
|
<button
|
||||||
@@ -71,7 +71,7 @@ export default function DashboardLayout({ children }) {
|
|||||||
{/* Mobile sidebar overlay */}
|
{/* Mobile sidebar overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/20 lg:hidden"
|
className="fixed inset-0 z-40 bg-slate-950/45 backdrop-blur-[2px] lg:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -91,11 +91,11 @@ export default function DashboardLayout({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300 isolate">
|
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300 isolate bg-bg">
|
||||||
{/* Faint grid background */}
|
{/* Faint grid background */}
|
||||||
<div className="landing-grid absolute inset-0 pointer-events-none -z-10" aria-hidden="true" />
|
<div className="landing-grid absolute inset-0 pointer-events-none -z-10" aria-hidden="true" />
|
||||||
<Header key={pathname} onMenuClick={() => setSidebarOpen(true)} />
|
<Header key={pathname} onMenuClick={() => setSidebarOpen(true)} />
|
||||||
<div className={`flex-1 overflow-y-auto custom-scrollbar ${pathname === "/dashboard/basic-chat" ? "" : "p-6 lg:p-10"} ${pathname === "/dashboard/basic-chat" ? "flex flex-col overflow-hidden" : ""}`}>
|
<div className={`flex-1 overflow-y-auto custom-scrollbar ${pathname === "/dashboard/basic-chat" ? "" : "p-5 sm:p-6 lg:p-8 xl:p-10"} ${pathname === "/dashboard/basic-chat" ? "flex flex-col overflow-hidden" : ""}`}>
|
||||||
<div className={`${pathname === "/dashboard/basic-chat" ? "flex-1 w-full h-full flex flex-col" : "max-w-7xl mx-auto"}`}>{children}</div>
|
<div className={`${pathname === "/dashboard/basic-chat" ? "flex-1 w-full h-full flex flex-col" : "max-w-7xl mx-auto"}`}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export const CLI_TOOLS = {
|
|||||||
requiresExternalUrl: true,
|
requiresExternalUrl: true,
|
||||||
notes: [
|
notes: [
|
||||||
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
|
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
|
||||||
{ type: "cloudCheck", text: "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings." },
|
{ type: "cloudCheck", text: "Cursor routes requests through its own server, so a local endpoint is not supported. Use a cloud endpoint instead." },
|
||||||
],
|
],
|
||||||
guideSteps: [
|
guideSteps: [
|
||||||
{ step: 1, title: "Open Settings", desc: "Go to Settings → Models" },
|
{ step: 1, title: "Open Settings", desc: "Go to Settings → Models" },
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import os from "os";
|
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
import { cleanupProviderConnections, getApiKeys, getSettings, updateSettings } from "@/lib/localDb";
|
||||||
import {
|
import { getMitmStatus, initDbHooks, loadEncryptedPassword, removeAllDNSEntriesSync, restoreToolDNS, startMitm } from "@/mitm/manager";
|
||||||
enableTunnel, enableTailscale,
|
|
||||||
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
|
|
||||||
getTunnelService, getTailscaleService, setTunnelUnexpectedExitCallback,
|
|
||||||
killCloudflared, isCloudflaredRunning, ensureCloudflared,
|
|
||||||
isTailscaleRunning, isTailscaleRunningStrict, isDaemonAlive, startFunnel,
|
|
||||||
checkInternet,
|
|
||||||
RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS,
|
|
||||||
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
|
|
||||||
} from "@/lib/tunnel";
|
|
||||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
|
||||||
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
||||||
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
|
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
|
||||||
|
|
||||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
|
||||||
(function bootstrapMitm() {
|
(function bootstrapMitm() {
|
||||||
if (!process.env.MITM_SERVER_PATH) {
|
if (!process.env.MITM_SERVER_PATH) {
|
||||||
try {
|
try {
|
||||||
@@ -32,32 +20,18 @@ import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
|
|||||||
|
|
||||||
process.setMaxListeners(20);
|
process.setMaxListeners(20);
|
||||||
|
|
||||||
// Defer heavy startup work so the first HTTP request (login → dashboard) isn't
|
|
||||||
// starved by DB cleanup, cloudflared download, lsof/DNS probes and OAuth pings.
|
|
||||||
const STARTUP_DEFER_MS = 3000;
|
const STARTUP_DEFER_MS = 3000;
|
||||||
|
|
||||||
// Survive Next.js hot reload
|
|
||||||
const g = global.__appSingleton ??= {
|
const g = global.__appSingleton ??= {
|
||||||
signalHandlersRegistered: false,
|
signalHandlersRegistered: false,
|
||||||
watchdogInterval: null,
|
|
||||||
networkMonitorInterval: null,
|
|
||||||
lastNetworkFingerprint: null,
|
|
||||||
lastWatchdogTick: Date.now(),
|
|
||||||
lastOnline: null,
|
|
||||||
mitmStartInProgress: false,
|
mitmStartInProgress: false,
|
||||||
tunnelAutoResumed: false,
|
|
||||||
tailscaleAutoResumed: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function initializeApp() {
|
export async function initializeApp() {
|
||||||
try {
|
try {
|
||||||
// Register cleanup + exit-respawn callback immediately so signals and
|
|
||||||
// unexpected cloudflared exits are handled even during the deferred window.
|
|
||||||
if (!g.signalHandlersRegistered) {
|
if (!g.signalHandlersRegistered) {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
|
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
|
||||||
try { killAllBridges(); } catch { /* best effort */ }
|
try { killAllBridges(); } catch { /* best effort */ }
|
||||||
killCloudflared();
|
|
||||||
process.exit();
|
process.exit();
|
||||||
};
|
};
|
||||||
process.on("SIGINT", cleanup);
|
process.on("SIGINT", cleanup);
|
||||||
@@ -66,13 +40,8 @@ export async function initializeApp() {
|
|||||||
g.signalHandlersRegistered = true;
|
g.signalHandlersRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTunnelUnexpectedExitCallback(() => {
|
|
||||||
safeRestartTunnel("unexpected-exit").catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Defer the heavy work — nothing here blocks incoming requests.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
runHeavyStartup().catch((e) => console.error("[InitApp] deferred startup failed:", e.message));
|
runHeavyStartup().catch((error) => console.error("[InitApp] deferred startup failed:", error.message));
|
||||||
}, STARTUP_DEFER_MS);
|
}, STARTUP_DEFER_MS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[InitApp] Error:", error);
|
console.error("[InitApp] Error:", error);
|
||||||
@@ -83,34 +52,15 @@ async function runHeavyStartup() {
|
|||||||
await cleanupProviderConnections();
|
await cleanupProviderConnections();
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
|
||||||
// Auto-resume tunnel (once per process)
|
|
||||||
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
|
|
||||||
g.tunnelAutoResumed = true;
|
|
||||||
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
|
||||||
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-resume tailscale (once per process)
|
|
||||||
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
|
|
||||||
g.tailscaleAutoResumed = true;
|
|
||||||
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
|
||||||
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.tunnelEnabled) ensureCloudflared().catch(() => {});
|
|
||||||
|
|
||||||
if (settings.mitmEnabled) {
|
if (settings.mitmEnabled) {
|
||||||
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it.
|
|
||||||
syncMitmAliasCache().catch(() => {});
|
syncMitmAliasCache().catch(() => {});
|
||||||
autoStartMitm(settings);
|
autoStartMitm(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTunnelMonitoring(settings);
|
|
||||||
|
|
||||||
if (hasQuotaAutoPingEnabled(settings)) {
|
if (hasQuotaAutoPingEnabled(settings)) {
|
||||||
import("@/shared/services/quotaAutoPing")
|
import("@/shared/services/quotaAutoPing")
|
||||||
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
|
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
|
||||||
.catch((e) => console.log("[AutoPing] scheduler start failed:", e.message));
|
.catch((error) => console.log("[AutoPing] scheduler start failed:", error.message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +84,7 @@ async function autoStartMitm(settings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keys = await getApiKeys();
|
const keys = await getApiKeys();
|
||||||
const activeKey = keys.find(k => k.isActive !== false);
|
const activeKey = keys.find((key) => key.isActive !== false);
|
||||||
|
|
||||||
console.log("[InitApp] MITM was enabled, auto-starting...");
|
console.log("[InitApp] MITM was enabled, auto-starting...");
|
||||||
await startMitm(activeKey?.key || "sk_9router", password);
|
await startMitm(activeKey?.key || "sk_9router", password);
|
||||||
@@ -142,189 +92,14 @@ async function autoStartMitm(settings) {
|
|||||||
try {
|
try {
|
||||||
await restoreToolDNS(password);
|
await restoreToolDNS(password);
|
||||||
console.log("[InitApp] DNS restored from saved state");
|
console.log("[InitApp] DNS restored from saved state");
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.log("[InitApp] DNS restore failed:", e.message);
|
console.log("[InitApp] DNS restore failed:", error.message);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.log("[InitApp] MITM auto-start failed:", err.message);
|
console.log("[InitApp] MITM auto-start failed:", error.message);
|
||||||
} finally {
|
} finally {
|
||||||
g.mitmStartInProgress = false;
|
g.mitmStartInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown only applies to repeating watchdog ticks (anti hammer-loop).
|
|
||||||
// Network/exit events are one-shot transitions → bypass to recover fast.
|
|
||||||
const FORCE_RESTART_REASONS = /^(startup|netchange|sleep|sleep\+netchange|online|unexpected-exit)$/;
|
|
||||||
|
|
||||||
// ─── Safe restart (4 guards: spawn / cooldown / alive / internet) ────────────
|
|
||||||
|
|
||||||
async function safeRestartTunnel(reason) {
|
|
||||||
const svc = getTunnelService();
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (!settings.tunnelEnabled) return;
|
|
||||||
if (svc.cancelToken.cancelled) return;
|
|
||||||
if (svc.spawnInProgress) return;
|
|
||||||
|
|
||||||
const force = FORCE_RESTART_REASONS.test(reason);
|
|
||||||
|
|
||||||
// Process alive = trust cloudflared (self-reconnects via --retries 99, keeps same URL).
|
|
||||||
// Killing a live process on network change drops the tunnel and rotates the quick-tunnel URL.
|
|
||||||
if (isCloudflaredRunning()) return;
|
|
||||||
|
|
||||||
if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
|
|
||||||
console.log(`[Tunnel] degraded but cooldown active, skip (${reason})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!await checkInternet()) return;
|
|
||||||
|
|
||||||
console.log(`[Tunnel] safeRestart (${reason}) — tunnel unreachable${force ? " [force]" : ""}`);
|
|
||||||
try {
|
|
||||||
await enableTunnel();
|
|
||||||
svc.lastRestartAt = Date.now();
|
|
||||||
console.log("[Tunnel] restart success");
|
|
||||||
} catch (err) {
|
|
||||||
if (!/cloudflared killed|tunnel cancelled/.test(err.message)) {
|
|
||||||
console.log("[Tunnel] restart failed:", err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeRestartTailscale(reason) {
|
|
||||||
const svc = getTailscaleService();
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (!settings.tailscaleEnabled) return;
|
|
||||||
if (svc.cancelToken.cancelled) return;
|
|
||||||
if (svc.spawnInProgress) return;
|
|
||||||
|
|
||||||
// Tailscale daemon is OS-level with built-in reconnect; trust it when running (even on netchange).
|
|
||||||
// Startup uses strict probe — cached state is cold after process/dev reload.
|
|
||||||
const running = reason === "startup" ? await isTailscaleRunningStrict() : isTailscaleRunning();
|
|
||||||
if (running) return;
|
|
||||||
|
|
||||||
// Daemon alive but funnel dropped → recover funnel only; never full-restart (preserves login/daemon).
|
|
||||||
if (isDaemonAlive() && svc.activeLocalPort) {
|
|
||||||
try {
|
|
||||||
await startFunnel(svc.activeLocalPort);
|
|
||||||
svc.lastRestartAt = Date.now();
|
|
||||||
console.log("[Tailscale] funnel re-established (daemon alive)");
|
|
||||||
} catch (err) {
|
|
||||||
console.log("[Tailscale] funnel recovery failed:", err.message);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const force = FORCE_RESTART_REASONS.test(reason);
|
|
||||||
if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
|
|
||||||
console.log(`[Tailscale] degraded but cooldown active, skip (${reason})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!await checkInternet()) return;
|
|
||||||
|
|
||||||
console.log(`[Tailscale] safeRestart (${reason}) — daemon not running${force ? " [force]" : ""}`);
|
|
||||||
try {
|
|
||||||
await enableTailscale();
|
|
||||||
svc.lastRestartAt = Date.now();
|
|
||||||
console.log("[Tailscale] restart success");
|
|
||||||
} catch (err) {
|
|
||||||
console.log("[Tailscale] restart failed:", err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Watchdog: 60s tick check both services ──────────────────────────────────
|
|
||||||
|
|
||||||
function startWatchdog() {
|
|
||||||
if (g.watchdogInterval) return;
|
|
||||||
g.watchdogInterval = setInterval(() => {
|
|
||||||
safeRestartTunnel("watchdog").catch(() => {});
|
|
||||||
safeRestartTailscale("watchdog").catch(() => {});
|
|
||||||
}, WATCHDOG_INTERVAL_MS);
|
|
||||||
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopWatchdog() {
|
|
||||||
if (!g.watchdogInterval) return;
|
|
||||||
clearInterval(g.watchdogInterval);
|
|
||||||
g.watchdogInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
|
|
||||||
|
|
||||||
function getNetworkFingerprint() {
|
|
||||||
const interfaces = os.networkInterfaces();
|
|
||||||
const active = [];
|
|
||||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
||||||
if (!addrs) continue;
|
|
||||||
if (VIRTUAL_IFACE_REGEX.test(name)) continue;
|
|
||||||
for (const addr of addrs) {
|
|
||||||
if (!addr.internal && addr.family === "IPv4") {
|
|
||||||
active.push(`${name}:${addr.address}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return active.sort().join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNetworkMonitor() {
|
|
||||||
if (g.networkMonitorInterval) return;
|
|
||||||
|
|
||||||
g.lastNetworkFingerprint = getNetworkFingerprint();
|
|
||||||
g.lastWatchdogTick = Date.now();
|
|
||||||
g.lastOnline = null;
|
|
||||||
|
|
||||||
g.networkMonitorInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = now - g.lastWatchdogTick;
|
|
||||||
g.lastWatchdogTick = now;
|
|
||||||
|
|
||||||
const currentFingerprint = getNetworkFingerprint();
|
|
||||||
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
|
|
||||||
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 6;
|
|
||||||
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
|
|
||||||
|
|
||||||
// Real reachability check (TCP 1.1.1.1:443) — not just interface presence
|
|
||||||
const online = await checkInternet();
|
|
||||||
const wasOffline = g.lastOnline === false;
|
|
||||||
g.lastOnline = online;
|
|
||||||
|
|
||||||
if (!online) return; // no internet → idle, don't restart
|
|
||||||
|
|
||||||
const onlineEdge = wasOffline; // offline → online transition
|
|
||||||
if (!networkChanged && !wasSleep && !onlineEdge) return;
|
|
||||||
|
|
||||||
// Wait for DHCP/DNS to settle before probing
|
|
||||||
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
|
|
||||||
|
|
||||||
const reason = onlineEdge ? "online"
|
|
||||||
: wasSleep && networkChanged ? "sleep+netchange"
|
|
||||||
: wasSleep ? "sleep" : "netchange";
|
|
||||||
safeRestartTunnel(reason).catch(() => {});
|
|
||||||
safeRestartTailscale(reason).catch(() => {});
|
|
||||||
} catch (err) {
|
|
||||||
console.log("[NetworkMonitor] error:", err.message);
|
|
||||||
}
|
|
||||||
}, NETWORK_CHECK_INTERVAL_MS);
|
|
||||||
|
|
||||||
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function stopNetworkMonitor() {
|
|
||||||
if (!g.networkMonitorInterval) return;
|
|
||||||
clearInterval(g.networkMonitorInterval);
|
|
||||||
g.networkMonitorInterval = null;
|
|
||||||
g.lastNetworkFingerprint = null;
|
|
||||||
g.lastOnline = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function configureTunnelMonitoring(settings) {
|
|
||||||
if (settings?.tunnelEnabled || settings?.tailscaleEnabled) {
|
|
||||||
startWatchdog();
|
|
||||||
startNetworkMonitor();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stopWatchdog();
|
|
||||||
stopNetworkMonitor();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default initializeApp;
|
export default initializeApp;
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ export function isLocalCliToolUrl(value) {
|
|||||||
export function resolveCliToolBaseUrl({
|
export function resolveCliToolBaseUrl({
|
||||||
appUrl = "",
|
appUrl = "",
|
||||||
requiresExternalUrl = false,
|
requiresExternalUrl = false,
|
||||||
tunnelEnabled = false,
|
|
||||||
tunnelPublicUrl = "",
|
|
||||||
tailscaleEnabled = false,
|
|
||||||
tailscaleUrl = "",
|
|
||||||
cloudEnabled = false,
|
cloudEnabled = false,
|
||||||
cloudUrl = "",
|
cloudUrl = "",
|
||||||
configuredBaseUrl = "",
|
configuredBaseUrl = "",
|
||||||
@@ -40,8 +36,6 @@ export function resolveCliToolBaseUrl({
|
|||||||
|
|
||||||
// Tools such as Cursor cannot call a loopback URL from their remote service.
|
// Tools such as Cursor cannot call a loopback URL from their remote service.
|
||||||
if (requiresExternalUrl) {
|
if (requiresExternalUrl) {
|
||||||
if (tunnelEnabled && tunnelPublicUrl) return trimTrailingSlashes(tunnelPublicUrl);
|
|
||||||
if (tailscaleEnabled && tailscaleUrl) return trimTrailingSlashes(tailscaleUrl);
|
|
||||||
if (cloudEnabled && cloudUrl) return trimTrailingSlashes(cloudUrl);
|
if (cloudEnabled && cloudUrl) return trimTrailingSlashes(cloudUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,16 +22,7 @@ describe("CLI tool endpoint resolution", () => {
|
|||||||
})).toBe("https://router.customer.example");
|
})).toBe("https://router.customer.example");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a public endpoint for externally hosted tools when the dashboard is local", () => {
|
it("uses the cloud endpoint for externally hosted tools when the dashboard is local", () => {
|
||||||
expect(resolveCliToolBaseUrl({
|
|
||||||
appUrl: "http://localhost:20128",
|
|
||||||
requiresExternalUrl: true,
|
|
||||||
tunnelEnabled: true,
|
|
||||||
tunnelPublicUrl: "https://tunnel.example/",
|
|
||||||
cloudEnabled: true,
|
|
||||||
cloudUrl: "https://cloud.example",
|
|
||||||
})).toBe("https://tunnel.example");
|
|
||||||
|
|
||||||
expect(resolveCliToolBaseUrl({
|
expect(resolveCliToolBaseUrl({
|
||||||
appUrl: "http://localhost:20128",
|
appUrl: "http://localhost:20128",
|
||||||
requiresExternalUrl: true,
|
requiresExternalUrl: true,
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe("dashboard guard local-only access", () => {
|
|||||||
expect(response.body.error).toBe("Unauthorized");
|
expect(response.body.error).toBe("Unauthorized");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects local-only route from a tunnel host", async () => {
|
it("rejects local-only route from a remote host", async () => {
|
||||||
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||||
host: "router.example.com",
|
host: "router.example.com",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -66,6 +66,33 @@ describe("Schema migrations", () => {
|
|||||||
expect(JSON.parse(settings.data)).toEqual({ foo: "bar" });
|
expect(JSON.parse(settings.data)).toEqual({ foo: "bar" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes legacy tunnel settings during upgrade", async () => {
|
||||||
|
const { getAdapter } = await import("@/lib/db/driver.js");
|
||||||
|
const db = await getAdapter();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
|
||||||
|
[JSON.stringify({
|
||||||
|
keep: "value",
|
||||||
|
tunnelEnabled: true,
|
||||||
|
tunnelUrl: "https://tunnel.example",
|
||||||
|
tunnelProvider: "cloudflare",
|
||||||
|
tailscaleEnabled: true,
|
||||||
|
tailscaleUrl: "https://machine.ts.net",
|
||||||
|
tunnelDashboardAccess: true,
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
db.run(`UPDATE _meta SET value = '8' WHERE key = 'schemaVersion'`);
|
||||||
|
db.close?.();
|
||||||
|
|
||||||
|
delete global._dbAdapter;
|
||||||
|
vi.resetModules();
|
||||||
|
const { getAdapter: getAdapter2 } = await import("@/lib/db/driver.js");
|
||||||
|
const db2 = await getAdapter2();
|
||||||
|
const settings = db2.get(`SELECT data FROM settings WHERE id=1`);
|
||||||
|
|
||||||
|
expect(JSON.parse(settings.data)).toEqual({ keep: "value" });
|
||||||
|
});
|
||||||
|
|
||||||
it("fresh DB + legacy db.json → imports data automatically", async () => {
|
it("fresh DB + legacy db.json → imports data automatically", async () => {
|
||||||
// Simulate user upgrading: place legacy JSON in DATA_DIR before first boot
|
// Simulate user upgrading: place legacy JSON in DATA_DIR before first boot
|
||||||
const legacy = {
|
const legacy = {
|
||||||
|
|||||||
Reference in New Issue
Block a user