From b876e0225aad98ff441b5266634c0538e4935dd9 Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 25 May 2026 16:15:34 +0700 Subject: [PATCH] Refactor Tunnel --- open-sse/services/tokenRefresh.js | 117 +++++--- .../dashboard/endpoint/EndpointPageClient.js | 5 +- .../(dashboard)/dashboard/proxy-pools/page.js | 3 - src/app/api/tunnel/disable/route.js | 2 +- src/app/api/tunnel/enable/route.js | 2 +- src/app/api/tunnel/status/route.js | 3 +- src/app/api/tunnel/tailscale-check/route.js | 2 +- src/app/api/tunnel/tailscale-disable/route.js | 2 +- src/app/api/tunnel/tailscale-enable/route.js | 2 +- src/app/api/tunnel/tailscale-install/route.js | 3 +- src/app/api/tunnel/tailscale-login/route.js | 14 - .../tunnel/tailscale-start-daemon/route.js | 21 -- src/app/layout.js | 1 + src/dashboardGuard.js | 2 - .../tunnel/{ => cloudflare}/cloudflared.js | 2 +- src/lib/tunnel/cloudflare/config.js | 9 + src/lib/tunnel/cloudflare/healthCheck.js | 29 ++ src/lib/tunnel/cloudflare/manager.js | 148 ++++++++++ src/lib/tunnel/cloudflare/pid.js | 23 ++ src/lib/tunnel/index.js | 46 +++ src/lib/tunnel/networkProbe.js | 68 ----- src/lib/tunnel/shared/dnsResolver.js | 17 ++ src/lib/tunnel/shared/internetCheck.js | 26 ++ src/lib/tunnel/shared/state.js | 41 +++ src/lib/tunnel/shared/watchdogConfig.js | 5 + src/lib/tunnel/state.js | 87 ------ src/lib/tunnel/tailscale/config.js | 7 + src/lib/tunnel/tailscale/healthCheck.js | 29 ++ src/lib/tunnel/tailscale/manager.js | 129 ++++++++ src/lib/tunnel/{ => tailscale}/tailscale.js | 1 - src/lib/tunnel/tunnelConfig.js | 18 -- src/lib/tunnel/tunnelManager.js | 276 ------------------ src/server-init.js | 17 -- src/shared/services/bootstrap.js | 7 + src/shared/services/initializeApp.js | 52 ++-- 35 files changed, 642 insertions(+), 574 deletions(-) delete mode 100644 src/app/api/tunnel/tailscale-login/route.js delete mode 100644 src/app/api/tunnel/tailscale-start-daemon/route.js rename src/lib/tunnel/{ => cloudflare}/cloudflared.js (99%) create mode 100644 src/lib/tunnel/cloudflare/config.js create mode 100644 src/lib/tunnel/cloudflare/healthCheck.js create mode 100644 src/lib/tunnel/cloudflare/manager.js create mode 100644 src/lib/tunnel/cloudflare/pid.js create mode 100644 src/lib/tunnel/index.js delete mode 100644 src/lib/tunnel/networkProbe.js create mode 100644 src/lib/tunnel/shared/dnsResolver.js create mode 100644 src/lib/tunnel/shared/internetCheck.js create mode 100644 src/lib/tunnel/shared/state.js create mode 100644 src/lib/tunnel/shared/watchdogConfig.js delete mode 100644 src/lib/tunnel/state.js create mode 100644 src/lib/tunnel/tailscale/config.js create mode 100644 src/lib/tunnel/tailscale/healthCheck.js create mode 100644 src/lib/tunnel/tailscale/manager.js rename src/lib/tunnel/{ => tailscale}/tailscale.js (99%) delete mode 100644 src/lib/tunnel/tunnelConfig.js delete mode 100644 src/lib/tunnel/tunnelManager.js delete mode 100644 src/server-init.js create mode 100644 src/shared/services/bootstrap.js diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index c449559f..63b0fdf9 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -7,35 +7,64 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js"; let _xaiServiceSingleton = null; async function refreshXaiToken(refreshToken, log) { if (!refreshToken) return null; - try { - if (!_xaiServiceSingleton) { - const mod = await import("../../src/lib/oauth/services/xai.js"); - _xaiServiceSingleton = new mod.XaiService(); + return dedupRefresh("xai", refreshToken, async () => { + try { + if (!_xaiServiceSingleton) { + const mod = await import("../../src/lib/oauth/services/xai.js"); + _xaiServiceSingleton = new mod.XaiService(); + } + const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + idToken: tokens.id_token, + }; + } catch (e) { + log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`); + const msg = String(e?.message || ""); + if (msg.includes("invalid_grant") || msg.includes("invalid_request")) { + return { error: "invalid_grant" }; + } + return null; } - const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - idToken: tokens.id_token, - }; - } catch (e) { - log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`); - const msg = String(e?.message || ""); - if (msg.includes("invalid_grant") || msg.includes("invalid_request")) { - return { error: "invalid_grant" }; - } - return null; - } + }, log); } // Default token expiry buffer (refresh if expires within 5 minutes) export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; -// In-flight refresh dedup: prevents race condition that triggers refresh_token_reused → Auth0 family revoke -const refreshPromiseCache = new Map(); -function getRefreshCacheKey(provider, refreshToken) { - return `${provider}:${refreshToken}`; +// Dedup: cache in-flight promise + recent result to prevent refresh_token_reused (Auth0 family revoke) +const REFRESH_RESULT_TTL_MS = 10_000; +const refreshDedupCache = new Map(); + +async function dedupRefresh(provider, oldToken, fn, log) { + if (!oldToken) return fn(); + const key = `${provider}:${oldToken}`; + const hit = refreshDedupCache.get(key); + if (hit) { + if (hit.promise) { + log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); + return hit.promise; + } + if (hit.expiresAt > Date.now()) { + log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`); + return hit.result; + } + refreshDedupCache.delete(key); + } + const promise = (async () => { + try { + const result = await fn(); + refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS }); + return result; + } catch (err) { + refreshDedupCache.delete(key); + throw err; + } + })(); + refreshDedupCache.set(key, { promise }); + return promise; } // Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth) @@ -71,6 +100,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo return null; } + return dedupRefresh(provider, refreshToken, async () => { try { const response = await fetch(config.refreshUrl, { method: "POST", @@ -114,12 +144,15 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo }); return null; } + }, log); } /** * Specialized refresh for Claude OAuth tokens */ export async function refreshClaudeOAuthToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("claude", refreshToken, async () => { try { const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { method: "POST", @@ -147,12 +180,15 @@ export async function refreshClaudeOAuthToken(refreshToken, log) { log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); return null; } + }, log); } /** * Specialized refresh for Google providers (Gemini, Antigravity) */ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { + if (!refreshToken) return null; + return dedupRefresh(`google:${clientId}`, refreshToken, async () => { try { const response = await fetch(OAUTH_ENDPOINTS.google.token, { method: "POST", @@ -181,12 +217,15 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`); return null; } + }, log); } /** * Specialized refresh for Qwen OAuth tokens */ export async function refreshQwenToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("qwen", refreshToken, async () => { const endpoint = OAUTH_ENDPOINTS.qwen.token; try { @@ -235,6 +274,7 @@ export async function refreshQwenToken(refreshToken, log) { log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); return null; + }, log); } /** @@ -244,6 +284,8 @@ export async function refreshQwenToken(refreshToken, log) { * so callers stop retrying and request re-authentication. */ export async function refreshCodexToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("codex", refreshToken, async () => { try { const response = await fetch(OAUTH_ENDPOINTS.openai.token, { method: "POST", @@ -306,6 +348,7 @@ export async function refreshCodexToken(refreshToken, log) { log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); return null; } + }, log); } /** @@ -313,6 +356,8 @@ export async function refreshCodexToken(refreshToken, log) { * Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub) */ export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) { + if (!refreshToken) return null; + return dedupRefresh("kiro", refreshToken, async () => { const authMethod = providerSpecificData?.authMethod; const clientId = providerSpecificData?.clientId; const clientSecret = providerSpecificData?.clientSecret; @@ -397,12 +442,15 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log, refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn, }; + }, log); } /** * Specialized refresh for iFlow OAuth tokens */ export async function refreshIflowToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("iflow", refreshToken, async () => { const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { @@ -442,12 +490,15 @@ export async function refreshIflowToken(refreshToken, log) { refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; + }, log); } /** * Specialized refresh for GitHub Copilot OAuth tokens */ export async function refreshGitHubToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("github", refreshToken, async () => { const params = { grant_type: "refresh_token", refresh_token: refreshToken, @@ -488,12 +539,15 @@ export async function refreshGitHubToken(refreshToken, log) { refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; + }, log); } /** * Refresh GitHub Copilot token using GitHub access token */ export async function refreshCopilotToken(githubAccessToken, log) { + if (!githubAccessToken) return null; + return dedupRefresh("copilot", githubAccessToken, async () => { try { const response = await fetch("https://api.github.com/copilot_internal/v2/token", { headers: { @@ -532,6 +586,7 @@ export async function refreshCopilotToken(githubAccessToken, log) { }); return null; } + }, log); } /** @@ -544,20 +599,8 @@ export async function getAccessToken(provider, credentials, log) { log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`); return null; } - - const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken); - - if (refreshPromiseCache.has(cacheKey)) { - log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); - return refreshPromiseCache.get(cacheKey); - } - - const refreshPromise = _getAccessTokenInternal(provider, credentials, log).finally(() => { - refreshPromiseCache.delete(cacheKey); - }); - - refreshPromiseCache.set(cacheKey, refreshPromise); - return refreshPromise; + // Dedup is handled inside each refreshXxxToken function + return _getAccessTokenInternal(provider, credentials, log); } async function _getAccessTokenInternal(provider, credentials, log) { diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index ce081ea1..0c5fc563 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -21,8 +21,9 @@ const CLIENT_PING_FAST_MS = 10000; const CLIENT_PING_SLOW_MS = 60000; const CLIENT_PING_TIMEOUT_MS = 5000; -// Browser-side health probe: must reach origin (not just CF edge). -// CORS mode → res.ok=false for 5xx (Cloudflare Error 1033 when origin dead). +// 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. async function clientPingUrl(url) { if (!url) return false; try { diff --git a/src/app/(dashboard)/dashboard/proxy-pools/page.js b/src/app/(dashboard)/dashboard/proxy-pools/page.js index fcfa986d..8edf791f 100644 --- a/src/app/(dashboard)/dashboard/proxy-pools/page.js +++ b/src/app/(dashboard)/dashboard/proxy-pools/page.js @@ -577,9 +577,6 @@ export default function ProxyPoolsPage() {

