UNPKG

@fanboynz/network-scanner

Version:

A Puppeteer-based network scanner for analyzing web traffic, generating adblock filter rules, and identifying third-party requests. Features include fingerprint spoofing, Cloudflare bypass, content analysis with curl/grep, and multiple output formats.

520 lines (459 loc) 17.9 kB
// === WireGuard VPN Module === // Per-site VPN configuration for network scanner // Manages WireGuard interfaces, routing, and lifecycle const { spawnSync } = require('child_process'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { formatLogMessage, messageColors } = require('./colorize'); const VPN_TAG = messageColors.processing('[vpn]'); /** * Fetch external IP address through the active tunnel * @param {string} interfaceName - WireGuard interface name (optional) * @returns {string|null} External IP or null */ function getExternalIP(interfaceName) { const services = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com']; for (const service of services) { const args = ['-s', '-m', '5']; if (interfaceName) args.push('--interface', interfaceName); args.push(service); // spawnSync (no shell) so a malicious interfaceName like // "wg-foo; rm -rf ~" can't be split into a second command. const result = spawnSync('curl', args, { encoding: 'utf8', timeout: 8000 }); if (result.status === 0 && result.stdout) { return result.stdout.trim(); } } return null; } // Track active interfaces for cleanup const activeInterfaces = new Map(); // Temp config directory for inline configs const TEMP_CONFIG_DIR = '/tmp/nwss-wireguard'; /** * Check if running with sufficient privileges * @returns {boolean} */ function hasRootPrivileges() { try { return process.getuid() === 0; } catch { return false; } } /** * Resolve interface name from config path or explicit name * @param {Object} vpnConfig - VPN configuration object * @returns {string} Interface name */ function resolveInterfaceName(vpnConfig) { if (vpnConfig.interface) { return vpnConfig.interface; } if (vpnConfig.config) { // Extract name from /etc/wireguard/wg-example.conf → wg-example return path.basename(vpnConfig.config, '.conf'); } // Inline-only config without an explicit interface: derive a stable // name from a hash of the content so connect and disconnect resolve // to the same name across calls. The old `wg-nwss${activeInterfaces.size}` // used the live Map size, so disconnect computed a DIFFERENT name // than connect did (size had grown in between) and silently failed // to find the entry — the interface would leak until disconnectAll. // // Truncated SHA-1 to 8 hex chars keeps the total under Linux's // 15-char IFNAMSIZ limit ('wg-nwss' = 7 + 8 = 15). if (vpnConfig.config_inline) { const hash = crypto.createHash('sha1').update(vpnConfig.config_inline).digest('hex').slice(0, 8); return `wg-nwss${hash}`; } // Last resort — should be unreachable if validation ran first. return 'wg-nwss-unknown'; } /** * Write inline config to temp file * @param {string} interfaceName - Interface name for the file * @param {string} configContent - WireGuard config content * @returns {string} Path to temp config file */ function writeInlineConfig(interfaceName, configContent) { if (!fs.existsSync(TEMP_CONFIG_DIR)) { fs.mkdirSync(TEMP_CONFIG_DIR, { recursive: true, mode: 0o700 }); } const configPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`); fs.writeFileSync(configPath, configContent, { mode: 0o600 }); return configPath; } /** * Bring up a WireGuard interface * @param {string} configPath - Path to .conf file (without extension for wg-quick) * @param {string} interfaceName - Interface name * @param {boolean} forceDebug - Debug logging * @returns {Object} { success, interface, error } */ function interfaceUp(configPath, interfaceName, forceDebug = false) { if (activeInterfaces.has(interfaceName)) { if (forceDebug) { console.log(formatLogMessage('debug', `${VPN_TAG} Interface ${interfaceName} already active`)); } return { success: true, interface: interfaceName, alreadyActive: true }; } try { // wg-quick accepts a config path or interface name in /etc/wireguard/. // spawnSync with arg array (no shell) — configPath comes from user // JSON, so naive `sudo wg-quick up "${configPath}"` was vulnerable // to a `";rm -rf ~;"` payload escaping the quotes. const upRes = spawnSync('sudo', ['wg-quick', 'up', configPath], { encoding: 'utf8', timeout: 15000 }); if (upRes.error) throw upRes.error; if (upRes.status !== 0) { throw new Error((upRes.stderr || '').trim() || `wg-quick up exited with status ${upRes.status}`); } activeInterfaces.set(interfaceName, { configPath, startedAt: Date.now(), sites: new Set() }); if (forceDebug) { console.log(formatLogMessage('debug', `${VPN_TAG} Interface ${interfaceName} is up`)); // Only fetch the external IP when debug-logging would actually // display it — getExternalIP runs 3 sequential 8s-timeout curls // (~24s worst case of blocking event loop). The result was // previously included in the return shape but no caller read // it; the work was pure waste outside debug runs. const externalIP = getExternalIP(interfaceName); if (externalIP) { console.log(formatLogMessage('debug', `${VPN_TAG} ${interfaceName} external IP: ${externalIP}`)); } } return { success: true, interface: interfaceName }; } catch (error) { return { success: false, interface: interfaceName, error: error.message.trim() }; } } /** * Bring down a WireGuard interface * @param {string} interfaceName - Interface name * @param {boolean} forceDebug - Debug logging * @returns {Object} { success, error } */ function interfaceDown(interfaceName, forceDebug = false) { const info = activeInterfaces.get(interfaceName); if (!info) { return { success: true, alreadyDown: true }; } try { // spawnSync with arg array (see interfaceUp comment for rationale). const downRes = spawnSync('sudo', ['wg-quick', 'down', info.configPath], { encoding: 'utf8', timeout: 10000 }); if (downRes.error) throw downRes.error; if (downRes.status !== 0) { throw new Error((downRes.stderr || '').trim() || `wg-quick down exited with status ${downRes.status}`); } activeInterfaces.delete(interfaceName); if (forceDebug) { console.log(formatLogMessage('debug', `${VPN_TAG} Interface ${interfaceName} is down`)); } return { success: true }; } catch (error) { // Force remove from tracking even if wg-quick fails activeInterfaces.delete(interfaceName); return { success: false, error: error.message.trim() }; } finally { // Clean up temp config regardless of wg-quick outcome — a leaked // .conf in TEMP_CONFIG_DIR could collide on a re-connect with the // same hash-derived interface name, especially after a wg-quick // down failure where the kernel interface might persist briefly. // Was previously only inside the try block, so failure paths // leaked the temp file. const tempPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`); if (fs.existsSync(tempPath)) { try { fs.unlinkSync(tempPath); } catch {} } } } /** * Check if a WireGuard interface is connected and passing traffic * @param {string} interfaceName - Interface name * @param {string} testHost - Host to ping (default: 1.1.1.1) * @param {boolean} forceDebug - Debug logging * @returns {Object} { connected, latencyMs, error } */ function checkConnection(interfaceName, testHost = '1.1.1.1', forceDebug = false) { try { // Check interface exists. spawnSync with arg array — interfaceName // can come from user JSON (siteConfig.vpn.interface) so the old // shell-interpolated `execSync(\`ip link show ${interfaceName}\`)` // was injection-vulnerable. const linkRes = spawnSync('ip', ['link', 'show', interfaceName], { encoding: 'utf8', timeout: 3000 }); if (linkRes.status !== 0) { throw new Error((linkRes.stderr || '').trim() || `ip link show failed for ${interfaceName}`); } // Ping through the specific interface. testHost defaults to '1.1.1.1' // but can be overridden by user config — same injection concern. const pingRes = spawnSync('ping', ['-c', '1', '-W', '5', '-I', interfaceName, testHost], { encoding: 'utf8', timeout: 8000 }); if (pingRes.status !== 0) { // Combine stderr + stdout (no shell `2>&1` available with spawnSync) throw new Error((pingRes.stderr || pingRes.stdout || '').split('\n')[0] || `ping failed for ${testHost}`); } const result = pingRes.stdout; const latencyMatch = result.match(/time=([0-9.]+)\s*ms/); const latencyMs = latencyMatch ? parseFloat(latencyMatch[1]) : null; if (forceDebug) { console.log(formatLogMessage('debug', `${VPN_TAG} ${interfaceName} connected (${latencyMs ? latencyMs + 'ms' : 'ok'})` )); } return { connected: true, latencyMs }; } catch (error) { if (forceDebug) { console.log(formatLogMessage('debug', `${VPN_TAG} ${interfaceName} health check failed: ${error.message.split('\n')[0]}` )); } return { connected: false, error: error.message.split('\n')[0] }; } } /** * Parse and validate a VPN site config * @param {Object|string} vpnConfig - VPN config from site JSON * @returns {Object} Normalized config { config, config_inline, interface, health_check, ... } */ function normalizeVpnConfig(vpnConfig) { // String shorthand: just a path to config if (typeof vpnConfig === 'string') { return { config: vpnConfig, interface: null, health_check: true }; } if (typeof vpnConfig !== 'object' || vpnConfig === null) { return null; } // Accept non-negative integers only — rejects: // - undefined/null/false (would have hit '|| 2' fallback anyway) // - strings like "3" (the old `|| 2` accepted those, then // `vpnConfig.max_retries + 1` downstream string-concatenated // to "31" and ran 31 retry attempts instead of 4) // - negative numbers / non-integers // Explicit 0 IS accepted now ("no retries, fail fast") — the old // `|| 2` treated 0 as falsy and silently substituted 2. const mr = vpnConfig.max_retries; const max_retries = (typeof mr === 'number' && Number.isInteger(mr) && mr >= 0) ? mr : 2; return { config: vpnConfig.config || null, config_inline: vpnConfig.config_inline || null, interface: vpnConfig.interface || null, health_check: vpnConfig.health_check !== false, test_host: vpnConfig.test_host || '1.1.1.1', retry: vpnConfig.retry !== false, max_retries }; } /** * Validate a VPN configuration * @param {Object} vpnConfig - Normalized VPN config * @returns {Object} { isValid, errors, warnings } */ function validateVpnConfig(vpnConfig) { const result = { isValid: true, errors: [], warnings: [] }; if (!vpnConfig) { result.isValid = false; result.errors.push('VPN configuration is null or invalid'); return result; } if (!vpnConfig.config && !vpnConfig.config_inline) { result.isValid = false; result.errors.push('VPN requires either "config" (path) or "config_inline" (content)'); return result; } if (vpnConfig.config && vpnConfig.config_inline) { result.warnings.push('Both "config" and "config_inline" provided; "config" takes precedence'); } // Validate config file exists if (vpnConfig.config) { const configPath = vpnConfig.config; // Accept both with and without .conf extension const pathsToCheck = [configPath, `${configPath}.conf`, `/etc/wireguard/${configPath}.conf`]; const found = pathsToCheck.some(p => fs.existsSync(p)); if (!found) { result.isValid = false; result.errors.push(`Config file not found: ${configPath} (also checked /etc/wireguard/)`); } } // Validate inline config has required sections if (vpnConfig.config_inline && !vpnConfig.config) { const content = vpnConfig.config_inline; if (!content.includes('[Interface]')) { result.isValid = false; result.errors.push('Inline config missing [Interface] section'); } if (!content.includes('[Peer]')) { result.isValid = false; result.errors.push('Inline config missing [Peer] section'); } if (!content.includes('PrivateKey')) { result.isValid = false; result.errors.push('Inline config missing PrivateKey'); } } if (!hasRootPrivileges()) { result.warnings.push('WireGuard requires root privileges - run with sudo'); } return result; } /** * Bring up VPN for a site, with health check and retry * @param {Object} siteConfig - Site configuration from JSON * @param {boolean} forceDebug - Debug logging * @returns {Promise<Object>} { success, interface, error } */ async function connectForSite(siteConfig, forceDebug = false) { const vpnConfig = normalizeVpnConfig(siteConfig.vpn); if (!vpnConfig) { return { success: false, error: 'Invalid VPN configuration' }; } const validation = validateVpnConfig(vpnConfig); if (!validation.isValid) { return { success: false, error: validation.errors.join('; ') }; } const interfaceName = resolveInterfaceName(vpnConfig); // Resolve config path let configPath; if (vpnConfig.config) { configPath = vpnConfig.config; // Resolve wg-quick style: if no path separators, look in /etc/wireguard/ if (!configPath.includes('/')) { const etcPath = `/etc/wireguard/${configPath}.conf`; if (fs.existsSync(etcPath)) { configPath = etcPath; } } } else { configPath = writeInlineConfig(interfaceName, vpnConfig.config_inline); } const maxAttempts = vpnConfig.retry ? vpnConfig.max_retries + 1 : 1; for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (forceDebug && attempt > 1) { console.log(formatLogMessage('debug', `${VPN_TAG} Retry ${attempt - 1}/${vpnConfig.max_retries} for ${interfaceName}` )); } // Ensure interface is down before retry if (attempt > 1) { interfaceDown(interfaceName, forceDebug); await new Promise(resolve => setTimeout(resolve, 2000)); } const upResult = interfaceUp(configPath, interfaceName, forceDebug); if (!upResult.success) { if (attempt === maxAttempts) { return upResult; } continue; } // Track which site is using this interface const info = activeInterfaces.get(interfaceName); if (info && siteConfig.url) { info.sites.add(siteConfig.url); } // Health check if (vpnConfig.health_check) { // Brief settle time for interface await new Promise(resolve => setTimeout(resolve, 1500)); const health = checkConnection(interfaceName, vpnConfig.test_host, forceDebug); if (!health.connected) { if (attempt === maxAttempts) { interfaceDown(interfaceName, forceDebug); return { success: false, interface: interfaceName, error: `Health check failed: ${health.error}` }; } continue; } } return { success: true, interface: interfaceName }; } return { success: false, interface: interfaceName, error: 'All attempts failed' }; } /** * Disconnect VPN for a site * If multiple sites share the interface, only removes the site reference * @param {Object} siteConfig - Site configuration from JSON * @param {boolean} forceDebug - Debug logging * @returns {Object} { success, tornDown, error } */ function disconnectForSite(siteConfig, forceDebug = false) { const vpnConfig = normalizeVpnConfig(siteConfig.vpn); if (!vpnConfig) return { success: true, tornDown: false }; const interfaceName = resolveInterfaceName(vpnConfig); const info = activeInterfaces.get(interfaceName); if (!info) { return { success: true, tornDown: false }; } // Remove this site from the interface's site set if (siteConfig.url) { info.sites.delete(siteConfig.url); } // Only tear down if no other sites are using it if (info.sites.size === 0) { const result = interfaceDown(interfaceName, forceDebug); return { success: result.success, tornDown: true, error: result.error }; } if (forceDebug) { console.log(formatLogMessage('debug', `${VPN_TAG} ${interfaceName} still used by ${info.sites.size} site(s), keeping up` )); } return { success: true, tornDown: false }; } /** * Tear down all active WireGuard interfaces * Call on process exit or cleanup * @param {boolean} forceDebug - Debug logging * @returns {Object} { tornDown, errors } */ function disconnectAll(forceDebug = false) { const results = { tornDown: 0, errors: [] }; for (const [interfaceName] of activeInterfaces) { const result = interfaceDown(interfaceName, forceDebug); if (result.success) { results.tornDown++; } else { results.errors.push({ interface: interfaceName, error: result.error }); } } // Clean up temp directory if (fs.existsSync(TEMP_CONFIG_DIR)) { try { fs.rmSync(TEMP_CONFIG_DIR, { recursive: true, force: true }); } catch {} } if (forceDebug && results.tornDown > 0) { console.log(formatLogMessage('debug', `${VPN_TAG} Disconnected ${results.tornDown} interface(s)` )); } return results; } // Public surface used by nwss.js. Internal helpers (checkConnection, // interfaceUp, interfaceDown, resolveInterfaceName, hasRootPrivileges, // getExternalIP, writeInlineConfig) stay module-private — none had // external callers. validateWireGuardAvailability, getInterfaceStatus, // and getActiveInterfaces were removed entirely (zero callers anywhere, // including no internal ones once their downstream consumers were // pruned). module.exports = { validateVpnConfig, normalizeVpnConfig, connectForSite, disconnectForSite, disconnectAll };