UNPKG

melq

Version:

Quantum-secure chat network with ML-KEM-768 encryption and host-based architecture

453 lines (377 loc) 15.7 kB
import { spawn } from 'child_process'; import { promisify } from 'util'; import { exec } from 'child_process'; import fetch from 'node-fetch'; import chalk from 'chalk'; const execAsync = promisify(exec); export class TunnelingService { constructor() { this.activeProcess = null; this.publicUrl = null; this.method = null; } async exposeToInternet(localPort, options = {}) { const { preferredMethod = 'auto', customDomain } = options; console.log(chalk.yellow('🌐 Setting up internet access...')); // Try automatic tunnel services first in auto mode if (preferredMethod === 'localtunnel' || preferredMethod === 'auto') { const ltUrl = await this.tryLocalTunnel(localPort); if (ltUrl) return ltUrl; } if (preferredMethod === 'ngrok' || preferredMethod === 'auto') { const ngrokUrl = await this.tryNgrok(localPort); if (ngrokUrl) return ngrokUrl; } if (preferredMethod === 'serveo' || preferredMethod === 'auto') { const serveoUrl = await this.tryServeo(localPort); if (serveoUrl) return serveoUrl; } // Try manual setup last (only if specifically requested or as final fallback) if (preferredMethod === 'manual') { const manualUrl = await this.tryManualSetup(localPort, customDomain, false); if (manualUrl) return manualUrl; } else if (preferredMethod === 'auto') { // In auto mode, don't use manual setup as fallback since it requires port forwarding console.log(chalk.yellow('⚠️ All automatic tunnel services failed. Consider using --tunnel manual for port forwarding setup.')); } throw new Error('No tunneling method available. Install localtunnel, ngrok, or try manual setup.'); } async tryManualSetup(localPort, customDomain, isAutoMode = false) { if (customDomain) { console.log(chalk.blue(`📡 Using custom domain: ${customDomain}`)); this.method = 'manual'; this.publicUrl = customDomain.startsWith('http') ? customDomain : `https://${customDomain}`; return { publicUrl: this.publicUrl, connectionCode: this.publicUrl.replace(/^https?:\/\//, 'melq://'), method: 'manual' }; } // In auto mode, don't automatically use manual setup - only if explicitly requested if (isAutoMode) { console.log(chalk.gray('Skipping manual setup in auto mode (use --tunnel manual to force)')); return null; } // Try to detect public IP (only when manually requested) try { const response = await fetch('https://api.ipify.org?format=text', { timeout: 5000 }); const publicIp = await response.text(); console.log(chalk.blue(`📡 Detected public IP: ${publicIp}`)); console.log(chalk.yellow('⚠️ Manual setup required:')); console.log(chalk.gray(` 1. Configure router to forward port ${localPort} to this device`)); console.log(chalk.gray(` 2. Share connection code: melq://${publicIp}:${localPort}`)); this.method = 'manual'; this.publicUrl = `http://${publicIp}:${localPort}`; return { publicUrl: this.publicUrl, connectionCode: `melq://${publicIp}:${localPort}`, method: 'manual', requiresPortForwarding: true }; } catch (error) { console.log(chalk.gray('Could not detect public IP')); return null; } } async tryNgrok(localPort) { try { console.log(chalk.gray(`Trying ngrok on port ${localPort}...`)); // Check if ngrok is available try { await execAsync('ngrok version'); console.log(chalk.gray('✓ ngrok found')); } catch (error) { console.log(chalk.gray('ngrok not found, skipping...')); return null; } // Kill any existing ngrok processes to avoid session limit error try { const isWindows = process.platform === 'win32'; const killCommand = isWindows ? 'taskkill /f /im ngrok.exe' : 'pkill -f ngrok'; await execAsync(killCommand); console.log(chalk.gray('✓ Cleaned up existing ngrok processes')); // Wait a moment for processes to fully terminate await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { // No existing processes, that's fine } // Start ngrok console.log(chalk.gray(`Starting: ngrok http ${localPort}`)); this.activeProcess = spawn('ngrok', ['http', localPort.toString()], { stdio: ['ignore', 'pipe', 'pipe'] }); let stdoutOutput = ''; let stderrOutput = ''; this.activeProcess.stdout.on('data', (data) => { const text = data.toString(); stdoutOutput += text; console.log(chalk.gray('ngrok stdout:'), text.trim()); }); this.activeProcess.stderr.on('data', (data) => { const text = data.toString(); stderrOutput += text; console.log(chalk.gray('ngrok stderr:'), text.trim()); }); // Wait for ngrok to start and get the URL return new Promise((resolve) => { // Give ngrok some time to start, then check its API setTimeout(async () => { try { console.log(chalk.gray('Checking ngrok API for tunnel info...')); const response = await fetch('http://127.0.0.1:4040/api/tunnels'); const data = await response.json(); if (data.tunnels && data.tunnels.length > 0) { const tunnel = data.tunnels.find(t => t.config && t.config.addr && t.config.addr.includes(localPort.toString())); if (tunnel && tunnel.public_url) { clearTimeout(timeout); const publicUrl = tunnel.public_url.replace('http://', 'https://'); // Ensure HTTPS this.publicUrl = publicUrl; this.method = 'ngrok'; console.log(chalk.green('✅ ngrok tunnel found via API!')); console.log(chalk.blue(`📡 Public URL: ${publicUrl}`)); resolve({ publicUrl, connectionCode: publicUrl.replace('https://', 'melq://'), method: 'ngrok' }); return; } } } catch (error) { console.log(chalk.gray('ngrok API check failed:', error.message)); } }, 5000); const timeout = setTimeout(() => { console.log(chalk.gray('ngrok timeout - checking output:')); if (stdoutOutput) console.log(chalk.gray('STDOUT:'), stdoutOutput.slice(-200)); if (stderrOutput) console.log(chalk.gray('STDERR:'), stderrOutput.slice(-200)); this.cleanup(); resolve(null); }, 20000); // Increased timeout for API check // Check both stdout and stderr for the URL const checkForUrl = (text) => { const urlMatch = text.match(/https:\/\/[\\w\\.-]+\\.ngrok(?:-free)?\\.app/); if (urlMatch) { clearTimeout(timeout); const publicUrl = urlMatch[0]; this.publicUrl = publicUrl; this.method = 'ngrok'; console.log(chalk.green('✅ ngrok tunnel established!')); console.log(chalk.blue(`📡 Public URL: ${publicUrl}`)); resolve({ publicUrl, connectionCode: publicUrl.replace('https://', 'melq://'), method: 'ngrok' }); return true; } return false; }; this.activeProcess.stdout.on('data', (data) => { checkForUrl(data.toString()); }); this.activeProcess.stderr.on('data', (data) => { checkForUrl(data.toString()); }); this.activeProcess.on('error', (error) => { console.log(chalk.gray('ngrok process error:'), error.message); clearTimeout(timeout); resolve(null); }); this.activeProcess.on('exit', (code, signal) => { console.log(chalk.gray(`ngrok exited with code ${code}, signal ${signal}`)); if (stderrOutput.includes('authenticate')) { console.log(chalk.yellow('⚠️ ngrok authentication required. Run: ngrok authtoken <token>')); } clearTimeout(timeout); resolve(null); }); }); } catch (error) { console.log(chalk.gray('ngrok failed:', error.message)); return null; } } async tryLocalTunnel(localPort) { try { console.log(chalk.yellow('🔧 Trying localtunnel...')); // Import localtunnel module directly (no need to spawn process) let localtunnel; try { localtunnel = (await import('localtunnel')).default; console.log(chalk.gray('✓ localtunnel module found')); } catch (error) { console.log(chalk.red('❌ localtunnel module not found:'), error.message); console.log(chalk.yellow('💡 Run "npm install localtunnel" to enable this feature')); return null; } // Create tunnel using the module API console.log(chalk.yellow(`🔧 Creating localtunnel on port ${localPort}...`)); // Add timeout to prevent hanging const tunnelPromise = localtunnel({ port: localPort, subdomain: undefined // Let localtunnel assign a random subdomain }); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Localtunnel timeout after 15 seconds')), 15000); }); const tunnel = await Promise.race([tunnelPromise, timeoutPromise]); this.activeProcess = tunnel; // Store tunnel instance for cleanup const publicUrl = tunnel.url; if (!publicUrl) { throw new Error('Localtunnel created but no URL returned'); } this.publicUrl = publicUrl; this.method = 'localtunnel'; console.log(chalk.green('✅ Localtunnel established!')); console.log(chalk.blue(`📡 Public URL: ${publicUrl}`)); console.log(chalk.cyan('💡 No account required - tunnel ready to use!')); return { publicUrl, connectionCode: publicUrl.replace('https://', 'melq://'), method: 'localtunnel' }; } catch (error) { console.log(chalk.gray('localtunnel failed:', error.message)); return null; } } async tryServeo(localPort) { try { console.log(chalk.gray('Trying serveo.net...')); this.activeProcess = spawn('ssh', [ '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=60', '-R', `80:localhost:${localPort}`, 'serveo.net' ], { stdio: ['ignore', 'pipe', 'pipe'] }); return new Promise((resolve) => { const timeout = setTimeout(() => { this.cleanup(); resolve(null); }, 10000); this.activeProcess.stderr.on('data', (data) => { const text = data.toString(); const urlMatch = text.match(/https:\/\/[\\w\\.-]+\\.serveo\\.net/); if (urlMatch) { clearTimeout(timeout); const publicUrl = urlMatch[0]; this.publicUrl = publicUrl; this.method = 'serveo'; console.log(chalk.green('✅ Serveo tunnel established!')); console.log(chalk.blue(`📡 Public URL: ${publicUrl}`)); resolve({ publicUrl, connectionCode: publicUrl.replace('https://', 'melq://'), method: 'serveo' }); } }); this.activeProcess.on('error', () => { clearTimeout(timeout); resolve(null); }); this.activeProcess.on('exit', () => { clearTimeout(timeout); resolve(null); }); }); } catch (error) { console.log(chalk.gray('serveo failed:', error.message)); return null; } } cleanup() { if (this.activeProcess) { console.log(chalk.gray('Stopping tunneling service...')); try { if (this.method === 'localtunnel') { // For localtunnel, close the tunnel instance this.activeProcess.close(); console.log(chalk.gray('✓ Localtunnel closed')); } else if (this.method === 'ngrok') { // For ngrok, try graceful shutdown first this.activeProcess.kill('SIGTERM'); // If process doesn't exit gracefully, force kill after 3 seconds setTimeout(() => { if (this.activeProcess && !this.activeProcess.killed) { console.log(chalk.gray('Force killing ngrok process...')); this.activeProcess.kill('SIGKILL'); } }, 3000); } else { // For other spawn processes this.activeProcess.kill(); } this.activeProcess = null; } catch (error) { console.log(chalk.gray('Error stopping tunnel process:'), error.message); } } this.publicUrl = null; this.method = null; } getConnectionInfo() { return { publicUrl: this.publicUrl, method: this.method, isActive: !!this.activeProcess }; } } // Helper function to validate and normalize connection codes export function parseConnectionCode(connectionCode) { if (!connectionCode) throw new Error('Connection code is required'); // Clean up the input const cleanCode = connectionCode.trim(); // Handle melq:// format if (cleanCode.startsWith('melq://')) { const address = cleanCode.replace('melq://', ''); // Use WSS for ngrok-free.app domains, but WS for localtunnel (they handle SSL termination differently) if (address.includes('.ngrok-free.app') || address.includes('.ngrok.')) { return `wss://${address}/ws`; } if (address.includes('.loca.lt')) { return `ws://${address}/ws`; } if (address.includes('.serveo.net')) { return `wss://${address}/ws`; } return `ws://${address}/ws`; } // Handle https:// format (for tunnels like ngrok) if (cleanCode.startsWith('https://')) { const address = cleanCode.replace('https://', ''); // Don't add /ws for ngrok-free.app - they handle routing differently if (address.includes('.ngrok-free.app')) { return `wss://${address}`; } return `wss://${address}/ws`; } // Handle http:// format if (cleanCode.startsWith('http://')) { const address = cleanCode.replace('http://', ''); // Upgrade HTTP to HTTPS for tunnel services if (address.includes('.ngrok-free.app') || address.includes('.ngrok.') || address.includes('.loca.lt') || address.includes('.serveo.net')) { return `wss://${address}/ws`; } return `ws://${address}/ws`; } // Handle bare ngrok/tunnel domains (assume https) if (cleanCode.includes('.ngrok.') || cleanCode.includes('.loca.lt') || cleanCode.includes('.serveo.net')) { return `wss://${cleanCode}/ws`; } // Handle IP:port format if (cleanCode.includes(':') && /^[0-9.:]+$/.test(cleanCode)) { return `ws://${cleanCode}/ws`; } // Handle bare domain (assume https for tunnels, ws for local) if (cleanCode.includes('.')) { return `wss://${cleanCode}/ws`; } throw new Error('Invalid connection code format. Expected: melq://host:port, https://tunnel.url, or IP:port'); }