Proxy Pools

-

- Manage reusable per-connection proxies and bind them to provider connections. -

diff --git a/src/app/api/tunnel/disable/route.js b/src/app/api/tunnel/disable/route.js index e0735f85..2d49e031 100644 --- a/src/app/api/tunnel/disable/route.js +++ b/src/app/api/tunnel/disable/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { disableTunnel } from "@/lib/tunnel/tunnelManager"; +import { disableTunnel } from "@/lib/tunnel"; export async function POST() { try { diff --git a/src/app/api/tunnel/enable/route.js b/src/app/api/tunnel/enable/route.js index 2a2e08d3..910ad617 100644 --- a/src/app/api/tunnel/enable/route.js +++ b/src/app/api/tunnel/enable/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { enableTunnel } from "@/lib/tunnel/tunnelManager"; +import { enableTunnel } from "@/lib/tunnel"; const DNS_WARMUP_DELAY_MS = 8000; diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js index 907cb77b..c94df58d 100644 --- a/src/app/api/tunnel/status/route.js +++ b/src/app/api/tunnel/status/route.js @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { getTunnelStatus, getTailscaleStatus } from "@/lib/tunnel/tunnelManager"; -import { getDownloadStatus } from "@/lib/tunnel/cloudflared"; +import { getTunnelStatus, getTailscaleStatus, getDownloadStatus } from "@/lib/tunnel"; export async function GET() { try { diff --git a/src/app/api/tunnel/tailscale-check/route.js b/src/app/api/tunnel/tailscale-check/route.js index f7519214..25676479 100644 --- a/src/app/api/tunnel/tailscale-check/route.js +++ b/src/app/api/tunnel/tailscale-check/route.js @@ -2,7 +2,7 @@ import os from "os"; import { exec } from "child_process"; import { promisify } from "util"; import { NextResponse } from "next/server"; -import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale"; +import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel"; import { getCachedPassword, loadEncryptedPassword } from "@/mitm/manager"; const execAsync = promisify(exec); diff --git a/src/app/api/tunnel/tailscale-disable/route.js b/src/app/api/tunnel/tailscale-disable/route.js index 67ce1600..1a0dca08 100644 --- a/src/app/api/tunnel/tailscale-disable/route.js +++ b/src/app/api/tunnel/tailscale-disable/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { disableTailscale } from "@/lib/tunnel/tunnelManager"; +import { disableTailscale } from "@/lib/tunnel"; export async function POST() { try { diff --git a/src/app/api/tunnel/tailscale-enable/route.js b/src/app/api/tunnel/tailscale-enable/route.js index 2115776a..61b7bda1 100644 --- a/src/app/api/tunnel/tailscale-enable/route.js +++ b/src/app/api/tunnel/tailscale-enable/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { enableTailscale } from "@/lib/tunnel/tunnelManager"; +import { enableTailscale } from "@/lib/tunnel"; export async function POST() { try { diff --git a/src/app/api/tunnel/tailscale-install/route.js b/src/app/api/tunnel/tailscale-install/route.js index 93353e15..f53c5eec 100644 --- a/src/app/api/tunnel/tailscale-install/route.js +++ b/src/app/api/tunnel/tailscale-install/route.js @@ -2,10 +2,9 @@ import os from "os"; import { execSync } from "child_process"; -import { installTailscale } from "@/lib/tunnel/tailscale"; +import { installTailscale, loadState, generateShortId } from "@/lib/tunnel"; import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; import { getSettings, updateSettings } from "@/lib/localDb"; -import { loadState, generateShortId } from "@/lib/tunnel/state.js"; initDbHooks(getSettings, updateSettings); diff --git a/src/app/api/tunnel/tailscale-login/route.js b/src/app/api/tunnel/tailscale-login/route.js deleted file mode 100644 index 516e4300..00000000 --- a/src/app/api/tunnel/tailscale-login/route.js +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 826f8b03..00000000 --- a/src/app/api/tunnel/tailscale-start-daemon/route.js +++ /dev/null @@ -1,21 +0,0 @@ -"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/app/layout.js b/src/app/layout.js index 3e9d10e4..b6c125c9 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -3,6 +3,7 @@ import "material-symbols/outlined.css"; import "./globals.css"; import { ThemeProvider } from "@/shared/components/ThemeProvider"; import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env +import "@/shared/services/bootstrap"; // Auto-run initializeApp (watchdog, auto-resume tunnel) import { initConsoleLogCapture } from "@/lib/consoleLogBuffer"; import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider"; diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 61af0939..989c6a1e 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -73,8 +73,6 @@ const LOCAL_ONLY_PATHS = [ "/api/tunnel/tailscale-install", "/api/tunnel/tailscale-enable", "/api/tunnel/tailscale-disable", - "/api/tunnel/tailscale-login", - "/api/tunnel/tailscale-start-daemon", "/api/tunnel/tailscale-check", "/api/tunnel/enable", "/api/tunnel/disable", diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflare/cloudflared.js similarity index 99% rename from src/lib/tunnel/cloudflared.js rename to src/lib/tunnel/cloudflare/cloudflared.js index 4eb2f207..9d90ca60 100644 --- a/src/lib/tunnel/cloudflared.js +++ b/src/lib/tunnel/cloudflare/cloudflared.js @@ -3,7 +3,7 @@ import path from "path"; import https from "https"; import os from "os"; import { execSync, spawn } from "child_process"; -import { savePid, loadPid, clearPid } from "./state.js"; +import { savePid, loadPid, clearPid } from "./pid.js"; import { DATA_DIR } from "@/lib/dataDir.js"; const BIN_DIR = path.join(DATA_DIR, "bin"); diff --git a/src/lib/tunnel/cloudflare/config.js b/src/lib/tunnel/cloudflare/config.js new file mode 100644 index 00000000..2cc3f9db --- /dev/null +++ b/src/lib/tunnel/cloudflare/config.js @@ -0,0 +1,9 @@ +// Cloudflare quick tunnel: DNS propagates fast, short timeouts OK +export const HEALTH_CHECK = { + intervalMs: 2000, + timeoutMs: 60000, + fetchTimeoutMs: 5000, + dnsTimeoutMs: 2000, +}; + +export const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us"; diff --git a/src/lib/tunnel/cloudflare/healthCheck.js b/src/lib/tunnel/cloudflare/healthCheck.js new file mode 100644 index 00000000..428b351d --- /dev/null +++ b/src/lib/tunnel/cloudflare/healthCheck.js @@ -0,0 +1,29 @@ +import { resolveDns } from "../shared/dnsResolver.js"; +import { HEALTH_CHECK } from "./config.js"; + +export async function probeUrlAlive(url) { + if (!url) return false; + let hostname; + try { hostname = new URL(url).hostname; } catch { return false; } + + if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false; + + try { + const res = await fetch(`${url}/api/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +export async function waitForHealth(url, cancelToken = { cancelled: false }) { + const start = Date.now(); + while (Date.now() - start < HEALTH_CHECK.timeoutMs) { + if (cancelToken.cancelled) throw new Error("cancelled"); + if (await probeUrlAlive(url)) return true; + await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); + } + throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); +} diff --git a/src/lib/tunnel/cloudflare/manager.js b/src/lib/tunnel/cloudflare/manager.js new file mode 100644 index 00000000..44f66ae3 --- /dev/null +++ b/src/lib/tunnel/cloudflare/manager.js @@ -0,0 +1,148 @@ +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) { + console.error(`[Tunnel] enable error: ${e.message}`); + throw e; + } finally { + svc.spawnInProgress = false; + } +} + +export async function disableTunnel() { + console.log("[Tunnel] disable"); + // Abort any in-flight enable so it cannot resurrect state after we clear it + svc.cancelToken.cancelled = true; + setUnexpectedExitHandler(null); + + try { killCloudflared(svc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); } + clearPid(); + + const state = loadState(); + if (state) saveState({ shortId: state.shortId, tunnelUrl: null }); + + await updateSettings({ tunnelEnabled: false, tunnelUrl: "" }); + // Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress + svc.spawnInProgress = false; + svc.activeLocalPort = null; + return { success: true }; +} + +export async function getTunnelStatus() { + const settings = await getSettings(); + const settingsEnabled = settings.tunnelEnabled === true; + const state = loadState(); + const shortId = state?.shortId || ""; + const publicUrl = shortId ? `https://r${shortId}.abc-tunnel.us` : ""; + const tunnelUrl = state?.tunnelUrl || ""; + + // Lazy: skip PID probe entirely when user disabled tunnel + const running = settingsEnabled ? isCloudflaredRunning() : false; + + return { + enabled: settingsEnabled && running, + settingsEnabled, + tunnelUrl, + shortId, + publicUrl, + running + }; +} diff --git a/src/lib/tunnel/cloudflare/pid.js b/src/lib/tunnel/cloudflare/pid.js new file mode 100644 index 00000000..919837c5 --- /dev/null +++ b/src/lib/tunnel/cloudflare/pid.js @@ -0,0 +1,23 @@ +import fs from "fs"; +import path from "path"; +import { TUNNEL_DIR, ensureTunnelDir } from "../shared/state.js"; + +const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid"); + +export function savePid(pid) { + ensureTunnelDir(); + fs.writeFileSync(PID_FILE, pid.toString()); +} + +export function loadPid() { + try { + if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8")); + } catch { /* ignore */ } + return null; +} + +export function clearPid() { + try { + if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + } catch { /* ignore */ } +} diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js new file mode 100644 index 00000000..19459e17 --- /dev/null +++ b/src/lib/tunnel/index.js @@ -0,0 +1,46 @@ +// 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, + isTailscaleLoggedIn, + 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, +} from "./shared/watchdogConfig.js"; diff --git a/src/lib/tunnel/networkProbe.js b/src/lib/tunnel/networkProbe.js deleted file mode 100644 index c7b6c4fb..00000000 --- a/src/lib/tunnel/networkProbe.js +++ /dev/null @@ -1,68 +0,0 @@ -import net from "net"; -import dns from "dns"; -import { INTERNET_CHECK, HEALTH_CHECK } from "./tunnelConfig.js"; - -// Force public DNS to bypass OS negative cache (mDNSResponder holds NXDOMAIN) -const resolver = new dns.promises.Resolver(); -resolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]); - -export function checkInternet() { - return new Promise((resolve) => { - const socket = new net.Socket(); - let done = false; - const finish = (ok) => { - if (done) return; - done = true; - try { socket.destroy(); } catch { /* ignore */ } - resolve(ok); - }; - socket.setTimeout(INTERNET_CHECK.timeoutMs); - socket.once("connect", () => finish(true)); - socket.once("timeout", () => finish(false)); - socket.once("error", () => finish(false)); - try { socket.connect(INTERNET_CHECK.port, INTERNET_CHECK.host); } - catch { finish(false); } - }); -} - -async function resolveDns(hostname, timeoutMs) { - // Try custom public DNS first (bypasses negative-cached NXDOMAIN on macOS). - // Fall back to OS resolver for hostnames blocked or unsupported by Cloudflare DNS - // (e.g. *.ts.net not always resolvable via 1.1.1.1). - const tryResolver = (fn) => Promise.race([ - fn(), - new Promise((_, rej) => setTimeout(() => rej(new Error("dns timeout")), timeoutMs)), - ]).then(() => true).catch(() => false); - - if (await tryResolver(() => resolver.resolve4(hostname))) return true; - return tryResolver(() => dns.promises.resolve4(hostname)); -} - -// Single health probe: DNS via 1.1.1.1 → fetch /api/health -export async function probeUrlAlive(url) { - if (!url) return false; - let hostname; - try { hostname = new URL(url).hostname; } catch { return false; } - - if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false; - - try { - const res = await fetch(`${url}/api/health`, { - signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs), - }); - return res.ok; - } catch { - return false; - } -} - -// Poll until tunnel responds /api/health, or timeout. Cancellable via token. -export async function waitForHealth(url, cancelToken = { cancelled: false }) { - const start = Date.now(); - while (Date.now() - start < HEALTH_CHECK.timeoutMs) { - if (cancelToken.cancelled) throw new Error("cancelled"); - if (await probeUrlAlive(url)) return true; - await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); - } - throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); -} diff --git a/src/lib/tunnel/shared/dnsResolver.js b/src/lib/tunnel/shared/dnsResolver.js new file mode 100644 index 00000000..e8fde7d3 --- /dev/null +++ b/src/lib/tunnel/shared/dnsResolver.js @@ -0,0 +1,17 @@ +import dns from "dns"; + +// Force public DNS to bypass OS negative cache (mDNSResponder holds NXDOMAIN) +const resolver = new dns.promises.Resolver(); +resolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]); + +// Try custom public DNS first, fall back to OS resolver +// (Cloudflare DNS may not resolve all hostnames, e.g. *.ts.net) +export async function resolveDns(hostname, timeoutMs) { + const tryResolver = (fn) => Promise.race([ + fn(), + new Promise((_, rej) => setTimeout(() => rej(new Error("dns timeout")), timeoutMs)), + ]).then(() => true).catch(() => false); + + if (await tryResolver(() => resolver.resolve4(hostname))) return true; + return tryResolver(() => dns.promises.resolve4(hostname)); +} diff --git a/src/lib/tunnel/shared/internetCheck.js b/src/lib/tunnel/shared/internetCheck.js new file mode 100644 index 00000000..756640d9 --- /dev/null +++ b/src/lib/tunnel/shared/internetCheck.js @@ -0,0 +1,26 @@ +import net from "net"; + +const INTERNET_CHECK = { + host: "1.1.1.1", + port: 443, + timeoutMs: 3000, +}; + +export function checkInternet() { + return new Promise((resolve) => { + const socket = new net.Socket(); + let done = false; + const finish = (ok) => { + if (done) return; + done = true; + try { socket.destroy(); } catch { /* ignore */ } + resolve(ok); + }; + socket.setTimeout(INTERNET_CHECK.timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + try { socket.connect(INTERNET_CHECK.port, INTERNET_CHECK.host); } + catch { finish(false); } + }); +} diff --git a/src/lib/tunnel/shared/state.js b/src/lib/tunnel/shared/state.js new file mode 100644 index 00000000..6a161814 --- /dev/null +++ b/src/lib/tunnel/shared/state.js @@ -0,0 +1,41 @@ +import fs from "fs"; +import path from "path"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +const TUNNEL_DIR = path.join(DATA_DIR, "tunnel"); +const STATE_FILE = path.join(TUNNEL_DIR, "state.json"); + +const SHORT_ID_LENGTH = 6; +const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789"; + +export function ensureTunnelDir() { + if (!fs.existsSync(TUNNEL_DIR)) fs.mkdirSync(TUNNEL_DIR, { recursive: true }); +} + +export function loadState() { + try { + if (fs.existsSync(STATE_FILE)) return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); + } catch { /* ignore corrupt state */ } + return null; +} + +export function saveState(state) { + ensureTunnelDir(); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +export function clearState() { + try { + if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); + } catch { /* ignore */ } +} + +export function generateShortId() { + let result = ""; + for (let i = 0; i < SHORT_ID_LENGTH; i++) { + result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length)); + } + return result; +} + +export { TUNNEL_DIR }; diff --git a/src/lib/tunnel/shared/watchdogConfig.js b/src/lib/tunnel/shared/watchdogConfig.js new file mode 100644 index 00000000..bcef4934 --- /dev/null +++ b/src/lib/tunnel/shared/watchdogConfig.js @@ -0,0 +1,5 @@ +// 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; diff --git a/src/lib/tunnel/state.js b/src/lib/tunnel/state.js deleted file mode 100644 index 72fcfdbc..00000000 --- a/src/lib/tunnel/state.js +++ /dev/null @@ -1,87 +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 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)) { - fs.mkdirSync(TUNNEL_DIR, { recursive: true }); - } -} - -export function loadState() { - try { - if (fs.existsSync(STATE_FILE)) { - return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); - } - } catch (e) { /* ignore corrupt state */ } - return null; -} - -export function saveState(state) { - ensureDir(); - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); -} - -export function clearState() { - try { - if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); - } catch (e) { /* ignore */ } -} - -// Cloudflare-specific PID -export function savePid(pid) { - ensureDir(); - fs.writeFileSync(CLOUDFLARED_PID_FILE, pid.toString()); -} - -export function loadPid() { - try { - if (fs.existsSync(CLOUDFLARED_PID_FILE)) { - return parseInt(fs.readFileSync(CLOUDFLARED_PID_FILE, "utf8")); - } - } catch (e) { /* ignore */ } - return null; -} - -export function clearPid() { - try { - 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/config.js b/src/lib/tunnel/tailscale/config.js new file mode 100644 index 00000000..3195ad69 --- /dev/null +++ b/src/lib/tunnel/tailscale/config.js @@ -0,0 +1,7 @@ +// Tailscale Funnel: cert provisioning + *.ts.net DNS propagation slower → longer timeouts +export const HEALTH_CHECK = { + intervalMs: 2000, + timeoutMs: 180000, + fetchTimeoutMs: 8000, + dnsTimeoutMs: 3000, +}; diff --git a/src/lib/tunnel/tailscale/healthCheck.js b/src/lib/tunnel/tailscale/healthCheck.js new file mode 100644 index 00000000..428b351d --- /dev/null +++ b/src/lib/tunnel/tailscale/healthCheck.js @@ -0,0 +1,29 @@ +import { resolveDns } from "../shared/dnsResolver.js"; +import { HEALTH_CHECK } from "./config.js"; + +export async function probeUrlAlive(url) { + if (!url) return false; + let hostname; + try { hostname = new URL(url).hostname; } catch { return false; } + + if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false; + + try { + const res = await fetch(`${url}/api/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +export async function waitForHealth(url, cancelToken = { cancelled: false }) { + const start = Date.now(); + while (Date.now() - start < HEALTH_CHECK.timeoutMs) { + if (cancelToken.cancelled) throw new Error("cancelled"); + if (await probeUrlAlive(url)) return true; + await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); + } + throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); +} diff --git a/src/lib/tunnel/tailscale/manager.js b/src/lib/tunnel/tailscale/manager.js new file mode 100644 index 00000000..4415f759 --- /dev/null +++ b/src/lib/tunnel/tailscale/manager.js @@ -0,0 +1,129 @@ +import { loadState, generateShortId } from "../shared/state.js"; +import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, 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 = isTailscaleLoggedIn(); + 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 (!isTailscaleLoggedIn() || !isTailscaleRunningStrict()) { + console.error("[Tailscale] strict probe failed (device removed?)"); + stopFunnel(); + return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." }; + } + + await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl }); + console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`); + + // Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails) + const hostname = new URL(result.tunnelUrl).hostname; + await provisionCert(hostname); + + // Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating) + let reachableNow = false; + try { + await waitForHealth(result.tunnelUrl, token); + reachableNow = true; + } catch (he) { + if (!he.message.startsWith("Health check timeout")) throw he; + console.warn(`[Tailscale] health check timed out, will retry via watchdog`); + } + console.log(`[Tailscale] enable success (reachable=${reachableNow})`); + return { success: true, tunnelUrl: result.tunnelUrl }; + } catch (e) { + console.error(`[Tailscale] enable error: ${e.message}`); + throw e; + } finally { + svc.spawnInProgress = false; + } +} + +export async function disableTailscale() { + console.log("[Tailscale] disable"); + svc.cancelToken.cancelled = true; + stopFunnel(); + await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" }); + return { success: true }; +} + +export async function getTailscaleStatus() { + const settings = await getSettings(); + const settingsEnabled = settings.tailscaleEnabled === true; + const tunnelUrl = settings.tailscaleUrl || ""; + // Skip probes entirely when disabled; check login before running (device removed = not logged in) + const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false; + const running = loggedIn ? isTailscaleRunning() : false; + return { + enabled: settingsEnabled && running, + settingsEnabled, + tunnelUrl, + running, + loggedIn + }; +} diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js similarity index 99% rename from src/lib/tunnel/tailscale.js rename to src/lib/tunnel/tailscale/tailscale.js index 88245a6a..bd636c37 100644 --- a/src/lib/tunnel/tailscale.js +++ b/src/lib/tunnel/tailscale/tailscale.js @@ -5,7 +5,6 @@ import crypto from "crypto"; import { execSync, exec, spawn } from "child_process"; import { promisify } from "util"; import { execWithPassword } from "@/mitm/dns/dnsConfig"; -import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js"; import { DATA_DIR } from "@/lib/dataDir.js"; const execAsync = promisify(exec); diff --git a/src/lib/tunnel/tunnelConfig.js b/src/lib/tunnel/tunnelConfig.js deleted file mode 100644 index d64ab588..00000000 --- a/src/lib/tunnel/tunnelConfig.js +++ /dev/null @@ -1,18 +0,0 @@ -// Tunnel + Tailscale shared config (all values in ms) -export const HEALTH_CHECK = { - intervalMs: 2000, - timeoutMs: 180000, - fetchTimeoutMs: 5000, - dnsTimeoutMs: 2000, -}; - -export const INTERNET_CHECK = { - host: "1.1.1.1", - port: 443, - timeoutMs: 3000, -}; - -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; diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js deleted file mode 100644 index bb1a4e55..00000000 --- a/src/lib/tunnel/tunnelManager.js +++ /dev/null @@ -1,276 +0,0 @@ -import { loadState, saveState, generateShortId, clearPid } from "./state.js"; -import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js"; -import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js"; -import { getSettings, updateSettings } from "@/lib/localDb"; -import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; -import { waitForHealth, probeUrlAlive } from "./networkProbe.js"; - -initDbHooks(getSettings, updateSettings); - -const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us"; - -// Per-service state (independent: tunnel ≠ tailscale) -const tunnelSvc = { - cancelToken: { cancelled: false }, - spawnInProgress: false, - lastRestartAt: 0, - activeLocalPort: null, -}; - -const tailscaleSvc = { - cancelToken: { cancelled: false }, - spawnInProgress: false, - lastRestartAt: 0, - activeLocalPort: null, -}; - -export function getTunnelService() { return tunnelSvc; } -export function getTailscaleService() { return tailscaleSvc; } - -export function isTunnelManuallyDisabled() { return tunnelSvc.cancelToken.cancelled; } -export function isTunnelReconnecting() { return tunnelSvc.spawnInProgress; } -export function isTailscaleReconnecting() { return tailscaleSvc.spawnInProgress; } - -// Callback invoked when cloudflared exits unexpectedly (set by initializeApp) -let onTunnelUnexpectedExit = null; -export function setTunnelUnexpectedExitCallback(cb) { onTunnelUnexpectedExit = cb; } - -// ─── Cloudflare Tunnel ─────────────────────────────────────────────────────── - -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, label) { - if (token.cancelled) throw new Error(`${label} cancelled`); -} - -export async function enableTunnel(localPort = 20128) { - console.log(`[Tunnel] enable start (port=${localPort})`); - tunnelSvc.cancelToken = { cancelled: false }; - tunnelSvc.activeLocalPort = localPort; - tunnelSvc.spawnInProgress = true; - const token = tunnelSvc.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, "tunnel"); - - 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 (onTunnelUnexpectedExit) onTunnelUnexpectedExit(); - }); - - const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate); - console.log(`[Tunnel] spawned: ${tunnelUrl}`); - throwIfCancelled(token, "tunnel"); - - 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 on some networks - 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) { - console.error(`[Tunnel] enable error: ${e.message}`); - throw e; - } finally { - tunnelSvc.spawnInProgress = false; - } -} - -export async function disableTunnel() { - console.log("[Tunnel] disable"); - // Abort any in-flight enable so it cannot resurrect state after we clear it - tunnelSvc.cancelToken.cancelled = true; - setUnexpectedExitHandler(null); - - try { killCloudflared(tunnelSvc.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 - tunnelSvc.spawnInProgress = false; - tunnelSvc.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 - }; -} - -// ─── Tailscale Funnel ───────────────────────────────────────────────────────── - -export async function enableTailscale(localPort = 20128) { - console.log(`[Tailscale] enable start (port=${localPort})`); - tailscaleSvc.cancelToken = { cancelled: false }; - tailscaleSvc.activeLocalPort = localPort; - tailscaleSvc.spawnInProgress = true; - const token = tailscaleSvc.cancelToken; - - try { - const sudoPass = getCachedPassword() || await loadEncryptedPassword() || ""; - await startDaemonWithPassword(sudoPass); - console.log("[Tailscale] daemon ready"); - throwIfCancelled(token, "tailscale"); - - const existing = loadState(); - const shortId = existing?.shortId || generateShortId(); - const tsHostname = shortId; - - const loggedIn = isTailscaleLoggedIn(); - 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, "tailscale"); - - 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, "tailscale"); - - 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 (!isTailscaleLoggedIn() || !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 { - tailscaleSvc.spawnInProgress = false; - } -} - -export async function disableTailscale() { - console.log("[Tailscale] disable"); - tailscaleSvc.cancelToken.cancelled = true; - stopFunnel(); - await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" }); - return { success: true }; -} - -export async function getTailscaleStatus() { - const settings = await getSettings(); - const settingsEnabled = settings.tailscaleEnabled === true; - const tunnelUrl = settings.tailscaleUrl || ""; - // Skip probes entirely when disabled; check login before running (device removed = not logged in) - const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false; - const running = loggedIn ? isTailscaleRunning() : false; - return { - enabled: settingsEnabled && running, - settingsEnabled, - tunnelUrl, - running, - loggedIn - }; -} diff --git a/src/server-init.js b/src/server-init.js deleted file mode 100644 index b1d7cd47..00000000 --- a/src/server-init.js +++ /dev/null @@ -1,17 +0,0 @@ -import initializeApp from "./shared/services/initializeApp.js"; - -async function startServer() { - console.log("Starting server..."); - - try { - await initializeApp(); - console.log("Server initialized"); - } catch (error) { - console.log("Error initializing server:", error); - process.exit(1); - } -} - -startServer().catch(console.log); - -export default startServer; diff --git a/src/shared/services/bootstrap.js b/src/shared/services/bootstrap.js new file mode 100644 index 00000000..8080c3b2 --- /dev/null +++ b/src/shared/services/bootstrap.js @@ -0,0 +1,7 @@ +import initializeApp from "./initializeApp.js"; + +// Server-only singleton: guard via global so HMR / re-imports don't double-init +if (typeof window === "undefined" && !global.__appBootstrapped) { + global.__appBootstrapped = true; + initializeApp().catch((e) => console.error("[Bootstrap] init failed:", e.message)); +} diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index 30c7cff0..41e41a23 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -7,15 +7,14 @@ import { enableTunnel, enableTailscale, isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting, getTunnelService, getTailscaleService, setTunnelUnexpectedExitCallback, -} from "@/lib/tunnel/tunnelManager"; -import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared"; -import { isTailscaleRunning } from "@/lib/tunnel/tailscale"; -import { loadState } from "@/lib/tunnel/state"; -import { checkInternet, probeUrlAlive } from "@/lib/tunnel/networkProbe"; -import { + killCloudflared, isCloudflaredRunning, ensureCloudflared, + isTailscaleRunning, + loadState, + checkInternet, + probeCloudflareAlive, probeTailscaleAlive, RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS, WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, -} from "@/lib/tunnel/tunnelConfig"; +} from "@/lib/tunnel"; import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager"; import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache"; @@ -130,6 +129,10 @@ async function autoStartMitm() { } } +// 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) { @@ -138,27 +141,33 @@ async function safeRestartTunnel(reason) { if (!settings.tunnelEnabled) return; if (svc.cancelToken.cancelled) return; if (svc.spawnInProgress) return; - // Bypass cooldown when process is dead (real respawn, not restart-loop guard) - const processDead = !isCloudflaredRunning(); - if (!processDead && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return; - // Alive check: process up + BOTH direct & public URL respond → skip + // Alive check FIRST: probe URLs to decide health (process up but tunnel 530 = dead) + let alive = false; if (isCloudflaredRunning()) { const state = loadState(); const publicUrl = state?.shortId ? `https://r${state.shortId}.abc-tunnel.us` : null; const directUrl = state?.tunnelUrl || null; if (publicUrl && directUrl) { const [publicOk, directOk] = await Promise.all([ - probeUrlAlive(publicUrl), - probeUrlAlive(directUrl), + probeCloudflareAlive(publicUrl), + probeCloudflareAlive(directUrl), ]); - if (publicOk && directOk) return; + alive = publicOk && directOk; } } + if (alive) return; + // Degraded/dead → cooldown only prevents hammer loop after a recent restart attempt. + // Bypass for network transitions (one-shot events) so user recovers fast after wifi change. + const force = FORCE_RESTART_REASONS.test(reason); + 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})`); + console.log(`[Tunnel] safeRestart (${reason}) — tunnel unreachable${force ? " [force]" : ""}`); try { await enableTunnel(); svc.lastRestartAt = Date.now(); @@ -174,15 +183,22 @@ async function safeRestartTailscale(reason) { if (!settings.tailscaleEnabled) return; if (svc.cancelToken.cancelled) return; if (svc.spawnInProgress) return; - if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return; + // Alive check FIRST: daemon up + URL responds = healthy + let alive = false; if (isTailscaleRunning() && settings.tailscaleUrl) { - if (await probeUrlAlive(settings.tailscaleUrl)) return; + alive = await probeTailscaleAlive(settings.tailscaleUrl); } + if (alive) 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})`); + console.log(`[Tailscale] safeRestart (${reason}) — tunnel unreachable${force ? " [force]" : ""}`); try { await enableTailscale(); svc.lastRestartAt = Date.now();