From ed17a8ffacce5ac3cd7aecd509633d7c9bc6b69f Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 10 Apr 2026 18:16:14 +0700 Subject: [PATCH] Feat : Tailscale --- open-sse/executors/github.js | 14 + .../dashboard/cli-tools/CLIToolsPageClient.js | 20 +- .../cli-tools/components/MitmLinkCard.js | 40 + .../dashboard/cli-tools/components/index.js | 1 + .../dashboard/endpoint/EndpointPageClient.js | 946 +++++++++++------- src/app/api/health/route.js | 5 + src/app/api/tunnel/status/route.js | 6 +- src/app/api/tunnel/tailscale-check/route.js | 41 + src/app/api/tunnel/tailscale-disable/route.js | 12 + src/app/api/tunnel/tailscale-enable/route.js | 12 + src/app/api/tunnel/tailscale-install/route.js | 67 ++ src/app/api/tunnel/tailscale-login/route.js | 14 + .../tunnel/tailscale-start-daemon/route.js | 21 + src/lib/localDb.js | 3 + src/lib/tunnel/state.js | 44 +- src/lib/tunnel/tailscale.js | 442 ++++++++ src/lib/tunnel/tunnelManager.js | 104 +- src/mitm/server.js | 29 + 18 files changed, 1433 insertions(+), 388 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js create mode 100644 src/app/api/health/route.js create mode 100644 src/app/api/tunnel/tailscale-check/route.js create mode 100644 src/app/api/tunnel/tailscale-disable/route.js create mode 100644 src/app/api/tunnel/tailscale-enable/route.js create mode 100644 src/app/api/tunnel/tailscale-install/route.js create mode 100644 src/app/api/tunnel/tailscale-login/route.js create mode 100644 src/app/api/tunnel/tailscale-start-daemon/route.js create mode 100644 src/lib/tunnel/tailscale.js diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index a670fe4..a517c84 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -103,6 +103,20 @@ export class GithubExecutor extends BaseExecutor { return sanitized; } + // Newer OpenAI models (gpt-5+, o1, o3, o4) require max_completion_tokens instead of max_tokens + requiresMaxCompletionTokens(model) { + return /gpt-5|o[134]-/i.test(model); + } + + transformRequest(model, body, stream, credentials) { + const transformed = { ...body }; + if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) { + transformed.max_completion_tokens = transformed.max_tokens; + delete transformed.max_tokens; + } + return transformed; + } + async execute(options) { const { model, log } = options; diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 5200779..baafc6f 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,7 +4,8 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard, MitmLinkCard } from "./components"; +import { MITM_TOOLS } from "@/shared/constants/cliTools"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -184,23 +185,18 @@ export default function CLIToolsPageClient({ machineId }) { }; const regularTools = Object.entries(CLI_TOOLS); + const mitmTools = Object.entries(MITM_TOOLS); return (
- {/* {!hasActiveProviders && ( - -
- warning -
-

No active providers

-

Please add and connect providers first to configure CLI tools.

-
-
-
- )} */}
{regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))}
+
+ {mitmTools.map(([toolId, tool]) => ( + + ))} +
); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js new file mode 100644 index 0000000..865128e --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; +import { Card } from "@/shared/components"; +import Image from "next/image"; + +/** + * Clickable card for MITM tools — navigates to /dashboard/mitm on click. + */ +export default function MitmLinkCard({ tool }) { + return ( + + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} + /> +
+
+
+

{tool.name}

+ MITM +
+

{tool.description}

+
+
+ chevron_right +
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index ee0ecc1..c345cc3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -8,4 +8,5 @@ export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; export { default as CopilotToolCard } from "./CopilotToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; +export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index bd6ed11..58c0c65 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -1,15 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import PropTypes from "prop-types"; import { Card, Button, Input, Modal, CardSkeleton, Toggle } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -/* ========== CLOUD CODE — COMMENTED OUT (replaced by Tunnel) ========== -const DEFAULT_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || ""; -const CLOUD_ACTION_TIMEOUT_MS = 15000; -========== END CLOUD CODE ========== */ - 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" }, @@ -17,8 +12,8 @@ const TUNNEL_BENEFITS = [ { icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" }, ]; -const TUNNEL_ACTION_TIMEOUT_MS = 90000; - +const TUNNEL_PING_INTERVAL_MS = 2000; +const TUNNEL_PING_MAX_MS = 300000; export default function APIPageClient({ machineId }) { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); @@ -26,196 +21,53 @@ export default function APIPageClient({ machineId }) { const [newKeyName, setNewKeyName] = useState(""); const [createdKey, setCreatedKey] = useState(null); - /* ========== CLOUD STATE — COMMENTED OUT (replaced by Tunnel) ========== - const [cloudEnabled, setCloudEnabled] = useState(false); - const [cloudUrl, setCloudUrl] = useState(DEFAULT_CLOUD_URL); - const [cloudUrlInput, setCloudUrlInput] = useState(DEFAULT_CLOUD_URL); - const [cloudUrlSaving, setCloudUrlSaving] = useState(false); - const [showCloudModal, setShowCloudModal] = useState(false); - const [showDisableModal, setShowDisableModal] = useState(false); - const [showSetupModal, setShowSetupModal] = useState(false); - const [setupStatus, setSetupStatus] = useState(null); - const [cloudSyncing, setCloudSyncing] = useState(false); - const [cloudStatus, setCloudStatus] = useState(null); - const [syncStep, setSyncStep] = useState(""); - ========== END CLOUD STATE ========== */ - - // Tunnel state const [requireApiKey, setRequireApiKey] = useState(false); + + // Cloudflare Tunnel state + const [tunnelChecking, setTunnelChecking] = useState(true); const [tunnelEnabled, setTunnelEnabled] = useState(false); const [tunnelUrl, setTunnelUrl] = useState(""); const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); - const [tunnelShortId, setTunnelShortId] = useState(""); const [tunnelLoading, setTunnelLoading] = useState(false); const [tunnelProgress, setTunnelProgress] = useState(""); const [tunnelStatus, setTunnelStatus] = useState(null); - const [showDisableModal, setShowDisableModal] = useState(false); - const [showEnableModal, setShowEnableModal] = useState(false); + const [showEnableTunnelModal, setShowEnableTunnelModal] = useState(false); + const [showDisableTunnelModal, setShowDisableTunnelModal] = useState(false); + + // Tailscale state + const [tsEnabled, setTsEnabled] = useState(false); + const [tsUrl, setTsUrl] = useState(""); + const [tsLoading, setTsLoading] = useState(false); + const [tsProgress, setTsProgress] = useState(""); + const [tsStatus, setTsStatus] = useState(null); + const [tsInstalled, setTsInstalled] = useState(null); // null=checking, true/false + const [tsInstalling, setTsInstalling] = useState(false); + const [tsInstallLog, setTsInstallLog] = useState([]); + const [tsSudoPassword, setTsSudoPassword] = useState(""); + const [tsConnecting, setTsConnecting] = useState(false); + const [showTsModal, setShowTsModal] = useState(false); + const [showDisableTsModal, setShowDisableTsModal] = useState(false); + const tsLogRef = useRef(null); + // API key visibility toggle state const [visibleKeys, setVisibleKeys] = useState(new Set()); const { copied, copy } = useCopyToClipboard(); + // Auto-scroll install log + useEffect(() => { + if (tsLogRef.current) tsLogRef.current.scrollTop = tsLogRef.current.scrollHeight; + }, [tsInstallLog]); + useEffect(() => { fetchData(); loadSettings(); }, []); - /* ========== CLOUD FUNCTIONS — COMMENTED OUT (replaced by Tunnel) ========== - const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch("/api/sync/cloud", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - signal: controller.signal, - }); - const data = await res.json().catch(() => ({})); - return { ok: res.ok, status: res.status, data }; - } catch (error) { - if (error?.name === "AbortError") { - return { ok: false, status: 408, data: { error: "Cloud request timeout" } }; - } - return { ok: false, status: 500, data: { error: error.message || "Cloud request failed" } }; - } finally { - clearTimeout(timeoutId); - } - }; - - const loadCloudSettings = async () => { - try { - const res = await fetch("/api/settings"); - if (res.ok) { - const data = await res.json(); - setCloudEnabled(data.cloudEnabled || false); - setRequireApiKey(data.requireApiKey || false); - const url = data.cloudUrl || DEFAULT_CLOUD_URL; - setCloudUrl(url); - setCloudUrlInput(url); - } - } catch (error) { - console.log("Error loading cloud settings:", error); - } - }; - - const handleCloudToggle = (checked) => { - if (checked) { - setShowCloudModal(true); - } else { - setShowDisableModal(true); - } - }; - - const handleEnableCloud = async () => { - setCloudSyncing(true); - setSyncStep("syncing"); - try { - const { ok, data } = await postCloudAction("enable"); - if (ok) { - setSyncStep("verifying"); - if (data.verified) { - setCloudEnabled(true); - setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" }); - setShowCloudModal(false); - } else { - setCloudEnabled(true); - setCloudStatus({ type: "warning", message: data.verifyError || "Connected but verification failed" }); - setShowCloudModal(false); - } - if (data.createdKey) await fetchData(); - } else { - setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" }); - } - } catch (error) { - setCloudStatus({ type: "error", message: error.message }); - } finally { - setCloudSyncing(false); - setSyncStep(""); - } - }; - - const handleConfirmDisable = async () => { - setCloudSyncing(true); - setSyncStep("syncing"); - try { - await postCloudAction("sync"); - setSyncStep("disabling"); - const { ok, data } = await postCloudAction("disable"); - if (ok) { - setCloudEnabled(false); - setCloudStatus({ type: "success", message: "Cloud disabled" }); - setShowDisableModal(false); - } else { - setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" }); - } - } catch (error) { - setCloudStatus({ type: "error", message: "Failed to disable cloud" }); - } finally { - setCloudSyncing(false); - setSyncStep(""); - } - }; - - const handleSyncCloud = async () => { - if (!cloudEnabled) return; - setCloudSyncing(true); - try { - const { ok, data } = await postCloudAction("sync"); - if (ok) setCloudStatus({ type: "success", message: "Synced successfully" }); - else setCloudStatus({ type: "error", message: data.error }); - } catch (error) { - setCloudStatus({ type: "error", message: error.message }); - } finally { - setCloudSyncing(false); - } - }; - - const handleSaveCloudUrl = async () => { - const trimmed = cloudUrlInput.trim().replace(/\/v1\/?$/, "").replace(/\/+$/, ""); - if (!trimmed) return; - setCloudUrlSaving(true); - setSetupStatus(null); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cloudUrl: trimmed }), - }); - if (res.ok) { - setCloudUrl(trimmed); - setCloudUrlInput(trimmed); - setSetupStatus({ type: "success", message: "Worker URL saved" }); - } else { - setSetupStatus({ type: "error", message: "Failed to save Worker URL" }); - } - } catch (error) { - setSetupStatus({ type: "error", message: error.message }); - } finally { - setCloudUrlSaving(false); - } - }; - - const handleCheckCloud = async () => { - if (!cloudUrl) return; - setCloudSyncing(true); - setSetupStatus(null); - try { - const { ok, data } = await postCloudAction("check", 8000); - if (ok) setSetupStatus({ type: "success", message: data.message || "Worker is running" }); - else setSetupStatus({ type: "error", message: data.error || "Check failed" }); - } catch { - setSetupStatus({ type: "error", message: "Cannot reach worker" }); - } finally { - setCloudSyncing(false); - } - }; - ========== END CLOUD FUNCTIONS ========== */ - const loadSettings = async () => { + setTunnelChecking(true); try { - const [settingsRes, tunnelRes] = await Promise.all([ + const [settingsRes, statusRes] = await Promise.all([ fetch("/api/settings"), fetch("/api/tunnel/status") ]); @@ -223,15 +75,63 @@ export default function APIPageClient({ machineId }) { const data = await settingsRes.json(); setRequireApiKey(data.requireApiKey || false); } - if (tunnelRes.ok) { - const data = await tunnelRes.json(); - setTunnelEnabled(data.enabled || false); - setTunnelUrl(data.tunnelUrl || ""); - setTunnelPublicUrl(data.publicUrl || ""); - setTunnelShortId(data.shortId || ""); + if (statusRes.ok) { + const data = await statusRes.json(); + const tEnabled = data.tunnel?.enabled || false; + const tUrl = data.tunnel?.tunnelUrl || ""; + const tPublicUrl = data.tunnel?.publicUrl || ""; + setTunnelUrl(tUrl); + setTunnelPublicUrl(tPublicUrl); + const tsEn = data.tailscale?.enabled || false; + const tsUrlVal = data.tailscale?.tunnelUrl || ""; + setTsUrl(tsUrlVal); + + if (tsEn && tsUrlVal) { + setTsLoading(true); + setTsProgress("Checking Tailscale..."); + const tsHealthUrl = `${tsUrlVal}/api/health`; + try { + const tsPing = await fetch(tsHealthUrl, { mode: "no-cors", cache: "no-store" }); + if (tsPing.ok || tsPing.type === "opaque") { + setTsEnabled(true); + } else { + const ok = await pingTsHealth(tsUrlVal); + setTsEnabled(ok); + if (!ok) setTsStatus({ type: "warning", message: "Tailscale not reachable." }); + } + } catch { + const ok = await pingTsHealth(tsUrlVal); + setTsEnabled(ok); + if (!ok) setTsStatus({ type: "warning", message: "Tailscale not reachable." }); + } finally { + setTsLoading(false); + setTsProgress(""); + } + } else { + setTsEnabled(tsEn); + } + + if (tEnabled && (tPublicUrl || tUrl)) { + // Ping once to verify reachable + const healthUrl = `${tPublicUrl || tUrl}/api/health`; + try { + const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); + if (ping.ok || ping.type === "opaque") { + setTunnelEnabled(true); + } else { + pingTunnelHealth(tPublicUrl || tUrl); + } + } catch { + pingTunnelHealth(tPublicUrl || tUrl); + } + } else { + setTunnelEnabled(tEnabled); + } } } catch (error) { console.log("Error loading settings:", error); + } finally { + setTunnelChecking(false); } }; @@ -262,45 +162,70 @@ export default function APIPageClient({ machineId }) { } }; + // u2500u2500u2500 Cloudflare Tunnel handlers + // Ping tunnel health until reachable, also check backend status to detect process die + const pingTunnelHealth = async (url) => { + setTunnelLoading(true); + setTunnelProgress("Waiting for tunnel ready..."); + const healthUrl = `${url}/api/health`; + const start = Date.now(); + while (Date.now() - start < TUNNEL_PING_MAX_MS) { + await new Promise((r) => setTimeout(r, TUNNEL_PING_INTERVAL_MS)); + try { + const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); + if (ping.ok || ping.type === "opaque") { + setTunnelEnabled(true); + setTunnelLoading(false); + setTunnelProgress(""); + return true; + } + } catch { /* not ready yet */ } + // Every 5 pings (~10s), check if backend process still alive + if ((Date.now() - start) % 10000 < TUNNEL_PING_INTERVAL_MS) { + try { + const statusRes = await fetch("/api/tunnel/status"); + if (statusRes.ok) { + const status = await statusRes.json(); + if (!status.tunnel?.enabled) { + setTunnelStatus({ type: "error", message: "Tunnel process stopped unexpectedly." }); + setTunnelLoading(false); + setTunnelProgress(""); + return false; + } + } + } catch { /* ignore */ } + } + } + setTunnelStatus({ type: "error", message: "Tunnel created but not reachable. Please try again." }); + setTunnelLoading(false); + setTunnelProgress(""); + return false; + }; + const handleEnableTunnel = async () => { - setShowEnableModal(false); + setShowEnableTunnelModal(false); setTunnelLoading(true); setTunnelStatus(null); - setTunnelProgress("Connecting to server..."); - - const progressSteps = [ - { delay: 2000, msg: "Creating tunnel..." }, - { delay: 5000, msg: "Starting cloudflared..." }, - { delay: 15000, msg: "Establishing connections..." }, - { delay: 30000, msg: "Waiting for tunnel ready..." }, - ]; - const timers = progressSteps.map(({ delay, msg }) => - setTimeout(() => setTunnelProgress(msg), delay) - ); - + setTunnelProgress("Creating tunnel..."); try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), TUNNEL_ACTION_TIMEOUT_MS); - const res = await fetch("/api/tunnel/enable", { - method: "POST", - signal: controller.signal, - }); - clearTimeout(timeoutId); - timers.forEach(clearTimeout); + const res = await fetch("/api/tunnel/enable", { method: "POST" }); const data = await res.json(); - if (res.ok) { - setTunnelEnabled(true); - setTunnelUrl(data.tunnelUrl || ""); - setTunnelPublicUrl(data.publicUrl || ""); - setTunnelShortId(data.shortId || ""); - setTunnelStatus({ type: "success", message: "Tunnel connected!" }); - } else { + if (!res.ok) { setTunnelStatus({ type: "error", message: data.error || "Failed to enable tunnel" }); + return; } + + const url = data.publicUrl || data.tunnelUrl; + if (!url) { + setTunnelStatus({ type: "error", message: "No tunnel URL returned" }); + return; + } + + setTunnelUrl(data.tunnelUrl || ""); + setTunnelPublicUrl(data.publicUrl || ""); + await pingTunnelHealth(url); } catch (error) { - timers.forEach(clearTimeout); - const msg = error?.name === "AbortError" ? "Tunnel creation timed out" : error.message; - setTunnelStatus({ type: "error", message: msg }); + setTunnelStatus({ type: "error", message: error.message }); } finally { setTunnelLoading(false); setTunnelProgress(""); @@ -317,8 +242,8 @@ export default function APIPageClient({ machineId }) { setTunnelEnabled(false); setTunnelUrl(""); setTunnelPublicUrl(""); + setShowDisableTunnelModal(false); setTunnelStatus({ type: "success", message: "Tunnel disabled" }); - setShowDisableModal(false); } else { setTunnelStatus({ type: "error", message: data.error || "Failed to disable tunnel" }); } @@ -329,6 +254,230 @@ export default function APIPageClient({ machineId }) { } }; + // u2500u2500u2500 Tailscale handlers + const checkTailscaleInstalled = async () => { + setTsInstalled(null); + try { + const res = await fetch("/api/tunnel/tailscale-check"); + if (res.ok) { + const data = await res.json(); + setTsInstalled(data.installed); + return data; + } + } catch { /* ignore */ } + setTsInstalled(false); + return { installed: false }; + }; + + const handleInstallTailscale = async () => { + setTsInstalling(true); + setTsStatus(null); + setTsInstallLog([]); + try { + const res = await fetch("/api/tunnel/tailscale-install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sudoPassword: tsSudoPassword }), + }); + setTsSudoPassword(""); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() || ""; + for (const part of parts) { + const lines = part.split("\n"); + let event = "progress"; + let data = null; + for (const line of lines) { + if (line.startsWith("event: ")) event = line.slice(7).trim(); + if (line.startsWith("data: ")) { + try { data = JSON.parse(line.slice(6)); } catch { /* skip */ } + } + } + if (!data) continue; + if (event === "progress") { + setTsInstallLog((prev) => [...prev.slice(-50), data.message]); + } else if (event === "done") { + setTsInstalled(true); + setTsInstalling(false); + return; + } else if (event === "error") { + setTsStatus({ type: "error", message: data.error || "Install failed" }); + } + } + } + } catch (e) { + setTsStatus({ type: "error", message: e.message }); + } finally { + setTsInstalling(false); + } + }; + + // Ping Tailscale health until reachable + const pingTsHealth = async (url) => { + setTsProgress("Waiting for Tailscale ready..."); + const healthUrl = `${url}/api/health`; + const start = Date.now(); + while (Date.now() - start < TUNNEL_PING_MAX_MS) { + await new Promise((r) => setTimeout(r, TUNNEL_PING_INTERVAL_MS)); + try { + const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); + if (ping.ok || ping.type === "opaque") return true; + } catch { /* not ready yet */ } + } + return false; + }; + + const handleConnectTailscale = async (preOpenedTab) => { + const tab = preOpenedTab || null; + setShowTsModal(false); + setTsConnecting(true); + setTsLoading(true); + setTsStatus(null); + setTsProgress("Connecting..."); + try { + const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); + const data = await res.json(); + + if (res.ok && data.success) { + if (tab) tab.close(); + setTsUrl(data.tunnelUrl || ""); + const reachable = await pingTsHealth(data.tunnelUrl); + if (reachable) { + setTsEnabled(true); + setTsStatus(null); + } else { + setTsEnabled(true); + setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); + } + return; + } + + // Needs login: redirect pre-opened tab or open new + if (data.needsLogin && data.authUrl) { + if (tab) tab.location.href = data.authUrl; + else window.open(data.authUrl, "tailscale_auth", "width=600,height=700"); + setTsProgress("Waiting for login..."); + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 3000)); + try { + const r2 = await fetch("/api/tunnel/tailscale-check"); + if (r2.ok) { + const check = await r2.json(); + if (check.loggedIn) { + setTsProgress("Starting funnel..."); + const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); + const data2 = await res2.json(); + if (res2.ok && data2.success) { + if (tab) tab.close(); + setTsUrl(data2.tunnelUrl || ""); + const ok2 = await pingTsHealth(data2.tunnelUrl); + if (ok2) { + setTsEnabled(true); + setTsStatus(null); + } else { + setTsEnabled(true); + setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); + } + } else if (data2.funnelNotEnabled && data2.enableUrl) { + await pollFunnelEnable(data2.enableUrl, tab); + } else { + setTsStatus({ type: "error", message: data2.error || "Failed to start funnel" }); + } + return; + } + } + } catch { /* retry */ } + } + setTsStatus({ type: "error", message: "Login timed out. Please try again." }); + return; + } + + // Funnel not enabled: redirect pre-opened tab + if (data.funnelNotEnabled && data.enableUrl) { + await pollFunnelEnable(data.enableUrl, tab); + return; + } + + if (tab) tab.close(); + setTsStatus({ type: "error", message: data.error || "Failed to connect" }); + } catch (error) { + if (tab) tab.close(); + setTsStatus({ type: "error", message: error.message }); + } finally { + setTsLoading(false); + setTsConnecting(false); + setTsProgress(""); + } + }; + + const pollFunnelEnable = async (enableUrl, tab) => { + if (tab) tab.location.href = enableUrl; + else window.open(enableUrl, "tailscale_auth", "width=600,height=700"); + setTsProgress("Enable Funnel in browser, waiting..."); + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 3000)); + try { + const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); + const data = await res.json(); + if (res.ok && data.success) { + if (tab) tab.close(); + setTsUrl(data.tunnelUrl || ""); + const ok3 = await pingTsHealth(data.tunnelUrl); + if (ok3) { + setTsEnabled(true); + setTsStatus(null); + } else { + setTsEnabled(true); + setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); + } + return; + } + if (data.funnelNotEnabled) continue; + if (data.error) { + setTsStatus({ type: "error", message: data.error }); + return; + } + } catch { /* retry */ } + } + setTsStatus({ type: "error", message: "Timed out waiting for Funnel to be enabled." }); + }; + + const handleDisableTailscale = async () => { + setTsLoading(true); + setTsStatus(null); + try { + const res = await fetch("/api/tunnel/tailscale-disable", { method: "POST" }); + const data = await res.json(); + if (res.ok) { + setTsEnabled(false); + setTsUrl(""); + setShowDisableTsModal(false); + setTsStatus({ type: "success", message: "Tailscale disabled" }); + } else { + setTsStatus({ type: "error", message: data.error || "Failed to disable Tailscale" }); + } + } catch (e) { + setTsStatus({ type: "error", message: e.message }); + } finally { + setTsLoading(false); + } + }; + + const handleOpenTsModal = async () => { + setTsStatus(null); + setTsInstallLog([]); + setShowTsModal(true); + await checkTailscaleInstalled(); + }; + const handleCreateKey = async () => { if (!newKeyName.trim()) return; @@ -417,96 +566,128 @@ export default function APIPageClient({ machineId }) { ); } - const currentEndpoint = tunnelEnabled && tunnelPublicUrl ? `${tunnelPublicUrl}/v1` : baseUrl; + const currentEndpoint = baseUrl; return (
{/* Endpoint Card */} -
-
-

API Endpoint

-

- {tunnelEnabled ? "Using Tunnel" : "Using Local Server"} -

-
+

API Endpoint

+ + {/* Endpoint rows */} +
+ {/* Local */} + + {/* Cloudflare Tunnel */}
- {tunnelEnabled ? ( - + Tunnel + {tunnelEnabled && !tunnelLoading ? ( + <> + + + + + ) : tunnelLoading ? ( +
+ progress_activity + {tunnelProgress || "Creating tunnel..."} +
+ ) : tunnelStatus?.type === "error" ? ( + <> +
+ error + {tunnelStatus.message} +
+ + + ) : tunnelChecking ? ( +
+ progress_activity + Checking... +
) : ( + )} +
+ {/* Tailscale */} +
+ Tailscale + {tsEnabled && !tsLoading ? ( + <> + + + + + ) : (tsLoading || tsConnecting) ? ( +
+ progress_activity + {tsProgress || "Connecting..."} +
+ ) : tsStatus?.type === "error" ? ( + <> +
+ error + {tsStatus.message} +
+ + + ) : ( + )}
- - {/* Endpoint URL */} -
- - -
- - {/* Direct endpoint */} - {/*
- Direct - arrow_forward - {currentEndpoint}/chat/completions - -
*/} - - {/* Tunnel Status */} - {tunnelStatus && ( -
- {tunnelStatus.message} -
- )} {/* API Keys */} @@ -608,10 +789,6 @@ export default function APIPageClient({ machineId }) { )} - {/* CLOUD MODALS — COMMENTED OUT (replaced by Tunnel) */} - {/* Setup Cloud Modal — removed */} - {/* Cloud Enable Modal — removed */} - {/* Add Key Modal */} setShowEnableModal(false)} + onClose={() => setShowEnableTunnelModal(false)} >
@@ -724,62 +901,118 @@ export default function APIPageClient({ machineId }) { > Start Tunnel - +
- {/* Disable Tunnel Modal */} + {/* Disable Cloudflare Tunnel Modal */} !tunnelLoading && setShowDisableModal(false)} + onClose={() => !tunnelLoading && setShowDisableTunnelModal(false)} >
-
-
- warning -
-

- Warning -

-

- The tunnel will be disconnected. Remote access will stop working. -

+

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

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

+ progress_activity + Checking... +

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

Tailscale is not installed. Install it to enable Funnel.

+
+ +
-
+ )} -

Are you sure you want to disable the tunnel?

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

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

- - +
@@ -787,6 +1020,49 @@ export default function APIPageClient({ machineId }) { ); } +/** Reusable endpoint row component */ +function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) { + return ( +
+ {label} + + + {actions} +
+ ); +} + +/** Reusable status alert */ +function StatusAlert({ status, className = "" }) { + // Render URLs in message as clickable links + const renderMessage = (msg) => { + const parts = msg.split(/(https?:\/\/[^\s]+)/g); + return parts.map((part, i) => + /^https?:\/\//.test(part) + ? {part} + : part + ); + }; + + return ( +
+ {renderMessage(status.message)} +
+ ); +} + APIPageClient.propTypes = { machineId: PropTypes.string.isRequired, }; diff --git a/src/app/api/health/route.js b/src/app/api/health/route.js new file mode 100644 index 0000000..7f809dd --- /dev/null +++ b/src/app/api/health/route.js @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js index 6cc4e87..c651cdc 100644 --- a/src/app/api/tunnel/status/route.js +++ b/src/app/api/tunnel/status/route.js @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; -import { getTunnelStatus } from "@/lib/tunnel/tunnelManager"; +import { getTunnelStatus, getTailscaleStatus } from "@/lib/tunnel/tunnelManager"; export async function GET() { try { - const status = await getTunnelStatus(); - return NextResponse.json(status); + const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]); + return NextResponse.json({ tunnel, tailscale }); } catch (error) { console.error("Tunnel status error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); diff --git a/src/app/api/tunnel/tailscale-check/route.js b/src/app/api/tunnel/tailscale-check/route.js new file mode 100644 index 0000000..3e5aee0 --- /dev/null +++ b/src/app/api/tunnel/tailscale-check/route.js @@ -0,0 +1,41 @@ +import os from "os"; +import { execSync } from "child_process"; +import { NextResponse } from "next/server"; +import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale"; + +const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; + +function hasBrew() { + try { execSync("which brew", { stdio: "ignore", env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; } +} + +function isDaemonRunning() { + try { + // Use custom socket + --json; exit 0 even when not logged in + execSync(`tailscale --socket ${TAILSCALE_SOCKET} status --json`, { + stdio: "ignore", + env: { ...process.env, PATH: EXTENDED_PATH }, + timeout: 3000 + }); + return true; + } catch { + // Fallback: check if tailscaled process is alive + try { + execSync("pgrep -x tailscaled", { stdio: "ignore", timeout: 2000 }); + return true; + } catch { return false; } + } +} + +export async function GET() { + try { + const installed = isTailscaleInstalled(); + const platform = os.platform(); + const brewAvailable = platform === "darwin" && hasBrew(); + const daemonRunning = installed ? isDaemonRunning() : false; + const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false; + return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning }); + } catch (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/tunnel/tailscale-disable/route.js b/src/app/api/tunnel/tailscale-disable/route.js new file mode 100644 index 0000000..67ce160 --- /dev/null +++ b/src/app/api/tunnel/tailscale-disable/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { disableTailscale } from "@/lib/tunnel/tunnelManager"; + +export async function POST() { + try { + const result = await disableTailscale(); + return NextResponse.json(result); + } catch (error) { + console.error("Tailscale disable error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/tunnel/tailscale-enable/route.js b/src/app/api/tunnel/tailscale-enable/route.js new file mode 100644 index 0000000..aee60f5 --- /dev/null +++ b/src/app/api/tunnel/tailscale-enable/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { enableTailscale } from "@/lib/tunnel/tunnelManager"; + +export async function POST() { + try { + const result = await enableTailscale(); + return NextResponse.json(result); + } catch (error) { + console.error("Tailscale enable error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/tunnel/tailscale-install/route.js b/src/app/api/tunnel/tailscale-install/route.js new file mode 100644 index 0000000..9ec2015 --- /dev/null +++ b/src/app/api/tunnel/tailscale-install/route.js @@ -0,0 +1,67 @@ +"use server"; + +import os from "os"; +import { execSync } from "child_process"; +import { installTailscale } from "@/lib/tunnel/tailscale"; +import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import { loadState, generateShortId } from "@/lib/tunnel/state.js"; + +initDbHooks(getSettings, updateSettings); + +const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; + +function hasBrew() { + try { execSync("which brew", { stdio: "ignore", env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; } +} + +export async function POST(request) { + const body = await request.json().catch(() => ({})); + const platform = os.platform(); + const isWindows = platform === "win32"; + const isBrew = platform === "darwin" && hasBrew(); + const needsPassword = !isWindows && !isBrew; + + const sudoPassword = body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || ""; + + if (needsPassword && !sudoPassword.trim()) { + return new Response(JSON.stringify({ error: "Sudo password is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const shortId = loadState()?.shortId || generateShortId(); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const send = (event, data) => { + controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); + }; + + try { + const result = await installTailscale(sudoPassword, shortId, (msg) => { + send("progress", { message: msg }); + }); + send("done", { success: true, authUrl: result?.authUrl || null }); + } catch (error) { + console.error("Tailscale install error:", error); + const msg = error.message?.includes("incorrect password") || error.message?.includes("Sorry") + ? "Wrong sudo password" + : error.message; + send("error", { error: msg }); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); +} diff --git a/src/app/api/tunnel/tailscale-login/route.js b/src/app/api/tunnel/tailscale-login/route.js new file mode 100644 index 0000000..516e430 --- /dev/null +++ b/src/app/api/tunnel/tailscale-login/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { startLogin } from "@/lib/tunnel/tailscale"; +import { loadState, generateShortId } from "@/lib/tunnel/state.js"; + +export async function POST() { + try { + const shortId = loadState()?.shortId || generateShortId(); + const result = await startLogin(shortId); + return NextResponse.json(result); + } catch (error) { + console.error("Tailscale login error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/tunnel/tailscale-start-daemon/route.js b/src/app/api/tunnel/tailscale-start-daemon/route.js new file mode 100644 index 0000000..826f8b0 --- /dev/null +++ b/src/app/api/tunnel/tailscale-start-daemon/route.js @@ -0,0 +1,21 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { startDaemonWithPassword } from "@/lib/tunnel/tailscale"; +import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; +import { getSettings, updateSettings } from "@/lib/localDb"; + +initDbHooks(getSettings, updateSettings); + +export async function POST(request) { + try { + const body = await request.json().catch(() => ({})); + // Use provided password, or fall back to cached/stored MITM password + const password = body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || ""; + await startDaemonWithPassword(password); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Tailscale start daemon error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/lib/localDb.js b/src/lib/localDb.js index aa81fb5..76cf935 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -55,6 +55,8 @@ const defaultData = { cloudEnabled: false, tunnelEnabled: false, tunnelUrl: "", + tailscaleEnabled: false, + tailscaleUrl: "", stickyRoundRobinLimit: 3, providerStrategies: {}, comboStrategy: "fallback", @@ -91,6 +93,7 @@ function cloneDefaultData() { cloudEnabled: false, tunnelEnabled: false, tunnelUrl: "", + tunnelProvider: "cloudflare", stickyRoundRobinLimit: 3, providerStrategies: {}, comboStrategy: "fallback", diff --git a/src/lib/tunnel/state.js b/src/lib/tunnel/state.js index 54c7fca..cbb386b 100644 --- a/src/lib/tunnel/state.js +++ b/src/lib/tunnel/state.js @@ -4,7 +4,8 @@ import os from "os"; const TUNNEL_DIR = path.join(os.homedir(), ".9router", "tunnel"); const STATE_FILE = path.join(TUNNEL_DIR, "state.json"); -const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid"); +const CLOUDFLARED_PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid"); +const TAILSCALE_PID_FILE = path.join(TUNNEL_DIR, "tailscale.pid"); function ensureDir() { if (!fs.existsSync(TUNNEL_DIR)) { @@ -32,15 +33,16 @@ export function clearState() { } catch (e) { /* ignore */ } } +// Cloudflare-specific PID export function savePid(pid) { ensureDir(); - fs.writeFileSync(PID_FILE, pid.toString()); + fs.writeFileSync(CLOUDFLARED_PID_FILE, pid.toString()); } export function loadPid() { try { - if (fs.existsSync(PID_FILE)) { - return parseInt(fs.readFileSync(PID_FILE, "utf8")); + if (fs.existsSync(CLOUDFLARED_PID_FILE)) { + return parseInt(fs.readFileSync(CLOUDFLARED_PID_FILE, "utf8")); } } catch (e) { /* ignore */ } return null; @@ -48,6 +50,38 @@ export function loadPid() { export function clearPid() { try { - if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + if (fs.existsSync(CLOUDFLARED_PID_FILE)) fs.unlinkSync(CLOUDFLARED_PID_FILE); } catch (e) { /* ignore */ } } + +// Tailscale-specific PID +export function saveTailscalePid(pid) { + ensureDir(); + fs.writeFileSync(TAILSCALE_PID_FILE, pid.toString()); +} + +export function loadTailscalePid() { + try { + if (fs.existsSync(TAILSCALE_PID_FILE)) { + return parseInt(fs.readFileSync(TAILSCALE_PID_FILE, "utf8")); + } + } catch (e) { /* ignore */ } + return null; +} + +export function clearTailscalePid() { + try { + if (fs.existsSync(TAILSCALE_PID_FILE)) fs.unlinkSync(TAILSCALE_PID_FILE); + } catch (e) { /* ignore */ } +} + +const SHORT_ID_LENGTH = 6; +const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789"; + +export function generateShortId() { + let result = ""; + for (let i = 0; i < SHORT_ID_LENGTH; i++) { + result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length)); + } + return result; +} diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js new file mode 100644 index 0000000..91ae8fe --- /dev/null +++ b/src/lib/tunnel/tailscale.js @@ -0,0 +1,442 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import { execSync, spawn } from "child_process"; +import { execWithPassword, executeElevatedPowerShell } from "@/mitm/dns/dnsConfig"; +import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js"; + +const BIN_DIR = path.join(os.homedir(), ".9router", "bin"); +const IS_MAC = os.platform() === "darwin"; +const IS_LINUX = os.platform() === "linux"; +const IS_WINDOWS = os.platform() === "win32"; +const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); + +// Custom socket for userspace-networking mode (no root required) +const TAILSCALE_DIR = path.join(os.homedir(), ".9router", "tailscale"); +export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock"); +const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET]; + +// Prefer system tailscale, fallback to local bin +function getTailscaleBin() { + try { + const systemPath = execSync("which tailscale 2>/dev/null || where tailscale 2>nul", { encoding: "utf8" }).trim(); + if (systemPath) return systemPath; + } catch (e) { /* not in PATH */ } + if (fs.existsSync(TAILSCALE_BIN)) return TAILSCALE_BIN; + return null; +} + +export function isTailscaleInstalled() { + return getTailscaleBin() !== null; +} + +/** Build tailscale CLI args with custom socket (no root needed) */ +function tsArgs(...args) { + return [...SOCKET_FLAG, ...args]; +} + +export function isTailscaleLoggedIn() { + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", + env: { ...process.env, PATH: EXTENDED_PATH }, + timeout: 5000 + }); + const json = JSON.parse(out); + // BackendState "Running" means fully logged in and connected + return json.BackendState === "Running"; + } catch (e) { + return false; + } +} + +export function isTailscaleRunning() { + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json 2>/dev/null`, { encoding: "utf8" }); + const json = JSON.parse(out); + return Object.keys(json.AllowFunnel || {}).length > 0; + } catch (e) { + return false; + } +} + +/** Get funnel URL from tailscale status */ +export function getTailscaleFunnelUrl(port) { + const bin = getTailscaleBin(); + if (!bin) return null; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { encoding: "utf8" }); + const json = JSON.parse(out); + const dnsName = json.Self?.DNSName?.replace(/\.$/, ""); + if (dnsName) return `https://${dnsName}`; + } catch (e) { /* ignore */ } + return null; +} + +/** + * Install tailscale. + * - macOS + brew: brew install tailscale (no sudo needed) + * - macOS no brew: download .pkg then sudo installer -pkg + * - Linux: fetch install.sh, pipe to sudo -S sh via stdin + * - Windows: download MSI via UAC-elevated PowerShell + */ +export async function installTailscale(sudoPassword, hostname, onProgress) { + const log = onProgress || (() => {}); + if (IS_WINDOWS) await installTailscaleWindows(log); + else if (IS_MAC) await installTailscaleMac(sudoPassword, log); + else await installTailscaleLinux(sudoPassword, log); + + log("Starting daemon..."); + await startDaemonWithPassword(sudoPassword); + log("Logging in..."); + return startLogin(hostname); +} + +const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; + +function hasBrew() { + try { execSync("which brew", { stdio: "ignore", env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; } +} + +async function installTailscaleMac(sudoPassword, log) { + if (hasBrew()) { + log("Installing via Homebrew..."); + await new Promise((resolve, reject) => { + const child = spawn("brew", ["install", "tailscale"], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, PATH: EXTENDED_PATH } + }); + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.stderr.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + if (c === 0) resolve(); + else reject(new Error(`brew install failed (code ${c})`)); + }); + child.on("error", reject); + }); + return; + } + + // No brew: download .pkg and install via sudo installer + const pkgUrl = "https://pkgs.tailscale.com/stable/tailscale-latest.pkg"; + const pkgPath = path.join(os.tmpdir(), "tailscale.pkg"); + + log("Downloading Tailscale package..."); + await new Promise((resolve, reject) => { + const child = spawn("curl", ["-fL", "--progress-bar", pkgUrl, "-o", pkgPath], { + stdio: ["ignore", "pipe", "pipe"] + }); + child.stderr.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + if (c === 0) resolve(); + else reject(new Error("Download failed")); + }); + child.on("error", reject); + }); + + log("Installing package..."); + await new Promise((resolve, reject) => { + const child = spawn("sudo", ["-S", "installer", "-pkg", pkgPath, "-target", "/"], { + stdio: ["pipe", "pipe", "pipe"] + }); + let stderr = ""; + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + try { execSync(`rm -f ${pkgPath}`, { stdio: "ignore" }); } catch { /* ignore */ } + if (c === 0) resolve(); + else { + const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) + ? "Wrong sudo password" + : stderr || `Exit code ${c}`; + reject(new Error(msg)); + } + }); + child.on("error", reject); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.end(); + }); +} + +async function installTailscaleLinux(sudoPassword, log) { + log("Downloading install script..."); + return new Promise((resolve, reject) => { + const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], { + stdio: ["ignore", "pipe", "pipe"] + }); + let scriptContent = ""; + let curlErr = ""; + curlChild.stdout.on("data", (d) => { scriptContent += d.toString(); }); + curlChild.stderr.on("data", (d) => { curlErr += d.toString(); }); + curlChild.on("exit", (code) => { + if (code !== 0) return reject(new Error(`Failed to download install script: ${curlErr}`)); + log("Running install script..."); + const child = spawn("sudo", ["-S", "sh"], { stdio: ["pipe", "pipe", "pipe"] }); + let stderr = ""; + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.on("close", (c) => { + if (c === 0) resolve(); + else { + const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) + ? "Wrong sudo password" + : stderr || `Exit code ${c}`; + reject(new Error(msg)); + } + }); + child.on("error", reject); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.write(scriptContent); + child.stdin.end(); + }); + curlChild.on("error", reject); + }); +} + +async function installTailscaleWindows(log) { + const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"; + const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi"); + const psScriptPath = path.join(os.tmpdir(), `tailscale-install-${Date.now()}.ps1`); + + log("Downloading Tailscale installer..."); + const psScript = [ + `Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'`, + `Start-Process msiexec.exe -ArgumentList '/i','${msiPath}','/quiet','/norestart' -Wait`, + `Remove-Item '${msiPath}' -Force -ErrorAction SilentlyContinue`, + ].join("\n"); + + fs.writeFileSync(psScriptPath, psScript, "utf8"); + log("Installing (UAC prompt may appear)..."); + await executeElevatedPowerShell(psScriptPath, 120000); +} + +/** Start tailscaled with sudo (TUN mode required for Funnel) */ +export async function startDaemonWithPassword(sudoPassword) { + if (IS_WINDOWS) return; + + // Check if daemon already responds + try { + const bin = getTailscaleBin() || "tailscale"; + execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + stdio: "ignore", + env: { ...process.env, PATH: EXTENDED_PATH }, + timeout: 3000 + }); + return; // Already running + } catch { /* not running, start it */ } + + // Ensure config dir exists + if (!fs.existsSync(TAILSCALE_DIR)) fs.mkdirSync(TAILSCALE_DIR, { recursive: true }); + + // tailscaled requires root for TUN (needed for Funnel) + const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled"; + const daemonCmd = `${tailscaledBin} --socket=${TAILSCALE_SOCKET} --statedir=${TAILSCALE_DIR}`; + + // Start via sudo in background (nohup keeps it alive) + await execWithPassword(`nohup ${daemonCmd} > /dev/null 2>&1 &`, sudoPassword || ""); + + // Wait for daemon to be ready + await new Promise((r) => setTimeout(r, 3000)); +} + +/** Best-effort: ensure daemon running (used for login flow) */ +function ensureDaemon() { + startDaemonWithPassword("").catch(() => {}); +} + +/** + * Run `tailscale up` and capture the auth URL for browser login. + * Resolves with { authUrl } or { alreadyLoggedIn: true }. + */ +export function startLogin(hostname) { + const bin = getTailscaleBin(); + if (!bin) return Promise.reject(new Error("Tailscale not installed")); + + return new Promise((resolve, reject) => { + // Ensure daemon is running (best-effort, no sudo) + ensureDaemon(); + + // Check if already logged in + if (isTailscaleLoggedIn()) { + resolve({ alreadyLoggedIn: true }); + return; + } + + // Spawn detached so process survives API request lifecycle + const args = tsArgs("up", "--accept-routes"); + if (hostname) args.push(`--hostname=${hostname}`); + const child = spawn(bin, args, { + stdio: ["ignore", "pipe", "pipe"], + detached: true + }); + + let resolved = false; + let output = ""; + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + // Don't kill — let tailscale up keep waiting for auth + child.unref(); + const url = parseAuthUrl(output); + if (url) resolve({ authUrl: url }); + else reject(new Error("tailscale up timed out without auth URL")); + }, 15000); + + const parseAuthUrl = (text) => { + const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9]+/); + return match ? match[0] : null; + }; + + const handleData = (data) => { + output += data.toString(); + const url = parseAuthUrl(output); + if (url && !resolved) { + resolved = true; + clearTimeout(timeout); + // Keep process alive — unref so it doesn't block Node exit + child.unref(); + resolve({ authUrl: url }); + } + }; + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + reject(err); + }); + + child.on("exit", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + const url = parseAuthUrl(output); + if (url) resolve({ authUrl: url }); + else if (isTailscaleLoggedIn()) resolve({ alreadyLoggedIn: true }); + else reject(new Error(`tailscale up exited with code ${code}`)); + }); + }); +} + +/** Start tailscale funnel for the given port */ +export async function startFunnel(port) { + const bin = getTailscaleBin(); + if (!bin) throw new Error("Tailscale not installed"); + + // Reset any existing funnel + try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore" }); } catch (e) { /* ignore */ } + + return new Promise((resolve, reject) => { + const child = spawn(bin, tsArgs("funnel", "--bg", `${port}`), { + stdio: ["ignore", "pipe", "pipe"] + }); + + let resolved = false; + let output = ""; + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + // --bg exits after setup, try status + const url = getTailscaleFunnelUrl(port); + if (url) resolve({ tunnelUrl: url }); + else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`)); + }, 30000); + + const parseFunnelUrl = (text) => + (text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9.-]+\.ts\.net[^\s]*/i) || [])[0]?.replace(/\/$/, "") || null; + + let funnelNotEnabled = false; + + const handleData = (data) => { + output += data.toString(); + + if (output.includes("Funnel is not enabled")) funnelNotEnabled = true; + + // Wait for the enable URL to arrive in a later chunk + if (funnelNotEnabled && !resolved) { + const enableMatch = output.match(/https:\/\/login\.tailscale\.com\/[^\s]+/); + if (enableMatch) { + resolved = true; + clearTimeout(timeout); + child.kill(); + resolve({ funnelNotEnabled: true, enableUrl: enableMatch[0] }); + return; + } + } + + const url = parseFunnelUrl(output); + if (url && !resolved) { + resolved = true; + clearTimeout(timeout); + resolve({ tunnelUrl: url }); + } + }; + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + + child.on("exit", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + const url = parseFunnelUrl(output) || getTailscaleFunnelUrl(port); + if (url) resolve({ tunnelUrl: url }); + else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`)); + }); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + reject(err); + }); + }); +} + +/** Stop tailscale funnel */ +export function stopFunnel() { + const bin = getTailscaleBin(); + if (!bin) return; + try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore" }); } catch (e) { /* ignore */ } +} + +/** Kill tailscaled daemon (runs as root, needs sudo) */ +export async function stopDaemon(sudoPassword) { + // Try non-sudo first + try { execSync("pkill -x tailscaled", { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + + // Check if still alive + try { execSync("pgrep -x tailscaled", { stdio: "ignore", timeout: 2000 }); } catch { return; } // Dead, done + + // Kill with sudo password + if (!IS_WINDOWS) { + try { await execWithPassword("pkill -x tailscaled", sudoPassword || ""); } catch { /* ignore */ } + } + + // Cleanup socket + try { if (fs.existsSync(TAILSCALE_SOCKET)) fs.unlinkSync(TAILSCALE_SOCKET); } catch { /* ignore */ } +} diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js index c19672d..b107a72 100644 --- a/src/lib/tunnel/tunnelManager.js +++ b/src/lib/tunnel/tunnelManager.js @@ -1,12 +1,14 @@ import crypto from "crypto"; -import { loadState, saveState } from "./state.js"; +import { loadState, saveState, generateShortId } from "./state.js"; import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js"; +import { startFunnel, stopFunnel, stopDaemon, isTailscaleRunning, isTailscaleLoggedIn, startLogin, startDaemonWithPassword } from "./tailscale.js"; import { getSettings, updateSettings } from "@/lib/localDb"; +import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; + +initDbHooks(getSettings, updateSettings); const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://9router.com"; const MACHINE_ID_SALT = "9router-tunnel-salt"; -const SHORT_ID_LENGTH = 6; -const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789"; const RECONNECT_DELAYS_MS = [5000, 10000, 20000, 30000, 60000]; const MAX_RECONNECT_ATTEMPTS = RECONNECT_DELAYS_MS.length; @@ -23,14 +25,6 @@ export function isTunnelReconnecting() { return isReconnecting; } -function generateShortId() { - let result = ""; - for (let i = 0; i < SHORT_ID_LENGTH; i++) { - result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length)); - } - return result; -} - function getMachineId() { try { const { machineIdSync } = require("node-machine-id"); @@ -41,9 +35,8 @@ function getMachineId() { } } -/** - * Register quick tunnel URL to worker (called on start and URL change) - */ +// ─── Cloudflare Tunnel ─────────────────────────────────────────────────────── + async function registerTunnelUrl(shortId, tunnelUrl) { await fetch(`${WORKER_URL}/api/tunnel/register`, { method: "POST", @@ -54,10 +47,12 @@ async function registerTunnelUrl(shortId, tunnelUrl) { export async function enableTunnel(localPort = 20128) { manualDisabled = false; + if (isCloudflaredRunning()) { const existing = loadState(); if (existing?.tunnelUrl) { - return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, alreadyRunning: true }; + const publicUrl = `https://r${existing.shortId}.9router.com`; + return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true }; } } @@ -67,7 +62,7 @@ export async function enableTunnel(localPort = 20128) { const existing = loadState(); const shortId = existing?.shortId || generateShortId(); - // onUrlUpdate: only called when URL changes AFTER initial connect (not on first resolve) + // onUrlUpdate: called when URL changes AFTER initial connect const onUrlUpdate = async (url) => { if (manualDisabled) return; await registerTunnelUrl(shortId, url); @@ -75,15 +70,12 @@ export async function enableTunnel(localPort = 20128) { await updateSettings({ tunnelEnabled: true, tunnelUrl: url }); }; - // Spawn quick tunnel — resolve returns initial URL, onUrlUpdate handles subsequent changes const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate); - // Register initial URL (exactly once) await registerTunnelUrl(shortId, tunnelUrl); saveState({ shortId, machineId, tunnelUrl }); await updateSettings({ tunnelEnabled: true, tunnelUrl }); - // Set exit handler only once (not on every reconnect) if (!exitHandlerRegistered) { setUnexpectedExitHandler(() => { if (!isReconnecting) scheduleReconnect(0); @@ -105,23 +97,17 @@ async function scheduleReconnect(attempt) { await new Promise((r) => { reconnectTimeoutId = setTimeout(r, delay); }); try { - if (manualDisabled) { - isReconnecting = false; - return; - } + if (manualDisabled) { isReconnecting = false; return; } const settings = await getSettings(); - if (!settings.tunnelEnabled) { - isReconnecting = false; - return; - } + if (!settings.tunnelEnabled) { isReconnecting = false; return; } await enableTunnel(); console.log("[Tunnel] Reconnected successfully"); isReconnecting = false; } catch (err) { console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message); isReconnecting = false; - const nextAttempt = attempt + 1; - if (nextAttempt < MAX_RECONNECT_ATTEMPTS) scheduleReconnect(nextAttempt); + const next = attempt + 1; + if (next < MAX_RECONNECT_ATTEMPTS) scheduleReconnect(next); else { console.log("[Tunnel] All reconnect attempts exhausted, disabling tunnel"); await updateSettings({ tunnelEnabled: false }); @@ -130,7 +116,6 @@ async function scheduleReconnect(attempt) { } export async function disableTunnel() { - // Block any reconnect attempts before killing the process manualDisabled = true; isReconnecting = true; if (reconnectTimeoutId) { @@ -148,10 +133,7 @@ export async function disableTunnel() { } await updateSettings({ tunnelEnabled: false, tunnelUrl: "" }); - - // Unblock reconnect lock — manualDisabled stays true to block Watchdog/NetworkMonitor isReconnecting = false; - return { success: true }; } @@ -170,3 +152,59 @@ export async function getTunnelStatus() { running }; } + +// ─── Tailscale Funnel ───────────────────────────────────────────────────────── + +export async function enableTailscale(localPort = 20128) { + // Ensure daemon is running (needs sudo for TUN mode) + const sudoPass = getCachedPassword() || await loadEncryptedPassword() || ""; + await startDaemonWithPassword(sudoPass); + + // Generate hostname from machine ID (same as tunnel shortId prefix) + const existing = loadState(); + const shortId = existing?.shortId || generateShortId(); + const tsHostname = shortId; + + // If not logged in, return auth URL for user to authenticate + if (!isTailscaleLoggedIn()) { + const loginResult = await startLogin(tsHostname); + if (loginResult.authUrl) { + return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + } + } + + stopFunnel(); + const result = await startFunnel(localPort); + + // Funnel not enabled on tailnet — return enable URL + if (result.funnelNotEnabled) { + return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl }; + } + + // Verify device is actually connected (BackendState=Running + funnel active) + if (!isTailscaleLoggedIn() || !isTailscaleRunning()) { + stopFunnel(); + return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." }; + } + + await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl }); + return { success: true, tunnelUrl: result.tunnelUrl }; +} + +export async function disableTailscale() { + stopFunnel(); + const sudoPass = getCachedPassword() || await loadEncryptedPassword() || ""; + await stopDaemon(sudoPass); + await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" }); + return { success: true }; +} + +export async function getTailscaleStatus() { + const settings = await getSettings(); + const running = isTailscaleRunning(); + return { + enabled: settings.tailscaleEnabled === true && running, + tunnelUrl: settings.tailscaleUrl || "", + running + }; +} diff --git a/src/mitm/server.js b/src/mitm/server.js index 9882667..5cd40d8 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); const dns = require("dns"); const { promisify } = require("util"); +const { execSync } = require("child_process"); const { log, err } = require("./logger"); const { TARGET_HOSTS, URL_PATTERNS, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); @@ -218,6 +219,34 @@ const server = https.createServer(sslOptions, async (req, res) => { } }); +// Kill any process occupying LOCAL_PORT before binding +function killPort(port) { + try { + const pids = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim(); + if (!pids) return; + const pidList = pids.split("\n"); + pidList.forEach(pid => { + try { + process.kill(Number(pid), "SIGKILL"); + } catch (e) { + err(`Failed to kill PID ${pid}: ${e.message}`); + throw e; + } + }); + log(`Killed ${pidList.length} process(es) on port ${port}`); + } catch (e) { + // lsof exits with status 1 when no process found — that's fine + if (e.status !== 1) throw e; + } +} + +try { + killPort(LOCAL_PORT); +} catch (e) { + err(`Cannot kill process on port ${LOCAL_PORT}: ${e.message}`); + process.exit(1); +} + server.listen(LOCAL_PORT, () => log(`🚀 Server ready on :${LOCAL_PORT}`)); server.on("error", (e) => {