| import fs from "fs"; | ||
| import path from "path"; | ||
| import https from "https"; | ||
| import os from "os"; | ||
| import { execSync, spawn } from "child_process"; | ||
| const BIN_DIR = path.join(os.homedir(), ".9remote", "bin"); | ||
| 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); | ||
| const PID_FILE = path.join(os.homedir(), ".9remote", "cloudflared.pid"); | ||
| // Track intentional shutdown to suppress exit logs | ||
| let isIntentionalShutdown = false; | ||
| // Auto-restart configuration | ||
| const MAX_RESTART_ATTEMPTS = 5; | ||
| const RESTART_WINDOW_MS = 60000; // 1 minute | ||
| let restartTimes = []; | ||
| let restartCallback = null; | ||
| const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download"; | ||
| /** | ||
| * Platform mappings for cloudflared | ||
| */ | ||
| const PLATFORM_MAPPINGS = { | ||
| darwin: { | ||
| x64: "cloudflared-darwin-amd64.tgz", | ||
| arm64: "cloudflared-darwin-amd64.tgz" | ||
| }, | ||
| win32: { | ||
| x64: "cloudflared-windows-amd64.exe" | ||
| }, | ||
| linux: { | ||
| x64: "cloudflared-linux-amd64", | ||
| arm64: "cloudflared-linux-arm64" | ||
| } | ||
| }; | ||
| /** | ||
| * Get download URL | ||
| */ | ||
| function getDownloadUrl() { | ||
| const platform = os.platform(); | ||
| const arch = os.arch(); | ||
| const platformMapping = PLATFORM_MAPPINGS[platform]; | ||
| if (!platformMapping) { | ||
| throw new Error(`Unsupported platform: ${platform}`); | ||
| } | ||
| const binaryName = platformMapping[arch]; | ||
| if (!binaryName) { | ||
| throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`); | ||
| } | ||
| return `${GITHUB_BASE_URL}/${binaryName}`; | ||
| } | ||
| /** | ||
| * Download file from URL | ||
| */ | ||
| async function downloadFile(url, dest) { | ||
| return new Promise((resolve, reject) => { | ||
| const file = fs.createWriteStream(dest); | ||
| https.get(url, (response) => { | ||
| if ([301, 302].includes(response.statusCode)) { | ||
| 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; | ||
| } | ||
| response.pipe(file); | ||
| file.on("finish", () => { | ||
| file.close(() => resolve(dest)); | ||
| }); | ||
| file.on("error", (err) => { | ||
| file.close(); | ||
| fs.unlinkSync(dest); | ||
| reject(err); | ||
| }); | ||
| }).on("error", (err) => { | ||
| file.close(); | ||
| if (fs.existsSync(dest)) fs.unlinkSync(dest); | ||
| reject(err); | ||
| }); | ||
| }); | ||
| } | ||
| /** | ||
| * Ensure cloudflared binary exists | ||
| */ | ||
| export async function ensureCloudflared() { | ||
| if (!fs.existsSync(BIN_DIR)) { | ||
| fs.mkdirSync(BIN_DIR, { recursive: true }); | ||
| } | ||
| if (fs.existsSync(BIN_PATH)) { | ||
| if (!IS_WINDOWS) { | ||
| fs.chmodSync(BIN_PATH, "755"); | ||
| } | ||
| return BIN_PATH; | ||
| } | ||
| console.log("📥 Downloading tunnel binary..."); | ||
| const url = getDownloadUrl(); | ||
| const isArchive = url.endsWith(".tgz"); | ||
| const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz") : BIN_PATH; | ||
| try { | ||
| await downloadFile(url, downloadDest); | ||
| if (isArchive) { | ||
| console.log("✅ Extracting..."); | ||
| execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe" }); | ||
| fs.unlinkSync(downloadDest); | ||
| } | ||
| if (!IS_WINDOWS) { | ||
| fs.chmodSync(BIN_PATH, "755"); | ||
| } | ||
| console.log("✅ cloudflared ready"); | ||
| return BIN_PATH; | ||
| } catch (error) { | ||
| console.error("❌ Failed to download cloudflared:", error.message); | ||
| throw error; | ||
| } | ||
| } | ||
| // Log patterns to filter cloudflared output | ||
| const LOG_IGNORE = [ | ||
| "INF Starting tunnel", | ||
| "INF Version", | ||
| "GOOS:", | ||
| "Settings:", | ||
| "Autoupdate frequency", | ||
| "Generated Connector", | ||
| "Initial protocol", | ||
| "ICMP proxy", | ||
| "Created ICMP", | ||
| "Starting metrics server", | ||
| "curve preferences", | ||
| "Updated to new configuration" | ||
| ]; | ||
| /** | ||
| * Spawn cloudflared tunnel | ||
| * @param {string} tunnelToken | ||
| * @param {Function} onRestart - Callback when tunnel needs restart | ||
| * @returns {ChildProcess} | ||
| */ | ||
| export async function spawnCloudflared(tunnelToken, onRestart = null) { | ||
| const binaryPath = await ensureCloudflared(); | ||
| // Store restart callback | ||
| if (onRestart) { | ||
| restartCallback = onRestart; | ||
| } | ||
| const child = spawn(binaryPath, ["tunnel", "run", "--token", tunnelToken], { | ||
| detached: false, | ||
| stdio: ["ignore", "pipe", "pipe"] | ||
| }); | ||
| let connectionCount = 0; | ||
| const handleLog = (data) => { | ||
| const msg = data.toString().trim(); | ||
| // Skip ignored messages | ||
| if (LOG_IGNORE.some(pattern => msg.includes(pattern))) { | ||
| return; | ||
| } | ||
| // Skip errors during intentional shutdown | ||
| if (isIntentionalShutdown) { | ||
| return; | ||
| } | ||
| // Show connection status briefly | ||
| if (msg.includes("Registered tunnel connection")) { | ||
| connectionCount++; | ||
| if (connectionCount <= 4) { | ||
| process.stdout.write(`\r ✔ Connection ${connectionCount}/4 established`); | ||
| if (connectionCount === 4) { | ||
| process.stdout.write("\n"); | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| // Show errors | ||
| if (msg.includes("ERR") || msg.includes("error") || msg.includes("failed")) { | ||
| console.error(`[cloudflared] ${msg}`); | ||
| } | ||
| }; | ||
| child.stdout.on("data", handleLog); | ||
| child.stderr.on("data", handleLog); | ||
| child.on("error", (error) => { | ||
| console.error("❌ cloudflared error:", error); | ||
| }); | ||
| child.on("exit", (code) => { | ||
| // Only log unexpected exits | ||
| if (!isIntentionalShutdown && code !== 0 && code !== null) { | ||
| console.log(`cloudflared exited with code ${code}`); | ||
| // Auto-restart logic | ||
| if (restartCallback) { | ||
| const now = Date.now(); | ||
| restartTimes.push(now); | ||
| // Remove old restart times outside window | ||
| restartTimes = restartTimes.filter(t => t > now - RESTART_WINDOW_MS); | ||
| if (restartTimes.length <= MAX_RESTART_ATTEMPTS) { | ||
| console.log(`🔄 Restarting tunnel... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`); | ||
| setTimeout(() => { | ||
| restartCallback(tunnelToken); | ||
| }, 2000); | ||
| } else { | ||
| console.log(`❌ Too many tunnel restarts (${MAX_RESTART_ATTEMPTS} in ${RESTART_WINDOW_MS / 1000}s). Giving up.`); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| // Save PID | ||
| fs.writeFileSync(PID_FILE, child.pid.toString()); | ||
| return child; | ||
| } | ||
| /** | ||
| * Kill cloudflared process | ||
| */ | ||
| export function killCloudflared() { | ||
| try { | ||
| if (fs.existsSync(PID_FILE)) { | ||
| isIntentionalShutdown = true; | ||
| const pid = parseInt(fs.readFileSync(PID_FILE, "utf8")); | ||
| process.kill(pid); | ||
| fs.unlinkSync(PID_FILE); | ||
| } | ||
| } catch (error) { | ||
| // Silently ignore errors | ||
| } | ||
| } | ||
| /** | ||
| * Reset restart counter | ||
| */ | ||
| export function resetRestartCounter() { | ||
| restartTimes = []; | ||
| restartCallback = null; | ||
| } |
+99
-17
@@ -26,3 +26,3 @@ #!/usr/bin/env node | ||
| const SERVER_PORT = 2208; | ||
| const MAX_RESTART_ATTEMPTS = 3; | ||
| const MAX_RESTART_ATTEMPTS = 10; | ||
| const RESTART_WINDOW_MS = 60000; // 1 minute | ||
@@ -189,3 +189,3 @@ | ||
| */ | ||
| function startServerWithRestart(onReady) { | ||
| function startServerWithRestart(onReady, onServerCrash) { | ||
| const restartTimes = []; | ||
@@ -201,2 +201,3 @@ let currentProcess = null; | ||
| isFirstStart = false; | ||
| } else { | ||
| } | ||
@@ -219,5 +220,8 @@ | ||
| }); | ||
| currentProcess.on("exit", (code, signal) => { | ||
| if (isShuttingDown) return; | ||
| if (isShuttingDown) { | ||
| return; | ||
| } | ||
@@ -243,3 +247,10 @@ // Check if it's a crash (non-zero exit code or unexpected signal) | ||
| console.log(chalk.yellow(`🔄 Restarting server... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`)); | ||
| console.log(ORANGE_DIM("⚠️ [DEBUG] NOTE: Tunnel connection may be stale - will restart tunnel")); | ||
| // ✅ Callback để restart cloudflared | ||
| if (onServerCrash) { | ||
| console.log(chalk.yellow("✅ Restarting tunnel connection...")); | ||
| onServerCrash(); | ||
| } | ||
| // Wait a bit before restart | ||
@@ -249,2 +260,3 @@ setTimeout(() => { | ||
| }, 1000); | ||
| } else { | ||
| } | ||
@@ -278,19 +290,16 @@ }); | ||
| */ | ||
| let exitHandlerRegistered = false; | ||
| function setupExitHandler(serverManager, tunnelProcess, apiKey) { | ||
| if (exitHandlerRegistered) return; | ||
| exitHandlerRegistered = true; | ||
| process.on("SIGINT", async () => { | ||
| console.log(chalk.yellow("\n\n🛑 Stopping server...")); | ||
| serverManager.shutdown(); | ||
| tunnelProcess.kill(); | ||
| resetRestartCounter(); | ||
| clearState(); | ||
| // Cleanup tunnel on worker | ||
| try { | ||
| await fetch(`${WORKER_URL}/api/tunnel/delete`, { | ||
| method: "DELETE", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ apiKey }) | ||
| }); | ||
| } catch { } | ||
| clearState(); | ||
| console.log(chalk.green("✅ Server stopped")); | ||
@@ -316,3 +325,4 @@ process.exit(0); | ||
| return response.json(); | ||
| const data = await response.json(); | ||
| return data; | ||
| } | ||
@@ -329,2 +339,4 @@ | ||
| killCloudflared(); | ||
| // Wait for process to be killed | ||
| await new Promise(resolve => setTimeout(resolve, 1000)); | ||
| } catch { } | ||
@@ -334,3 +346,3 @@ | ||
| try { | ||
| await fetch(`${WORKER_URL}/api/session/create`, { | ||
| const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, { | ||
| method: "POST", | ||
@@ -340,2 +352,11 @@ headers: { "Content-Type": "application/json" }, | ||
| }); | ||
| if (!sessionResponse.ok) { | ||
| const text = await sessionResponse.text(); | ||
| console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${sessionResponse.statusText}`)); | ||
| console.log(chalk.yellow(`Response: ${text.substring(0, 200)}`)); | ||
| return null; | ||
| } | ||
| const sessionData = await sessionResponse.json(); | ||
| } catch (error) { | ||
@@ -347,3 +368,61 @@ console.log(chalk.red(`❌ Failed to create session: ${error.message}`)); | ||
| // Start server with auto-restart | ||
| const serverManager = startServerWithRestart(); | ||
| const serverManager = startServerWithRestart(null, async () => { | ||
| // Callback khi server crash - đợi server ready rồi gửi SIGHUP | ||
| if (!tunnelProcess) { | ||
| return; | ||
| } | ||
| // Wait for server to be ready | ||
| const maxWait = 60000; // 60s | ||
| const checkInterval = 1000; // 1s | ||
| const maxRetries = Math.floor(maxWait / checkInterval); | ||
| let serverReady = false; | ||
| for (let i = 0; i < maxRetries; i++) { | ||
| try { | ||
| const response = await fetch(`http://localhost:${SERVER_PORT}/api/health`, { | ||
| method: "GET", | ||
| timeout: 2000 | ||
| }); | ||
| if (response.ok) { | ||
| const data = await response.json(); | ||
| if (data.status === "ok") { | ||
| serverReady = true; | ||
| console.log(chalk.green(`✅ Server ready after ${i + 1}s`)); | ||
| break; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| // Server not ready yet | ||
| } | ||
| if (i % 5 === 0) { | ||
| } | ||
| await new Promise(resolve => setTimeout(resolve, checkInterval)); | ||
| } | ||
| if (!serverReady) { | ||
| console.log(chalk.red("❌ Server not ready after 60s - skipping tunnel reconnect")); | ||
| return; | ||
| } | ||
| // Server ready - send SIGHUP to cloudflared to reconnect | ||
| try { | ||
| process.kill(tunnelProcess.pid, "SIGHUP"); | ||
| console.log(chalk.green("✅ SIGHUP sent - cloudflared should reconnect")); | ||
| } catch (err) { | ||
| // Fallback: kill and restart | ||
| try { | ||
| tunnelProcess.kill(); | ||
| await new Promise(resolve => setTimeout(resolve, 1000)); | ||
| tunnelProcess = await startTunnel(token); | ||
| console.log(chalk.green("✅ Tunnel restarted")); | ||
| } catch (restartErr) { | ||
| console.log(chalk.red(`❌ Failed to restart tunnel: ${restartErr.message}`)); | ||
| } | ||
| } | ||
| }); | ||
@@ -396,2 +475,3 @@ // Wait for server to start | ||
| } | ||
@@ -431,5 +511,7 @@ // Wait for tunnel to be ready | ||
| break; | ||
| } else { | ||
| } | ||
| } | ||
| } catch { } | ||
| } catch (err) { | ||
| } | ||
@@ -436,0 +518,0 @@ const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; |
+1
-1
| { | ||
| "name": "9remote", | ||
| "version": "0.1.25", | ||
| "version": "0.1.26", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "Remote terminal access from anywhere", |
+21
-9
@@ -18,3 +18,3 @@ import fs from "fs"; | ||
| // Auto-restart configuration | ||
| const MAX_RESTART_ATTEMPTS = 3; | ||
| const MAX_RESTART_ATTEMPTS = 5; | ||
| const RESTART_WINDOW_MS = 60000; // 1 minute | ||
@@ -181,2 +181,7 @@ let restartTimes = []; | ||
| // Reset intentional shutdown flag immediately after spawn | ||
| // This ensures auto-restart works if cloudflared crashes | ||
| isIntentionalShutdown = false; | ||
| console.log(`✅ Cloudflared spawned with PID: ${child.pid}`); | ||
| let connectionCount = 0; | ||
@@ -210,5 +215,3 @@ | ||
| // Show errors | ||
| if (msg.includes("ERR") || msg.includes("error") || msg.includes("failed")) { | ||
| console.error(`[cloudflared] ${msg}`); | ||
| } | ||
| console.log(`[cloudflared] ${msg}`); | ||
| }; | ||
@@ -223,6 +226,8 @@ | ||
| child.on("exit", (code) => { | ||
| // Only log unexpected exits | ||
| if (!isIntentionalShutdown && code !== 0 && code !== null) { | ||
| console.log(`cloudflared exited with code ${code}`); | ||
| child.on("exit", (code, signal) => { | ||
| console.log(`⚠️ Cloudflared process exited (code: ${code}, signal: ${signal}, intentional: ${isIntentionalShutdown})`); | ||
| // Restart on ANY unexpected exit (including code 0 if not intentional) | ||
| if (!isIntentionalShutdown) { | ||
| console.log(`⚠️ Cloudflared unexpected exit detected - will restart`); | ||
@@ -240,2 +245,3 @@ // Auto-restart logic | ||
| setTimeout(() => { | ||
| console.log(`🔄 Executing tunnel restart...`); | ||
| restartCallback(tunnelToken); | ||
@@ -246,3 +252,7 @@ }, 2000); | ||
| } | ||
| } else { | ||
| console.log(`⚠️ No restart callback registered`); | ||
| } | ||
| } else { | ||
| console.log(`ℹ️ Cloudflared exit ignored (intentional shutdown)`); | ||
| } | ||
@@ -265,7 +275,9 @@ }); | ||
| const pid = parseInt(fs.readFileSync(PID_FILE, "utf8")); | ||
| console.log(`🔄 Killing cloudflared process PID: ${pid}`); | ||
| process.kill(pid); | ||
| fs.unlinkSync(PID_FILE); | ||
| console.log(`✅ Cloudflared killed`); | ||
| } | ||
| } catch (error) { | ||
| // Silently ignore errors | ||
| console.log(`⚠️ Error killing cloudflared: ${error.message}`); | ||
| } | ||
@@ -272,0 +284,0 @@ } |
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 6 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 26 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential malware
Supply chain riskAI has identified this package as malware. This is a strong signal that the package may be malicious.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 6 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 26 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
1364422
0.87%13
8.33%6704
4.88%1
-50%135
1.5%52
4%