From 7c4bff21914ba7d45608f9be1f6d6f851bc294b4 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Mon, 31 Aug 2026 23:54:57 +0800 Subject: [PATCH] fix(tunnel): stabilize cloudflared on Windows with QUIC protocol, non-blocking process management, and resilient connectivity checks --- backend/src/controllers/userController.js | 28 +-- backend/src/services/tunnelService.js | 262 ++++++++++------------ backend/start_tunnel.bat | 2 +- 3 files changed, 130 insertions(+), 162 deletions(-) diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index 04b799e..2141efc 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -4,33 +4,35 @@ const crypto = require('crypto'); const os = require('os'); const { createNotification } = require('./notificationController'); const { sendPasswordResetLink } = require('../services/emailService'); +const { getDetectedTunnelUrl } = require('../services/tunnelService'); // ── Base URL Helper (Auto-detects Tunnel vs LAN IP vs Domain) ──────────────── const getBestServerBaseUrl = (req) => { - // 1. Explicit environment overrides (PUBLIC_URL, APP_URL, CLOUDFLARE_TUNNEL_PUBLIC_URL) - const envPublicUrl = process.env.PUBLIC_URL || process.env.APP_URL || process.env.CLOUDFLARE_TUNNEL_PUBLIC_URL; - if (envPublicUrl && envPublicUrl.trim()) { - return envPublicUrl.trim().replace(/\/+$/, ''); + // 1. Explicit environment override + if (process.env.APP_URL && process.env.APP_URL.trim()) { + return process.env.APP_URL.trim().replace(/\/+$/, ''); } - // 2. Database system_settings configured public URL - try { - const row = db.prepare("SELECT value FROM system_settings WHERE key IN ('public_server_url', 'app_url', 'public_url') ORDER BY updated_at DESC LIMIT 1").get(); - if (row && row.value && row.value.trim()) { - return row.value.trim().replace(/\/+$/, ''); - } - } catch (_) {} - const forwardedProto = req.headers['x-forwarded-proto']; const forwardedHost = req.headers['x-forwarded-host']; const proto = forwardedProto || req.protocol || 'http'; let host = forwardedHost || req.get('host') || `localhost:${process.env.PORT || 18484}`; - // 3. If caller is connecting via a real remote hostname / public domain / LAN IP (not localhost/127.0.0.1) + // 2. If caller is connecting via a real remote hostname / public domain / LAN IP (not localhost/127.0.0.1) if (!host.startsWith('localhost') && !host.startsWith('127.0.0.1')) { return `${proto}://${host}`; } + // 3. If live Cloudflare Tunnel is connected and detected + const activeTunnelUrl = getDetectedTunnelUrl(); + if (activeTunnelUrl) { + return activeTunnelUrl; + } + + if (process.env.CLOUDFLARE_TUNNEL_PUBLIC_URL && process.env.CLOUDFLARE_TUNNEL_PUBLIC_URL.trim()) { + return process.env.CLOUDFLARE_TUNNEL_PUBLIC_URL.trim().replace(/\/+$/, ''); + } + // 4. Fallback: Automatically detect primary local IPv4 address of this server on the LAN const port = process.env.PORT || 18484; const interfaces = os.networkInterfaces(); diff --git a/backend/src/services/tunnelService.js b/backend/src/services/tunnelService.js index 4c6746a..975f511 100644 --- a/backend/src/services/tunnelService.js +++ b/backend/src/services/tunnelService.js @@ -1,6 +1,6 @@ 'use strict'; -const { spawn, execSync } = require('child_process'); +const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const dns = require('dns'); @@ -12,8 +12,9 @@ let monitorTimer = null; let lastInternetCheck = false; let lastCheckedTime = null; let lastLogMessage = ''; -let tunnelState = 'stopped'; // 'stopped', 'starting', 'connected', 'error', 'no_internet' -let retryCount = 0; +let tunnelState = 'stopped'; // 'stopped', 'starting', 'connected', 'error', 'no_internet', 'disabled' +let consecutiveFailures = 0; +let detectedTunnelHostname = null; /** * Finds the cloudflared executable path. @@ -35,11 +36,11 @@ function getCloudflaredPath() { const binaryName = isWindows ? 'cloudflared.exe' : 'cloudflared'; const candidatePaths = [ - path.join(__dirname, '..', '..', '..', 'bin', binaryName), path.join(__dirname, '..', '..', 'bin', binaryName), path.join(__dirname, '..', 'bin', binaryName), path.join(process.cwd(), 'bin', binaryName), path.join(process.cwd(), '..', 'bin', binaryName), + path.join(__dirname, '..', '..', '..', 'bin', binaryName), ]; for (const cand of candidatePaths) { @@ -49,84 +50,47 @@ function getCloudflaredPath() { } } - try { - const cmd = isWindows ? 'where.exe cloudflared' : 'which cloudflared'; - const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); - const first = out.trim().split(/\r?\n/)[0]; - if (first && fs.existsSync(first)) { - setExecutable(first); - return first; - } - } catch (_) {} - return null; } -const dnsPromises = dns.promises; -const publicResolver = new dnsPromises.Resolver(); -publicResolver.setServers(['1.1.1.1', '1.0.0.1', '8.8.8.8', '8.8.4.4']); - -let consecutiveFailures = 0; -const MAX_CONSECUTIVE_FAILURES = 4; // 4 * 15s = 60s of sustained outage before killing - /** - * Checks if cloudflared is already running as an external process (e.g. start_tunnel.bat). + * Robust check for internet connectivity. + * Tests multiple DNS endpoints and falls back to HTTPS. */ -function isCloudflaredAlreadyRunning() { - if (process.platform !== 'win32') return false; - try { - const out = execSync('tasklist /FI "IMAGENAME eq cloudflared.exe" /NH', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'] +function checkInternet(timeoutMs = 5000) { + return new Promise((resolve) => { + let isResolved = false; + const done = (val) => { + if (!isResolved) { + isResolved = true; + clearTimeout(timer); + resolve(val); + } + }; + + const timer = setTimeout(() => done(false), timeoutMs); + + // Try DNS resolution on multiple reliable hosts in parallel + dns.lookup('cloudflare.com', (err1) => { + if (!err1) return done(true); + + dns.lookup('1.1.1.1', (err2) => { + if (!err2) return done(true); + + // Fallback HTTPS request + const req = https.get('https://1.1.1.1', { timeout: 3000 }, (res) => { + res.destroy(); + done(true); + }); + + req.on('error', () => done(false)); + req.on('timeout', () => { + req.destroy(); + done(false); + }); + }); }); - return out.toLowerCase().includes('cloudflared.exe'); - } catch (_) { - return false; - } -} - -/** - * Robust, multi-tiered internet check. - * Uses Direct Public DNS (bypassing local router/LAN DNS), OS DNS, and HTTP/HTTPS ping in parallel. - */ -async function checkInternet(timeoutMs = 4500) { - const checks = [ - // 1. Direct Public DNS query bypassing local LAN router DNS - publicResolver.resolve('cloudflare.com').then(() => true).catch(() => false), - - // 2. OS default DNS query - new Promise((resolve) => { - dns.lookup('cloudflare.com', (err) => resolve(!err)); - }), - - // 3. Direct HTTPS request to Cloudflare 1.1.1.1 - new Promise((resolve) => { - const req = https.get('https://1.1.1.1', { timeout: 3500 }, (res) => { - res.destroy(); - resolve(true); - }); - req.on('error', () => resolve(false)); - req.on('timeout', () => { req.destroy(); resolve(false); }); - }), - - // 4. HTTP request to Google 204 connectivity endpoint - new Promise((resolve) => { - const http = require('http'); - const req = http.get('http://clients3.google.com/generate_204', { timeout: 3500 }, (res) => { - res.destroy(); - resolve(res.statusCode === 204 || res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.on('timeout', () => { req.destroy(); resolve(false); }); - }) - ]; - - try { - const results = await Promise.all(checks); - return results.some((r) => r === true); - } catch (_) { - return false; - } + }); } /** @@ -134,15 +98,6 @@ async function checkInternet(timeoutMs = 4500) { */ async function startTunnel() { if (tunnelProcess || isStarting) return; - - // Check if cloudflared is already active externally (e.g. from start_tunnel.bat) - if (isCloudflaredAlreadyRunning()) { - tunnelState = 'connected'; - lastLogMessage = 'External cloudflared process detected and running.'; - console.log('[Tunnel] External cloudflared process detected. Attached to active tunnel.'); - return; - } - isStarting = true; const binPath = getCloudflaredPath(); @@ -156,52 +111,55 @@ async function startTunnel() { const DEFAULT_TOKEN = 'eyJhIjoiZWRhMWQ4ZTc1MzNjMjBiMDcyNmM0ZGU1OWE5YTMxYzgiLCJ0IjoiZjJhOGYyYmMtMWE1YS00MmNmLWJjZTUtZWMzYzAxNzY4M2IyIiwicyI6Ik5EbGtOMkZpTldNdFpEYzVNUzAwTUdFMUxXSTFNalV0WW1RNVl6VXlaV1EzTVRWaiJ9'; const rawToken = process.env.CLOUDFLARE_TUNNEL_TOKEN || DEFAULT_TOKEN; - const token = rawToken.replace(/[\r\n\s'"=]/g, '').trim(); // Strip any hidden CR/LF, spaces, or quotes + const token = rawToken.replace(/[\r\n\s'"=]/g, '').trim(); const port = process.env.PORT || 18484; - // Use http2 on Windows to avoid QUIC UDP handshake stalls + // Use default auto-negotiated protocol (QUIC with HTTP/2 fallback) and --no-autoupdate const args = token - ? ['tunnel', '--protocol', 'http2', 'run', '--token', token] - : ['tunnel', '--protocol', 'http2', '--url', `http://localhost:${port}`]; + ? ['tunnel', '--no-autoupdate', 'run', '--token', token] + : ['tunnel', '--no-autoupdate', '--url', `http://localhost:${port}`]; - console.log(`[Tunnel] Launching Cloudflare tunnel: "${binPath}" ${token ? 'tunnel --protocol http2 run --token [CONFIGURED]' : args.join(' ')}`); + console.log(`[Tunnel] Launching Cloudflare tunnel: "${binPath}" ${token ? 'tunnel --no-autoupdate run --token [CONFIGURED]' : args.join(' ')}`); tunnelState = 'starting'; try { tunnelProcess = spawn(binPath, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, NO_AUTOUPDATE: 'true', TUNNEL_METRICS: 'localhost:0' } + env: { ...process.env, NO_AUTOUPDATE: 'true' } }); const pid = tunnelProcess.pid; console.log(`[Tunnel] Process started (PID: ${pid})`); - tunnelProcess.stdout.on('data', (data) => { + const handleLogStream = (data) => { const str = data.toString(); - lastLogMessage = str.trim().split('\n').pop() || lastLogMessage; - if (str.includes('Registered tunnel connection') || (str.includes('Connection') && str.includes('registered')) || str.includes('Updated to new configuration')) { - tunnelState = 'connected'; - console.log(`[Tunnel] Status: Connected to Cloudflare edge network.`); - } - }); + const lines = str.trim().split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + lastLogMessage = trimmed; - tunnelProcess.stderr.on('data', (data) => { - const str = data.toString(); - lastLogMessage = str.trim().split('\n').pop() || lastLogMessage; - if (str.includes('Registered tunnel connection') || (str.includes('Connection') && str.includes('registered')) || str.includes('Updated to new configuration')) { - tunnelState = 'connected'; - console.log(`[Tunnel] Status: Connected to Cloudflare edge network.`); - } else if (str.includes('ERR') || str.includes('Unauthorized')) { - if (str.includes('Unauthorized') || str.includes('Invalid tunnel secret')) { - tunnelState = 'error'; - console.error('[Tunnel] Authorization error: Invalid tunnel secret.'); + // Extract configured hostname if present in ingress logs + const hostMatch = trimmed.match(/"hostname":\s*"([^"]+)"/); + if (hostMatch && hostMatch[1]) { + detectedTunnelHostname = hostMatch[1]; + } + + if (trimmed.includes('Registered tunnel connection') || + (trimmed.includes('Connection') && trimmed.includes('registered')) || + trimmed.includes('Tunnel connection curve preferences')) { + tunnelState = 'connected'; + consecutiveFailures = 0; } } - }); + }; + + tunnelProcess.stdout.on('data', handleLogStream); + tunnelProcess.stderr.on('data', handleLogStream); tunnelProcess.on('error', (err) => { - console.error('[Tunnel] Process spawn error:', err.message); + console.error('[Tunnel] Process error:', err.message); tunnelState = 'error'; lastLogMessage = err.message; tunnelProcess = null; @@ -212,15 +170,16 @@ async function startTunnel() { console.log(`[Tunnel] Process exited (Code: ${code}, Signal: ${signal})`); tunnelProcess = null; isStarting = false; - if (tunnelState !== 'no_internet') { + if (tunnelState !== 'no_internet' && tunnelState !== 'disabled') { tunnelState = 'stopped'; } }); - // Mark as connected after a short period if still alive + // If still alive after 4 seconds, mark as connected setTimeout(() => { if (tunnelProcess && tunnelState === 'starting') { tunnelState = 'connected'; + consecutiveFailures = 0; } }, 4000); @@ -235,32 +194,35 @@ async function startTunnel() { } /** - * Stops the Cloudflare Tunnel child process. + * Stops the Cloudflare Tunnel child process cleanly and non-blockingly. */ function stopTunnel() { if (!tunnelProcess) return; + const proc = tunnelProcess; + tunnelProcess = null; + tunnelState = 'stopped'; + try { - console.log(`[Tunnel] Stopping tunnel process (PID: ${tunnelProcess.pid})...`); - const pid = tunnelProcess.pid; + console.log(`[Tunnel] Stopping tunnel process (PID: ${proc.pid})...`); if (process.platform === 'win32') { - try { - execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }); - } catch (_) { - try { tunnelProcess.kill('SIGTERM'); } catch (__) {} - } + // Asynchronous non-blocking taskkill + const killer = spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { + windowsHide: true, + stdio: 'ignore' + }); + killer.on('error', () => { + try { proc.kill('SIGTERM'); } catch (_) {} + }); } else { - tunnelProcess.kill('SIGTERM'); + proc.kill('SIGTERM'); } } catch (err) { console.warn('[Tunnel] Error stopping tunnel:', err.message); - } finally { - tunnelProcess = null; - tunnelState = 'stopped'; } } /** - * Periodic check loop: checks internet and maintains tunnel state. + * Periodic check loop: checks internet and maintains tunnel state resiliently. */ async function checkAndMaintainTunnel() { const isEnabled = process.env.CLOUDFLARE_TUNNEL_ENABLED !== 'false'; @@ -270,35 +232,25 @@ async function checkAndMaintainTunnel() { return; } - // If an external cloudflared process is already running, preserve connected state - if (!tunnelProcess && isCloudflaredAlreadyRunning()) { - tunnelState = 'connected'; - lastLogMessage = 'External cloudflared process detected and active.'; - lastInternetCheck = true; - lastCheckedTime = new Date().toISOString(); - return; - } - - const hasInternet = await checkInternet(); + const hasInternet = await checkInternet(5000); lastInternetCheck = hasInternet; lastCheckedTime = new Date().toISOString(); if (hasInternet) { consecutiveFailures = 0; if (!tunnelProcess && !isStarting) { - console.log('[Tunnel] Internet connection detected. Activating auto tunnel...'); + console.log('[Tunnel] Internet connection available. Starting Cloudflare tunnel...'); await startTunnel(); } } else { consecutiveFailures++; - if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - if (tunnelProcess) { - console.warn('[Tunnel] Sustained internet outage (>60s). Suspending tunnel until internet is restored...'); - tunnelState = 'no_internet'; - stopTunnel(); - } else { - tunnelState = 'no_internet'; - } + console.warn(`[Tunnel] Internet check failed (${consecutiveFailures}/3)`); + + // Only suspend tunnel after 3 consecutive failures (approx 90s) + if (consecutiveFailures >= 3 && tunnelProcess) { + console.warn('[Tunnel] Persistent internet loss. Suspending tunnel...'); + tunnelState = 'no_internet'; + stopTunnel(); } } } @@ -316,11 +268,11 @@ function startAutoTunnel() { console.log('[Tunnel] Initializing Auto Cloudflare Tunnel Service...'); - // Initial check immediately - checkAndMaintainTunnel(); + // Initial check after a short server startup delay + setTimeout(checkAndMaintainTunnel, 2000); - // Periodic check interval (default: 15 seconds) - const intervalMs = parseInt(process.env.CLOUDFLARE_TUNNEL_CHECK_INTERVAL, 10) || 15000; + // Periodic check interval (default: 30 seconds to prevent aggressive CPU/DNS churn) + const intervalMs = parseInt(process.env.CLOUDFLARE_TUNNEL_CHECK_INTERVAL, 10) || 30000; if (monitorTimer) clearInterval(monitorTimer); monitorTimer = setInterval(checkAndMaintainTunnel, intervalMs); @@ -349,16 +301,30 @@ function getTunnelStatus() { isRunning: !!tunnelProcess, pid: tunnelProcess ? tunnelProcess.pid : null, status: tunnelState, + hostname: detectedTunnelHostname, + publicUrl: detectedTunnelHostname ? `https://${detectedTunnelHostname}` : null, lastChecked: lastCheckedTime, lastLog: lastLogMessage }; } +/** + * Returns detected public tunnel URL (if active and discovered) + */ +function getDetectedTunnelUrl() { + if (tunnelState === 'connected' && detectedTunnelHostname) { + return `https://${detectedTunnelHostname}`; + } + return null; +} + module.exports = { startAutoTunnel, startTunnel, stopTunnel, getTunnelStatus, + getDetectedTunnelUrl, checkInternet, getCloudflaredPath }; + diff --git a/backend/start_tunnel.bat b/backend/start_tunnel.bat index bb2f6e9..9989853 100644 --- a/backend/start_tunnel.bat +++ b/backend/start_tunnel.bat @@ -11,5 +11,5 @@ if not exist "%~dp0bin\cloudflared.exe" ( ) echo [INFO] Connecting to Cloudflare Tunnel... -"%~dp0bin\cloudflared.exe" tunnel --protocol http2 run --token eyJhIjoiZWRhMWQ4ZTc1MzNjMjBiMDcyNmM0ZGU1OWE5YTMxYzgiLCJ0IjoiZjJhOGYyYmMtMWE1YS00MmNmLWJjZTUtZWMzYzAxNzY4M2IyIiwicyI6Ik5EbGtOMkZpTldNdFpEYzVNUzAwTUdFMUxXSTFNalV0WW1RNVl6VXlaV1EzTVRWaiJ9 +"%~dp0bin\cloudflared.exe" tunnel run --token eyJhIjoiZWRhMWQ4ZTc1MzNjMjBiMDcyNmM0ZGU1OWE5YTMxYzgiLCJ0IjoiZjJhOGYyYmMtMWE1YS00MmNmLWJjZTUtZWMzYzAxNzY4M2IyIiwicyI6Ik9EUXpZMkUyT0RNdE0yVmxNaTAwTjJVMUxXRmlZVFl0TkRKak1HSmpOR0l4WTJReSJ9 pause