2026-02-21 04:42:46 -05:00
import fs from "fs" ;
import path from "path" ;
import https from "https" ;
import os from "os" ;
import { execSync , spawn } from "child_process" ;
import { savePid , loadPid , clearPid } from "./state.js" ;
2026-04-15 23:58:35 -04:00
import { DATA _DIR } from "@/lib/dataDir.js" ;
2026-02-21 04:42:46 -05:00
2026-04-15 23:58:35 -04:00
const BIN _DIR = path . join ( DATA _DIR , "bin" ) ;
2026-02-21 04:42:46 -05:00
const BINARY _NAME = "cloudflared" ;
const IS _WINDOWS = os . platform ( ) === "win32" ;
const BIN _NAME = IS _WINDOWS ? ` ${ BINARY _NAME } .exe ` : BINARY _NAME ;
const BIN _PATH = path . join ( BIN _DIR , BIN _NAME ) ;
2026-04-29 06:28:38 -04:00
const POWERSHELL _HIDDEN _COMMAND = "powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command" ;
2026-05-07 04:50:36 -04:00
const DEFAULT _QUICK _TUNNEL _PROTOCOL = "http2" ;
const QUICK _TUNNEL _PROTOCOLS = new Set ( [ "http2" , "quic" , "auto" ] ) ;
2026-02-21 04:42:46 -05:00
2026-04-15 23:58:35 -04:00
const GITHUB _BASE _URL = "https://github.com/cloudflare/cloudflared/releases/latest/download" ;
2026-02-21 04:42:46 -05:00
const PLATFORM _MAPPINGS = {
darwin : {
x64 : "cloudflared-darwin-amd64.tgz" ,
2026-04-08 23:16:11 -04:00
arm64 : "cloudflared-darwin-arm64.tgz"
2026-02-21 04:42:46 -05:00
} ,
win32 : {
2026-04-08 23:16:11 -04:00
x64 : "cloudflared-windows-amd64.exe" ,
2026-04-15 23:58:35 -04:00
ia32 : "cloudflared-windows-386.exe" ,
arm64 : "cloudflared-windows-386.exe"
2026-02-21 04:42:46 -05:00
} ,
linux : {
x64 : "cloudflared-linux-amd64" ,
arm64 : "cloudflared-linux-arm64"
}
} ;
2026-04-15 23:58:35 -04:00
// Fallback order: prefer smallest/most-compatible binary per platform
const PLATFORM _FALLBACK = {
darwin : "cloudflared-darwin-amd64.tgz" ,
win32 : "cloudflared-windows-386.exe" ,
linux : "cloudflared-linux-amd64"
} ;
2026-02-21 04:42:46 -05:00
function getDownloadUrl ( ) {
const platform = os . platform ( ) ;
const arch = os . arch ( ) ;
const platformMapping = PLATFORM _MAPPINGS [ platform ] ;
if ( ! platformMapping ) {
throw new Error ( ` Unsupported platform: ${ platform } ` ) ;
}
2026-04-15 23:58:35 -04:00
const binaryName = platformMapping [ arch ] || PLATFORM _FALLBACK [ platform ] ;
2026-02-21 04:42:46 -05:00
return ` ${ GITHUB _BASE _URL } / ${ binaryName } ` ;
}
2026-04-15 23:58:35 -04:00
// Download state — shared so status API can read it
const dlState = { downloading : false , progress : 0 } ;
export function getDownloadStatus ( ) {
return { downloading : dlState . downloading , progress : dlState . progress } ;
}
2026-02-21 04:42:46 -05:00
function downloadFile ( url , dest ) {
return new Promise ( ( resolve , reject ) => {
const file = fs . createWriteStream ( dest ) ;
https . get ( url , ( response ) => {
2026-04-15 23:58:35 -04:00
if ( [ 301 , 302 , 303 , 307 , 308 ] . includes ( response . statusCode ) ) {
2026-02-21 04:42:46 -05:00
file . close ( ) ;
fs . unlinkSync ( dest ) ;
downloadFile ( response . headers . location , dest ) . then ( resolve ) . catch ( reject ) ;
return ;
}
if ( response . statusCode !== 200 ) {
file . close ( ) ;
fs . unlinkSync ( dest ) ;
reject ( new Error ( ` Download failed with status ${ response . statusCode } ` ) ) ;
return ;
}
2026-04-15 23:58:35 -04:00
const totalBytes = parseInt ( response . headers [ "content-length" ] , 10 ) || 0 ;
let receivedBytes = 0 ;
dlState . downloading = true ;
dlState . progress = 0 ;
response . on ( "data" , ( chunk ) => {
receivedBytes += chunk . length ;
if ( totalBytes > 0 ) dlState . progress = Math . round ( ( receivedBytes / totalBytes ) * 100 ) ;
} ) ;
2026-02-21 04:42:46 -05:00
response . pipe ( file ) ;
file . on ( "finish" , ( ) => {
2026-04-15 23:58:35 -04:00
dlState . downloading = false ;
dlState . progress = 100 ;
2026-02-21 04:42:46 -05:00
file . close ( ( ) => resolve ( dest ) ) ;
} ) ;
file . on ( "error" , ( err ) => {
2026-04-15 23:58:35 -04:00
dlState . downloading = false ;
dlState . progress = 0 ;
2026-02-21 04:42:46 -05:00
file . close ( ) ;
fs . unlinkSync ( dest ) ;
reject ( err ) ;
} ) ;
} ) . on ( "error" , ( err ) => {
2026-04-15 23:58:35 -04:00
dlState . downloading = false ;
dlState . progress = 0 ;
2026-02-21 04:42:46 -05:00
file . close ( ) ;
if ( fs . existsSync ( dest ) ) fs . unlinkSync ( dest ) ;
reject ( err ) ;
} ) ;
} ) ;
}
2026-04-15 23:58:35 -04:00
const MIN _BINARY _SIZE = 1024 * 1024 ; // 1MB - cloudflared is ~30MB+
// Validate binary is executable on current platform and not truncated
function isValidBinary ( filePath ) {
try {
const stat = fs . statSync ( filePath ) ;
if ( stat . size < MIN _BINARY _SIZE ) return false ;
const fd = fs . openSync ( filePath , "r" ) ;
const buf = Buffer . alloc ( 4 ) ;
fs . readSync ( fd , buf , 0 , 4 , 0 ) ;
fs . closeSync ( fd ) ;
const magic = buf . toString ( "hex" ) ;
if ( IS _WINDOWS ) return magic . startsWith ( "4d5a" ) ; // PE (MZ)
if ( os . platform ( ) === "darwin" ) return magic . startsWith ( "cffaedfe" ) || magic . startsWith ( "cefaedfe" ) ;
return magic . startsWith ( "7f454c46" ) ; // ELF (Linux)
} catch {
return false ;
}
}
let downloadPromise = null ;
2026-02-21 04:42:46 -05:00
export async function ensureCloudflared ( ) {
2026-04-15 23:58:35 -04:00
if ( downloadPromise ) return downloadPromise ;
downloadPromise = _ensureCloudflared ( ) . finally ( ( ) => { downloadPromise = null ; } ) ;
return downloadPromise ;
}
async function _ensureCloudflared ( ) {
2026-02-21 04:42:46 -05:00
if ( ! fs . existsSync ( BIN _DIR ) ) {
fs . mkdirSync ( BIN _DIR , { recursive : true } ) ;
}
2026-04-15 23:58:35 -04:00
// Clean up incomplete downloads from previous runs
const tmpPath = ` ${ BIN _PATH } .tmp ` ;
if ( fs . existsSync ( tmpPath ) ) {
try { fs . unlinkSync ( tmpPath ) ; } catch { /* ignore */ }
}
2026-02-21 04:42:46 -05:00
if ( fs . existsSync ( BIN _PATH ) ) {
2026-04-15 23:58:35 -04:00
if ( ! isValidBinary ( BIN _PATH ) ) {
console . log ( "[cloudflared] Invalid binary detected, re-downloading..." ) ;
fs . unlinkSync ( BIN _PATH ) ;
} else {
if ( ! IS _WINDOWS ) fs . chmodSync ( BIN _PATH , "755" ) ;
return BIN _PATH ;
2026-02-21 04:42:46 -05:00
}
}
const url = getDownloadUrl ( ) ;
const isArchive = url . endsWith ( ".tgz" ) ;
2026-04-15 23:58:35 -04:00
const downloadDest = isArchive ? path . join ( BIN _DIR , "cloudflared.tgz.tmp" ) : tmpPath ;
2026-02-21 04:42:46 -05:00
await downloadFile ( url , downloadDest ) ;
if ( isArchive ) {
2026-04-14 00:48:59 -04:00
execSync ( ` tar -xzf " ${ downloadDest } " -C " ${ BIN _DIR } " ` , { stdio : "pipe" , windowsHide : true } ) ;
2026-02-21 04:42:46 -05:00
fs . unlinkSync ( downloadDest ) ;
2026-04-15 23:58:35 -04:00
} else {
fs . renameSync ( downloadDest , BIN _PATH ) ;
2026-02-21 04:42:46 -05:00
}
if ( ! IS _WINDOWS ) {
fs . chmodSync ( BIN _PATH , "755" ) ;
}
return BIN _PATH ;
}
let cloudflaredProcess = null ;
2026-02-22 09:44:11 -05:00
let unexpectedExitHandler = null ;
/** Register a callback to be called when cloudflared exits unexpectedly after connecting */
export function setUnexpectedExitHandler ( handler ) {
unexpectedExitHandler = handler ;
}
2026-02-21 04:42:46 -05:00
export async function spawnCloudflared ( tunnelToken ) {
const binaryPath = await ensureCloudflared ( ) ;
const child = spawn ( binaryPath , [ "tunnel" , "run" , "--dns-resolver-addrs" , "1.1.1.1:53" , "--token" , tunnelToken ] , {
detached : false ,
2026-03-15 22:11:19 -04:00
windowsHide : true ,
2026-02-21 04:42:46 -05:00
stdio : [ "ignore" , "pipe" , "pipe" ]
} ) ;
cloudflaredProcess = child ;
savePid ( child . pid ) ;
return new Promise ( ( resolve , reject ) => {
let connectionCount = 0 ;
2026-02-22 09:44:11 -05:00
let resolved = false ;
2026-02-21 04:42:46 -05:00
const timeout = setTimeout ( ( ) => {
2026-02-22 09:44:11 -05:00
resolved = true ;
2026-02-21 04:42:46 -05:00
resolve ( child ) ;
} , 90000 ) ;
const handleLog = ( data ) => {
const msg = data . toString ( ) ;
2026-03-13 22:37:29 -04:00
// Count exact occurrences in this chunk (each chunk may contain multiple lines)
const matches = msg . match ( /Registered tunnel connection/g ) ;
if ( matches ) {
connectionCount += matches . length ;
2026-02-22 09:44:11 -05:00
if ( connectionCount >= 4 && ! resolved ) {
resolved = true ;
2026-02-21 04:42:46 -05:00
clearTimeout ( timeout ) ;
resolve ( child ) ;
}
}
} ;
child . stdout . on ( "data" , handleLog ) ;
child . stderr . on ( "data" , handleLog ) ;
child . on ( "error" , ( err ) => {
2026-02-22 09:44:11 -05:00
if ( ! resolved ) {
resolved = true ;
clearTimeout ( timeout ) ;
reject ( err ) ;
}
2026-02-21 04:42:46 -05:00
} ) ;
2026-05-03 04:18:27 -04:00
child . on ( "exit" , ( code , signal ) => {
2026-02-22 09:44:11 -05:00
cloudflaredProcess = null ;
clearPid ( ) ;
2026-03-13 22:37:29 -04:00
const wasConnected = resolved ; // true = already connected successfully
2026-02-22 09:44:11 -05:00
if ( ! resolved ) {
resolved = true ;
clearTimeout ( timeout ) ;
2026-05-03 04:18:27 -04:00
// Collect stderr output for better error diagnosis
let stderrOutput = "" ;
if ( child . stderr && ! child . stderr . destroyed ) {
// Try to read any buffered stderr (may not have all output but helps with common errors)
stderrOutput = " Check cloudflared logs for details." ;
}
if ( code === 1 ) {
// Common exit code 1 issues: invalid token, auth failure, network issues
reject ( new Error ( ` cloudflared exited with code ${ code } ${ stderrOutput } Ensure your tunnel token is valid and network is reachable. ` ) ) ;
} else if ( code === 2 ) {
reject ( new Error ( ` cloudflared exited with code ${ code } ${ stderrOutput } Check if required arguments are correct. ` ) ) ;
} else {
reject ( new Error ( ` cloudflared exited with code ${ code } ${ stderrOutput } ` ) ) ;
2026-02-22 09:44:11 -05:00
}
2026-05-03 04:18:27 -04:00
return ;
2026-02-22 09:44:11 -05:00
}
2026-05-03 07:00:35 -04:00
// Watchdog (initializeApp) handles recovery — no auto-reconnect here
if ( wasConnected && unexpectedExitHandler ) unexpectedExitHandler ( ) ;
2026-02-21 04:42:46 -05:00
} ) ;
} ) ;
}
2026-03-19 12:47:13 -04:00
/ * *
* Spawn cloudflared quick tunnel ( no account needed )
* Returns the generated trycloudflare . com URL
* /
export async function spawnQuickTunnel ( localPort , onUrlUpdate ) {
const binaryPath = await ensureCloudflared ( ) ;
const configDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "cloudflared-quick-" ) ) ;
const configPath = path . join ( configDir , "config.yml" ) ;
// Avoid using default ~/.cloudflared/config.yml, which can conflict with quick tunnel behavior.
fs . writeFileSync ( configPath , "# quick-tunnel config placeholder\n" , "utf8" ) ;
let isCleaned = false ;
const cleanup = ( ) => {
if ( isCleaned ) return ;
isCleaned = true ;
try {
fs . rmSync ( configDir , { recursive : true , force : true } ) ;
} catch ( e ) { /* ignore */ }
} ;
2026-05-07 04:50:36 -04:00
const requestedProtocol = String ( process . env . TUNNEL _TRANSPORT _PROTOCOL || process . env . CLOUDFLARED _PROTOCOL || DEFAULT _QUICK _TUNNEL _PROTOCOL ) . trim ( ) . toLowerCase ( ) ;
const tunnelProtocol = QUICK _TUNNEL _PROTOCOLS . has ( requestedProtocol ) ? requestedProtocol : DEFAULT _QUICK _TUNNEL _PROTOCOL ;
const child = spawn ( binaryPath , [ "tunnel" , "--url" , ` http://127.0.0.1: ${ localPort } ` , "--config" , configPath , "--no-autoupdate" ] , {
2026-03-19 12:47:13 -04:00
detached : false ,
windowsHide : true ,
2026-05-07 04:50:36 -04:00
env : {
... process . env ,
TUNNEL _TRANSPORT _PROTOCOL : tunnelProtocol ,
} ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
2026-03-19 12:47:13 -04:00
} ) ;
cloudflaredProcess = child ;
savePid ( child . pid ) ;
return new Promise ( ( resolve , reject ) => {
let resolved = false ;
2026-03-19 13:26:01 -04:00
function getQuickTunnelUrlFromLog ( message ) {
// cloudflared logs may contain "api.trycloudflare.com" as well,
// but that is NOT the quick-tunnel endpoint we need.
const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi ;
const candidates = [ ] ;
for ( const match of message . matchAll ( regex ) ) {
const host = match [ 1 ] ;
if ( host === "api" ) continue ;
candidates . push ( ` https:// ${ host } .trycloudflare.com ` ) ;
}
if ( ! candidates . length ) return null ;
return candidates [ candidates . length - 1 ] ;
}
2026-03-19 12:47:13 -04:00
const timeout = setTimeout ( ( ) => {
if ( resolved ) return ;
resolved = true ;
cleanup ( ) ;
reject ( new Error ( "Quick tunnel timed out" ) ) ;
} , 90000 ) ;
2026-03-20 01:44:20 -04:00
let lastUrl = null ;
2026-03-19 12:47:13 -04:00
const handleLog = ( data ) => {
const msg = data . toString ( ) ;
2026-03-19 13:26:01 -04:00
const tunnelUrl = getQuickTunnelUrlFromLog ( msg ) ;
2026-03-20 01:44:20 -04:00
if ( ! tunnelUrl ) return ;
if ( ! resolved ) {
// First URL — resolve the promise, do NOT call onUrlUpdate (caller handles initial register)
2026-03-19 12:47:13 -04:00
resolved = true ;
2026-03-20 01:44:20 -04:00
lastUrl = tunnelUrl ;
2026-03-19 12:47:13 -04:00
clearTimeout ( timeout ) ;
cleanup ( ) ;
resolve ( { child , tunnelUrl } ) ;
2026-03-20 01:44:20 -04:00
return ;
}
// URL changed after initial connect — notify caller to re-register
if ( tunnelUrl !== lastUrl ) {
lastUrl = tunnelUrl ;
2026-03-19 12:47:13 -04:00
if ( onUrlUpdate ) onUrlUpdate ( tunnelUrl ) ;
}
} ;
child . stdout . on ( "data" , handleLog ) ;
child . stderr . on ( "data" , handleLog ) ;
child . on ( "error" , ( err ) => {
if ( resolved ) return ;
resolved = true ;
clearTimeout ( timeout ) ;
cleanup ( ) ;
reject ( err ) ;
} ) ;
2026-05-03 04:18:27 -04:00
child . on ( "exit" , ( code , signal ) => {
2026-03-19 12:47:13 -04:00
cloudflaredProcess = null ;
clearPid ( ) ;
if ( ! resolved ) {
resolved = true ;
clearTimeout ( timeout ) ;
cleanup ( ) ;
2026-05-03 04:18:27 -04:00
// Provide more helpful error messages for common exit codes
if ( code === 1 ) {
reject ( new Error ( ` cloudflared exited with code ${ code } . This often means: (1) the tunnel token is invalid or expired, (2) network connectivity issues, or (3) cloudflared cannot reach the local server. ` ) ) ;
} else if ( code === 2 ) {
reject ( new Error ( ` cloudflared exited with code ${ code } . Check that arguments are correct. ` ) ) ;
} else {
reject ( new Error ( ` cloudflared exited with code ${ code } ` ) ) ;
}
2026-03-19 12:47:13 -04:00
return ;
}
if ( unexpectedExitHandler ) unexpectedExitHandler ( ) ;
cleanup ( ) ;
} ) ;
} ) ;
}
2026-04-27 23:20:31 -04:00
// Kill cloudflared processes whose command line targets the given port (any host).
// Boundary check ensures :20128 doesn't match :201280 or :202128.
function killCloudflaredByPort ( port ) {
if ( ! port ) return ;
try {
if ( IS _WINDOWS ) {
const psCmd = ` Get-CimInstance Win32_Process -Filter \\ "Name='cloudflared.exe' \\ " | Where-Object { $ _.CommandLine -match ': ${ port } ( \\ D| $ )' } | ForEach-Object { Stop-Process -Id $ _.ProcessId -Force } ` ;
2026-04-29 06:28:38 -04:00
execSync ( ` ${ POWERSHELL _HIDDEN _COMMAND } " ${ psCmd } " ` , { stdio : "ignore" , windowsHide : true } ) ;
2026-04-27 23:20:31 -04:00
} else {
execSync ( ` pkill -f "cloudflared.*: ${ port } ([^0-9]| $ )" 2>/dev/null || true ` , { stdio : "ignore" , windowsHide : true } ) ;
}
} catch ( e ) { /* ignore */ }
}
export function killCloudflared ( localPort ) {
2026-02-21 04:42:46 -05:00
if ( cloudflaredProcess ) {
try {
cloudflaredProcess . kill ( ) ;
} catch ( e ) { /* ignore */ }
cloudflaredProcess = null ;
}
const pid = loadPid ( ) ;
if ( pid ) {
try {
process . kill ( pid ) ;
} catch ( e ) { /* ignore */ }
clearPid ( ) ;
}
2026-04-27 23:20:31 -04:00
killCloudflaredByPort ( localPort ) ;
2026-02-21 04:42:46 -05:00
}
export function isCloudflaredRunning ( ) {
const pid = loadPid ( ) ;
if ( ! pid ) return false ;
try {
process . kill ( pid , 0 ) ;
return true ;
} catch ( e ) {
return false ;
}
}