2026-02-21 16:42:46 +07: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-16 10:58:35 +07:00
import { DATA_DIR } from "@/lib/dataDir.js" ;
2026-02-21 16:42:46 +07:00
2026-04-16 10:58:35 +07:00
const BIN_DIR = path . join ( DATA_DIR , "bin" );
2026-02-21 16:42:46 +07: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 17:28:38 +07:00
const POWERSHELL_HIDDEN_COMMAND = "powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command" ;
2026-05-07 15:50:36 +07:00
const DEFAULT_QUICK_TUNNEL_PROTOCOL = "http2" ;
const QUICK_TUNNEL_PROTOCOLS = new Set ([ "http2" , "quic" , "auto" ]);
2026-02-21 16:42:46 +07:00
2026-04-16 10:58:35 +07:00
const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download" ;
2026-02-21 16:42:46 +07:00
const PLATFORM_MAPPINGS = {
darwin : {
x64 : "cloudflared-darwin-amd64.tgz" ,
2026-04-09 10:16:11 +07:00
arm64 : "cloudflared-darwin-arm64.tgz"
2026-02-21 16:42:46 +07:00
},
win32 : {
2026-04-09 10:16:11 +07:00
x64 : "cloudflared-windows-amd64.exe" ,
2026-04-16 10:58:35 +07:00
ia32 : "cloudflared-windows-386.exe" ,
arm64 : "cloudflared-windows-386.exe"
2026-02-21 16:42:46 +07:00
},
linux : {
x64 : "cloudflared-linux-amd64" ,
arm64 : "cloudflared-linux-arm64"
}
};
2026-04-16 10:58:35 +07: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 16:42:46 +07: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-16 10:58:35 +07:00
const binaryName = platformMapping [ arch ] || PLATFORM_FALLBACK [ platform ];
2026-02-21 16:42:46 +07:00
return ` ${ GITHUB_BASE_URL } / ${ binaryName } ` ;
}
2026-04-16 10:58:35 +07: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 16:42:46 +07:00
function downloadFile ( url , dest ) {
return new Promise (( resolve , reject ) => {
const file = fs . createWriteStream ( dest );
https . get ( url , ( response ) => {
2026-04-16 10:58:35 +07:00
if ([ 301 , 302 , 303 , 307 , 308 ]. includes ( response . statusCode )) {
2026-02-21 16:42:46 +07: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-16 10:58:35 +07: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 16:42:46 +07:00
response . pipe ( file );
file . on ( "finish" , () => {
2026-04-16 10:58:35 +07:00
dlState . downloading = false ;
dlState . progress = 100 ;
2026-02-21 16:42:46 +07:00
file . close (() => resolve ( dest ));
});
file . on ( "error" , ( err ) => {
2026-04-16 10:58:35 +07:00
dlState . downloading = false ;
dlState . progress = 0 ;
2026-02-21 16:42:46 +07:00
file . close ();
fs . unlinkSync ( dest );
reject ( err );
});
}). on ( "error" , ( err ) => {
2026-04-16 10:58:35 +07:00
dlState . downloading = false ;
dlState . progress = 0 ;
2026-02-21 16:42:46 +07:00
file . close ();
if ( fs . existsSync ( dest )) fs . unlinkSync ( dest );
reject ( err );
});
});
}
2026-04-16 10:58:35 +07: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 16:42:46 +07:00
export async function ensureCloudflared () {
2026-04-16 10:58:35 +07:00
if ( downloadPromise ) return downloadPromise ;
downloadPromise = _ensureCloudflared (). finally (() => { downloadPromise = null ; });
return downloadPromise ;
}
async function _ensureCloudflared () {
2026-02-21 16:42:46 +07:00
if ( ! fs . existsSync ( BIN_DIR )) {
fs . mkdirSync ( BIN_DIR , { recursive : true });
}
2026-04-16 10:58:35 +07: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 16:42:46 +07:00
if ( fs . existsSync ( BIN_PATH )) {
2026-04-16 10:58:35 +07: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 16:42:46 +07:00
}
}
const url = getDownloadUrl ();
const isArchive = url . endsWith ( ".tgz" );
2026-04-16 10:58:35 +07:00
const downloadDest = isArchive ? path . join ( BIN_DIR , "cloudflared.tgz.tmp" ) : tmpPath ;
2026-02-21 16:42:46 +07:00
await downloadFile ( url , downloadDest );
if ( isArchive ) {
2026-04-14 11:48:59 +07:00
execSync ( `tar -xzf " ${ downloadDest } " -C " ${ BIN_DIR } "` , { stdio : "pipe" , windowsHide : true });
2026-02-21 16:42:46 +07:00
fs . unlinkSync ( downloadDest );
2026-04-16 10:58:35 +07:00
} else {
fs . renameSync ( downloadDest , BIN_PATH );
2026-02-21 16:42:46 +07:00
}
if ( ! IS_WINDOWS ) {
fs . chmodSync ( BIN_PATH , "755" );
}
return BIN_PATH ;
}
let cloudflaredProcess = null ;
2026-02-22 21:44:11 +07: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 16:42:46 +07: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-16 09:11:19 +07:00
windowsHide : true ,
2026-02-21 16:42:46 +07:00
stdio : [ "ignore" , "pipe" , "pipe" ]
});
cloudflaredProcess = child ;
savePid ( child . pid );
return new Promise (( resolve , reject ) => {
let connectionCount = 0 ;
2026-02-22 21:44:11 +07:00
let resolved = false ;
2026-02-21 16:42:46 +07:00
const timeout = setTimeout (() => {
2026-02-22 21:44:11 +07:00
resolved = true ;
2026-02-21 16:42:46 +07:00
resolve ( child );
}, 90000 );
const handleLog = ( data ) => {
const msg = data . toString ();
2026-03-14 09:37:29 +07: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 21:44:11 +07:00
if ( connectionCount >= 4 && ! resolved ) {
resolved = true ;
2026-02-21 16:42:46 +07:00
clearTimeout ( timeout );
resolve ( child );
}
}
};
child . stdout . on ( "data" , handleLog );
child . stderr . on ( "data" , handleLog );
child . on ( "error" , ( err ) => {
2026-02-22 21:44:11 +07:00
if ( ! resolved ) {
resolved = true ;
clearTimeout ( timeout );
reject ( err );
}
2026-02-21 16:42:46 +07:00
});
2026-05-03 04:18:27 -04:00
child . on ( "exit" , ( code , signal ) => {
2026-02-22 21:44:11 +07:00
cloudflaredProcess = null ;
clearPid ();
2026-03-14 09:37:29 +07:00
const wasConnected = resolved ; // true = already connected successfully
2026-02-22 21:44:11 +07: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." ;
2026-02-22 21:44:11 +07:00
}
2026-05-03 04:18:27 -04:00
if ( code === 1 ) {
// Common exit code 1 issues: invalid token, auth failure, network issues
reject ( new Error ( `cloudflared exited with code ${ code }${ stderrOutput } Ensure your tunnel token is valid and network is reachable.` ));
} else if ( code === 2 ) {
reject ( new Error ( `cloudflared exited with code ${ code }${ stderrOutput } Check if required arguments are correct.` ));
} else {
reject ( new Error ( `cloudflared exited with code ${ code }${ stderrOutput } ` ));
}
return ;
2026-02-22 21:44:11 +07:00
}
2026-05-03 18:00:35 +07:00
// Watchdog (initializeApp) handles recovery — no auto-reconnect here
if ( wasConnected && unexpectedExitHandler ) unexpectedExitHandler ();
2026-02-21 16:42:46 +07:00
});
});
}
2026-03-19 23:47:13 +07: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 15:50:36 +07: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 23:47:13 +07:00
detached : false ,
windowsHide : true ,
2026-05-07 15:50:36 +07:00
env : {
... process . env ,
TUNNEL_TRANSPORT_PROTOCOL : tunnelProtocol ,
},
stdio : [ "ignore" , "pipe" , "pipe" ],
2026-03-19 23:47:13 +07:00
});
cloudflaredProcess = child ;
savePid ( child . pid );
return new Promise (( resolve , reject ) => {
let resolved = false ;
2026-03-20 00:26:01 +07: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 23:47:13 +07:00
const timeout = setTimeout (() => {
if ( resolved ) return ;
resolved = true ;
cleanup ();
reject ( new Error ( "Quick tunnel timed out" ));
}, 90000 );
2026-03-20 12:44:20 +07:00
let lastUrl = null ;
2026-03-19 23:47:13 +07:00
const handleLog = ( data ) => {
const msg = data . toString ();
2026-03-20 00:26:01 +07:00
const tunnelUrl = getQuickTunnelUrlFromLog ( msg );
2026-03-20 12:44:20 +07:00
if ( ! tunnelUrl ) return ;
if ( ! resolved ) {
// First URL — resolve the promise, do NOT call onUrlUpdate (caller handles initial register)
2026-03-19 23:47:13 +07:00
resolved = true ;
2026-03-20 12:44:20 +07:00
lastUrl = tunnelUrl ;
2026-03-19 23:47:13 +07:00
clearTimeout ( timeout );
cleanup ();
resolve ({ child , tunnelUrl });
2026-03-20 12:44:20 +07:00
return ;
}
// URL changed after initial connect — notify caller to re-register
if ( tunnelUrl !== lastUrl ) {
lastUrl = tunnelUrl ;
2026-03-19 23:47:13 +07: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 23:47:13 +07: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 23:47:13 +07:00
return ;
}
if ( unexpectedExitHandler ) unexpectedExitHandler ();
cleanup ();
});
});
}
2026-04-28 10:20:31 +07: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 17:28:38 +07:00
execSync ( ` ${ POWERSHELL_HIDDEN_COMMAND } " ${ psCmd } "` , { stdio : "ignore" , windowsHide : true });
2026-04-28 10:20:31 +07: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 16:42:46 +07: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-28 10:20:31 +07:00
killCloudflaredByPort ( localPort );
2026-02-21 16:42:46 +07:00
}
export function isCloudflaredRunning () {
const pid = loadPid ();
if ( ! pid ) return false ;
try {
process . kill ( pid , 0 );
return true ;
} catch ( e ) {
return false ;
}
}