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.

1,177 lines (1,050 loc) 72 kB
/** * Network tools module for whois and dig lookups - COMPLETE FIXED VERSION * Provides domain analysis capabilities with proper timeout handling, custom whois servers, and retry logic */ const { exec, execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const { formatLogMessage, messageColors } = require('./colorize'); const ANSI_REGEX = /\x1b\[[0-9;]*m/g; // Cycling index for whois server rotation let whoisServerCycleIndex = 0; // Global dig result cache — shared across ALL handler instances and processUrl calls // Key: `${domain}-${recordType}`, Value: { result, timestamp } // DNS records don't change based on what terms you're searching for, // so we cache the raw dig output and let each handler check its own terms against it const globalDigResultCache = new Map(); const GLOBAL_DIG_CACHE_TTL = 50400000; // 14 hours (persisted to disk between runs) const GLOBAL_DIG_CACHE_MAX = 1000; // Global whois result cache — shared across ALL handler instances and processUrl calls // Whois data is per root domain and doesn't change based on search terms const globalWhoisResultCache = new Map(); const GLOBAL_WHOIS_CACHE_TTL = 50400000; // 14 hours (persisted to disk between runs) const GLOBAL_WHOIS_CACHE_MAX = 1000; // Persistent disk cache file paths const DIG_CACHE_FILE = path.join(__dirname, '..', '.digcache'); const WHOIS_CACHE_FILE = path.join(__dirname, '..', '.whoiscache'); /** * Load persistent cache from disk into in-memory Map * Skips expired entries and enforces max size * @param {string} filePath - Path to cache file * @param {Map} cache - In-memory cache Map to populate * @param {number} ttl - TTL in milliseconds * @param {number} maxSize - Maximum cache entries */ function loadDiskCache(filePath, cache, ttl, maxSize) { try { if (!fs.existsSync(filePath)) return; const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); const now = Date.now(); let loaded = 0; for (const [key, entry] of Object.entries(data)) { if (loaded >= maxSize) break; if (now - entry.timestamp < ttl) { cache.set(key, entry); loaded++; } } } catch { // Corrupt or unreadable cache file — delete and start fresh try { fs.unlinkSync(filePath); } catch {} } } /** * Save in-memory cache to disk, evicting oldest entries if over max size * @param {string} filePath - Path to cache file * @param {Map} cache - In-memory cache Map to persist * @param {number} ttl - TTL in milliseconds * @param {number} maxSize - Maximum cache entries */ function saveDiskCache(filePath, cache, ttl, maxSize) { try { const now = Date.now(); const entries = {}; let count = 0; // Collect valid entries, skip expired for (const [key, entry] of cache) { if (now - entry.timestamp < ttl) { entries[key] = entry; count++; } } // If over max, keep only the newest entries. Drop the pretty-print — // saveDiskCache runs on the synchronous 'exit' handler when --dns-cache // is set, so any work here directly delays scan exit. Compact JSON is // several times faster on multi-megabyte caches and the file is not // intended for human reading. if (count > maxSize) { const sorted = Object.entries(entries) .sort((a, b) => b[1].timestamp - a[1].timestamp) .slice(0, maxSize); const trimmed = {}; for (const [key, entry] of sorted) { trimmed[key] = entry; } fs.writeFileSync(filePath, JSON.stringify(trimmed)); } else { fs.writeFileSync(filePath, JSON.stringify(entries)); } } catch { // Disk write failed — non-fatal, in-memory cache still works } } // Track in-flight lookups to prevent duplicate concurrent requests const pendingDigLookups = new Map(); const pendingWhoisLookups = new Map(); // DNS cache statistics const dnsCacheStats = { digHits: 0, digMisses: 0, whoisHits: 0, whoisMisses: 0, freshDig: [], freshWhois: [] }; /** * Get DNS cache statistics for end-of-scan reporting * @returns {Object} Cache hit/miss counts and fresh domain lists */ function getDnsCacheStats() { return { ...dnsCacheStats }; } // Disk cache is opt-in via --dns-cache flag let diskCacheEnabled = false; /** * Enable persistent disk caching for dig/whois results * Call this when --dns-cache flag is set */ function enableDiskCache() { diskCacheEnabled = true; loadDiskCache(DIG_CACHE_FILE, globalDigResultCache, GLOBAL_DIG_CACHE_TTL, GLOBAL_DIG_CACHE_MAX); loadDiskCache(WHOIS_CACHE_FILE, globalWhoisResultCache, GLOBAL_WHOIS_CACHE_TTL, GLOBAL_WHOIS_CACHE_MAX); // Save caches to disk once on process exit instead of per-lookup. The // 'exit' handler fires synchronously regardless of how the process exits // (normal completion, signal, uncaught exception), so a separate signal // handler is redundant. We deliberately do NOT install SIGINT/SIGTERM // handlers here — nwss.js installs its own async ones that perform // browser/VPN cleanup, and a sync handler here would call process.exit(0) // first and skip that cleanup entirely. const flushCaches = () => { saveDiskCache(DIG_CACHE_FILE, globalDigResultCache, GLOBAL_DIG_CACHE_TTL, GLOBAL_DIG_CACHE_MAX); saveDiskCache(WHOIS_CACHE_FILE, globalWhoisResultCache, GLOBAL_WHOIS_CACHE_TTL, GLOBAL_WHOIS_CACHE_MAX); }; process.on('exit', flushCaches); } /** * Strips ANSI color codes from a string for clean file logging * @param {string} text - Text that may contain ANSI codes * @returns {string} Text with ANSI codes removed */ function stripAnsiColors(text) { // Remove ANSI escape sequences (color codes) ANSI_REGEX.lastIndex = 0; return text.replace(ANSI_REGEX, ''); } /** * Validates if whois command is available on the system * @returns {Object} Object with isAvailable boolean and version/error info */ function validateWhoisAvailability() { if (validateWhoisAvailability._cached) return validateWhoisAvailability._cached; try { const result = execSync('whois --version 2>&1', { encoding: 'utf8' }); validateWhoisAvailability._cached = { isAvailable: true, version: result.trim() }; } catch (error) { try { execSync('which whois', { encoding: 'utf8' }); validateWhoisAvailability._cached = { isAvailable: true, version: 'whois (version unknown)' }; } catch (e) { validateWhoisAvailability._cached = { isAvailable: false, error: 'whois command not found' }; } } return validateWhoisAvailability._cached; } /** * Validates if dig command is available on the system * @returns {Object} Object with isAvailable boolean and version/error info */ function validateDigAvailability() { if (validateDigAvailability._cached) return validateDigAvailability._cached; try { const result = execSync('dig -v 2>&1', { encoding: 'utf8' }); validateDigAvailability._cached = { isAvailable: true, version: result.split('\n')[0].trim() }; } catch (error) { validateDigAvailability._cached = { isAvailable: false, error: 'dig command not found' }; } return validateDigAvailability._cached; } /** * Executes a command with proper timeout handling * @param {string} command - Command to execute * @param {number} timeout - Timeout in milliseconds * @returns {Promise<Object>} Promise that resolves with stdout/stderr or rejects on timeout/error */ function execWithTimeout(command, timeout = 10000) { return new Promise((resolve, reject) => { const child = exec(command, { encoding: 'utf8' }, (error, stdout, stderr) => { if (timer) clearTimeout(timer); if (error) { reject(error); } else { resolve({ stdout, stderr }); } }); // Set up timeout const timer = setTimeout(() => { child.kill('SIGTERM'); // Force kill after 2 seconds if SIGTERM doesn't work. unref() so this // tail timer doesn't keep the event loop alive past scan completion — // a dig that times out near the end of a scan would otherwise delay // exit by ~2 seconds. const killTimer = setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } }, 2000); killTimer.unref(); reject(new Error(`Command timeout after ${timeout}ms: ${command}`)); }, timeout); // Handle child process errors child.on('error', (err) => { if (timer) clearTimeout(timer); reject(err); }); }); } /** * Selects a whois server from the configuration * @param {string|Array<string>} whoisServer - Single server string or array of servers * @param {string} mode - Selection mode: 'random' (default) or 'cycle' * @returns {string|null} Selected whois server or null if none specified */ function selectWhoisServer(whoisServer = '', mode = 'random'){ if (!whoisServer) { return null; // Use default whois behavior } if (typeof whoisServer === 'string') { return whoisServer; } if (Array.isArray(whoisServer) && whoisServer.length > 0) { if (mode === 'cycle') { const selectedServer = whoisServer[whoisServerCycleIndex % whoisServer.length]; whoisServerCycleIndex = (whoisServerCycleIndex + 1) % whoisServer.length; return selectedServer; } else { // Random selection (default behavior) const randomIndex = Math.floor(Math.random() * whoisServer.length); return whoisServer[randomIndex]; } } return null; } /** * Gets common whois servers for debugging/fallback suggestions * @returns {Array<string>} List of common whois servers */ function getCommonWhoisServers() { return [ 'whois.iana.org', 'whois.internic.net', 'whois.verisign-grs.com', 'whois.markmonitor.com', 'whois.godaddy.com', 'whois.namecheap.com', 'whois.1and1.com' ]; } /** * Suggests alternative whois servers based on domain TLD * @param {string} domain - Domain to get suggestions for * @param {string} failedServer - Server that failed (to exclude from suggestions) * @returns {Array<string>} Suggested whois servers */ function suggestWhoisServers(domain, failedServer = null) { const tld = domain.split('.').pop().toLowerCase(); const suggestions = []; // TLD-specific servers const tldServers = { 'com': ['whois.verisign-grs.com', 'whois.internic.net'], 'net': ['whois.verisign-grs.com', 'whois.internic.net'], 'org': ['whois.pir.org'], 'info': ['whois.afilias.net'], 'biz': ['whois.neulevel.biz'], 'uk': ['whois.nominet.uk'], 'de': ['whois.denic.de'], 'fr': ['whois.afnic.fr'], 'it': ['whois.nic.it'], 'nl': ['whois.domain-registry.nl'] }; if (tldServers[tld]) { suggestions.push(...tldServers[tld]); } // Add common servers suggestions.push(...getCommonWhoisServers()); // Remove duplicates and failed server const uniqueSuggestions = [...new Set(suggestions)]; return failedServer ? uniqueSuggestions.filter(s => s !== failedServer) : uniqueSuggestions; } /** * Performs a whois lookup on a domain with proper timeout handling and custom server support (basic version) * @param {string} domain - Domain to lookup * @param {number} timeout - Timeout in milliseconds (default: 10000) * @param {string|Array<string>} whoisServer - Custom whois server(s) to use * @param {boolean} debugMode - Enable debug logging (default: false) * @returns {Promise<Object>} Object with success status and output/error */ async function whoisLookup(domain = '', timeout = 10000, whoisServer = '', debugMode = false, logFunc = null) { const startTime = Date.now(); let cleanDomain, selectedServer, whoisCommand; try { // Clean domain (remove protocol, path, etc) cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, ''); // Select whois server if provided selectedServer = selectWhoisServer(whoisServer); // Build whois command if (selectedServer) { // Use custom whois server with -h flag whoisCommand = `whois -h "${selectedServer}" -- "${cleanDomain}"`; } else { // Use default whois behavior whoisCommand = `whois -- "${cleanDomain}"`; } if (debugMode) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Starting lookup for ${cleanDomain} (timeout: ${timeout}ms)`); logFunc(`${messageColors.highlight('[whois]')} Command: ${whoisCommand}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Starting lookup for ${cleanDomain} (timeout: ${timeout}ms)`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Command: ${whoisCommand}`)); } } const { stdout, stderr } = await execWithTimeout(whoisCommand, timeout); const duration = Date.now() - startTime; if (stderr && stderr.trim()) { if (debugMode) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Lookup failed for ${cleanDomain} after ${duration}ms`); logFunc(`${messageColors.highlight('[whois]')} Server: ${selectedServer || 'default'}`); logFunc(`${messageColors.highlight('[whois]')} Error: ${stderr.trim()}`); logFunc(`${messageColors.highlight('[whois]')} Command executed: ${whoisCommand}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Lookup failed for ${cleanDomain} after ${duration}ms`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Server: ${selectedServer || 'default'}`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Error: ${stderr.trim()}`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Command executed: ${whoisCommand}`)); } if (selectedServer) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Custom server used: ${selectedServer}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Custom server used: ${selectedServer}`)); } } } return { success: false, error: stderr.trim(), domain: cleanDomain, whoisServer: selectedServer, duration: duration, command: whoisCommand }; } if (debugMode) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Lookup successful for ${cleanDomain} after ${duration}ms`); logFunc(`${messageColors.highlight('[whois]')} Server: ${selectedServer || 'default'}`); logFunc(`${messageColors.highlight('[whois]')} Output length: ${stdout.length} characters`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Lookup successful for ${cleanDomain} after ${duration}ms`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Server: ${selectedServer || 'default'}`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Output length: ${stdout.length} characters`)); } } return { success: true, output: stdout, domain: cleanDomain, whoisServer: selectedServer, duration: duration, command: whoisCommand }; } catch (error) { const duration = Date.now() - startTime; const isTimeout = error.message.includes('timeout') || error.message.includes('Command timeout'); const errorType = isTimeout ? 'timeout' : 'error'; if (debugMode) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Lookup ${errorType} for ${cleanDomain || domain} after ${duration}ms`); logFunc(`${messageColors.highlight('[whois]')} Server: ${selectedServer || 'default'}`); logFunc(`${messageColors.highlight('[whois]')} Command: ${whoisCommand || 'command not built'}`); logFunc(`${messageColors.highlight('[whois]')} ${errorType === 'timeout' ? 'Timeout' : 'Error'}: ${error.message}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Lookup ${errorType} for ${cleanDomain || domain} after ${duration}ms`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Server: ${selectedServer || 'default'}`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Command: ${whoisCommand || 'command not built'}`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} ${errorType === 'timeout' ? 'Timeout' : 'Error'}: ${error.message}`)); } if (selectedServer) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Failed server: ${selectedServer} (custom)`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Failed server: ${selectedServer} (custom)`)); } } else { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Failed server: system default whois server`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Failed server: system default whois server`)); } } if (isTimeout) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Timeout exceeded ${timeout}ms limit`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Timeout exceeded ${timeout}ms limit`)); } if (selectedServer) { if (logFunc) { logFunc(`${messageColors.highlight('[whois]')} Consider using a different whois server or increasing timeout`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Consider using a different whois server or increasing timeout`)); } } } } return { success: false, error: error.message, domain: cleanDomain || domain, whoisServer: selectedServer, duration: duration, command: whoisCommand, isTimeout: isTimeout, errorType: errorType }; } } /** * Performs a whois lookup with retry logic and fallback servers * @param {string} domain - Domain to lookup * @param {number} timeout - Timeout in milliseconds (default: 10000) * @param {string|Array<string>} whoisServer - Custom whois server(s) to use * @param {boolean} debugMode - Enable debug logging (default: false) * @param {Object} retryOptions - Retry configuration options * @param {number} whoisDelay - Delay in milliseconds before whois requests (default: 2000) * @returns {Promise<Object>} Object with success status and output/error */ async function whoisLookupWithRetry(domain = '', timeout = 10000, whoisServer = '', debugMode = false, retryOptions = {}, whoisDelay = 8000, logFunc = null) { const { maxRetries = 3, timeoutMultiplier = 1.5, useFallbackServers = true, retryOnTimeout = true, retryOnError = true } = retryOptions; let serversToTry = []; // Build list of servers to try if (whoisServer && whoisServer !== '') { if (Array.isArray(whoisServer)) { serversToTry = [...whoisServer]; // Copy array to avoid modifying original } else { serversToTry = [whoisServer]; } } else { serversToTry = ['']; // Default server (empty string instead of null) } // Add fallback servers if enabled and we have custom servers if (useFallbackServers && whoisServer && whoisServer !== '') { const fallbacks = suggestWhoisServers(domain).slice(0, 3); // Only add fallbacks that aren't already in our list const existingServers = serversToTry.filter(s => s !== ''); const existingServerCount = existingServers.length; const newFallbacks = fallbacks.filter(fb => { for (let i = 0; i < existingServerCount; i++) { if (existingServers[i] === fb) return false; } return true; }); serversToTry.push(...newFallbacks); } let lastError = null; let totalAttempts = 0; let serversAttempted = []; if (debugMode) { const totalServers = serversToTry.length; if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Starting whois lookup for ${domain} with ${totalServers} server(s) to try`); logFunc(`${messageColors.highlight('[whois-retry]')} Servers: [${serversToTry.map(s => s || 'default').join(', ')}]`); logFunc(`${messageColors.highlight('[whois-retry]')} Retry settings: maxRetries=${maxRetries} per server, timeoutMultiplier=${timeoutMultiplier}, retryOnTimeout=${retryOnTimeout}, retryOnError=${retryOnError}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Starting whois lookup for ${domain} with ${totalServers} server(s) to try`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Servers: [${serversToTry.map(s => s || 'default').join(', ')}]`)); console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Retry settings: maxRetries=${maxRetries} per server, timeoutMultiplier=${timeoutMultiplier}, retryOnTimeout=${retryOnTimeout}, retryOnError=${retryOnError}`)); } } // Try each server with retry logic const serverCount = serversToTry.length; for (let serverIndex = 0; serverIndex < serverCount; serverIndex++) { const server = serversToTry[serverIndex]; let currentTimeout = timeout; let retryCount = 0; serversAttempted.push(server); if (debugMode) { const serverName = (server && server !== '') ? server : 'default'; if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Server ${serverIndex + 1}/${serverCount}: ${serverName} (max ${maxRetries} attempts)`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Server ${serverIndex + 1}/${serverCount}: ${serverName} (max ${maxRetries} attempts)`)); } } // Retry this server up to maxRetries times while (retryCount < maxRetries) { totalAttempts++; const attemptNum = retryCount + 1; if (debugMode) { const serverName = (server && server !== '') ? server : 'default'; if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Attempt ${attemptNum}/${maxRetries} on server ${serverName} (timeout: ${currentTimeout}ms)`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Attempt ${attemptNum}/${maxRetries} on server ${serverName} (timeout: ${currentTimeout}ms)`)); } } // Add progressive delay between retries (but not before first attempt on any server) if (retryCount > 0 && whoisDelay > 0) { // Progressive delay: base delay * retry attempt number + extra delay // Attempt 2: base delay * 1 + 4000ms = 8000ms + 4000ms = 12000ms // Attempt 3: base delay * 2 + 6000ms = 16000ms + 6000ms = 22000ms // Attempt 4+: base delay * 3 + 6000ms = 24000ms + 6000ms = 30000ms (if maxRetries > 3) const delayMultiplier = Math.min(retryCount, 3); const baseDelay = whoisDelay * delayMultiplier; // Add extra delay based on retry attempt let extraDelay = 0; if (retryCount === 1) { extraDelay = 4000; // Extra 4 seconds for 2nd attempt } else if (retryCount >= 2) { extraDelay = 6000; // Extra 6 seconds for 3rd+ attempts } const actualDelay = baseDelay + extraDelay; if (debugMode) { if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Adding ${actualDelay}ms progressive delay before retry ${retryCount + 1} (base: ${baseDelay}ms + extra: ${extraDelay}ms)...`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${actualDelay}ms progressive delay before retry ${retryCount + 1} (base: ${baseDelay}ms + extra: ${extraDelay}ms)...`)); } } await new Promise(resolve => setTimeout(resolve, actualDelay)); } else if (serverIndex > 0 && retryCount === 0 && whoisDelay > 0) { // Add delay before trying a new server (but not the very first server) if (debugMode) { if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Adding ${whoisDelay}ms delay before trying new server...`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${whoisDelay}ms delay before trying new server...`)); } } await new Promise(resolve => setTimeout(resolve, whoisDelay)); } else if (debugMode && whoisDelay === 0) { // Log when delay is skipped due to whoisDelay being 0 if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Skipping delay (whoisDelay: ${whoisDelay}ms)`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Skipping delay (whoisDelay: ${whoisDelay}ms)`)); } } try { const result = await whoisLookup(domain, currentTimeout, server || '', debugMode, logFunc); if (result.success) { if (debugMode) { if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} SUCCESS on attempt ${attemptNum}/${maxRetries} for server ${result.whoisServer || 'default'} (total attempts: ${totalAttempts})`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} SUCCESS on attempt ${attemptNum}/${maxRetries} for server ${result.whoisServer || 'default'} (total attempts: ${totalAttempts})`)); } } // Add retry info to result // V8 Optimized: Object.assign performs better than spread return Object.assign({}, result, { retryInfo: { totalAttempts: totalAttempts, maxAttempts: serverCount * maxRetries, serversAttempted: serversAttempted, finalServer: result.whoisServer, retriedAfterFailure: totalAttempts > 1, serverRetries: retryCount, serverIndex: serverIndex } }); } // Determine if we should retry based on error type const shouldRetry = (result.isTimeout && retryOnTimeout) || (!result.isTimeout && retryOnError); if (debugMode) { const serverName = (result.whoisServer && result.whoisServer !== '') ? result.whoisServer : 'default'; const errorType = result.isTimeout ? 'TIMEOUT' : 'ERROR'; if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} ${errorType} on attempt ${attemptNum}/${maxRetries} with server ${serverName}: ${result.error}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} ${errorType} on attempt ${attemptNum}/${maxRetries} with server ${serverName}: ${result.error}`)); } if (retryCount < maxRetries - 1) { if (shouldRetry) { if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Will retry attempt ${attemptNum + 1}/${maxRetries} on same server...`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Will retry attempt ${attemptNum + 1}/${maxRetries} on same server...`)); } } else { if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Skipping retry on same server (retryOn${result.isTimeout ? 'Timeout' : 'Error'}=${shouldRetry})`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Skipping retry on same server (retryOn${result.isTimeout ? 'Timeout' : 'Error'}=${shouldRetry})`)); } } } else if (serverIndex < serverCount - 1) { if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Max retries reached for server${serverIndex < serverCount - 1 ? ', will try next server...' : ', no more servers to try'}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Max retries reached for server${serverIndex < serverCount - 1 ? ', will try next server...' : ', no more servers to try'}`)); } } } lastError = result; // If this is the last retry for this server or we shouldn't retry this error type, break to next server if (retryCount >= maxRetries - 1 || !shouldRetry) { break; } // Increase timeout for next retry attempt on same server retryCount++; currentTimeout = Math.round(currentTimeout * timeoutMultiplier); } catch (error) { if (debugMode) { const serverName = (server && server !== '') ? server : 'default' if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} EXCEPTION on attempt ${attemptNum}/${maxRetries} with server ${serverName}: ${error.message}`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} EXCEPTION on attempt ${attemptNum}/${maxRetries} with server ${serverName}: ${error.message}`)); } } lastError = { success: false, error: error.message, domain: domain, whoisServer: server || '', isTimeout: error.message.includes('timeout'), duration: 0 }; // For exceptions, only retry if it's a retryable error type const isRetryableException = error.message.includes('timeout') || error.message.includes('ECONNRESET') || error.message.includes('ENOTFOUND'); if (retryCount >= maxRetries - 1 || !isRetryableException) { break; } retryCount++; currentTimeout = Math.round(currentTimeout * timeoutMultiplier); } } } // All attempts failed if (debugMode) { const attemptedServerCount = serversAttempted.length; if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} FINAL FAILURE: All ${totalAttempts} attempts failed for ${domain} across ${attemptedServerCount} server(s)`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} FINAL FAILURE: All ${totalAttempts} attempts failed for ${domain} across ${attemptedServerCount} server(s)`)); } if (lastError) { if (logFunc) { logFunc(`${messageColors.highlight('[whois-retry]')} Last error: ${lastError.error} (${lastError.isTimeout ? 'timeout' : 'error'})`); } else { console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Last error: ${lastError.error} (${lastError.isTimeout ? 'timeout' : 'error'})`)); } } } // Return the last error with retry info // V8 Optimized: Object.assign instead of spread operator return Object.assign({}, lastError, { retryInfo: { totalAttempts: totalAttempts, maxAttempts: serverCount * maxRetries, serversAttempted: serversAttempted, finalServer: lastError?.whoisServer || '', retriedAfterFailure: totalAttempts > 1, allAttemptsFailed: true } }); } /** * Performs a dig lookup on a domain with proper timeout handling * @param {string} domain - Domain to lookup * @param {string} recordType - DNS record type (A, AAAA, MX, TXT, etc.) default: 'A' * @param {number} timeout - Timeout in milliseconds (default: 5000) * @returns {Promise<Object>} Object with success status and output/error */ async function digLookup(domain = '', recordType = 'A', timeout = 5000) { try { // Clean domain const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, ''); // Single dig command — full output contains everything including the short answers const { stdout: fullOutput, stderr } = await execWithTimeout(`dig "${cleanDomain}" ${recordType}`, timeout); if (stderr && stderr.trim()) { return { success: false, error: stderr.trim(), domain: cleanDomain, recordType }; } // Extract short output from ANSWER SECTION of full dig output const answerMatch = fullOutput.match(/;; ANSWER SECTION:\n([\s\S]*?)(?:\n;;|\n*$)/); let shortOutput = ''; if (answerMatch) { shortOutput = answerMatch[1] .split('\n') .map(line => line.split(/\s+/).pop()) .filter(Boolean) .join('\n'); } return { success: true, output: fullOutput, shortOutput, domain: cleanDomain, recordType }; } catch (error) { return { success: false, error: error.message, domain: domain, recordType }; } } /** * Checks if whois output contains all specified search terms (AND logic) * @param {string} whoisOutput - The whois lookup output * @param {Array<string>} searchTerms - Array of terms that must all be present * @returns {boolean} True if all terms are found */ function checkWhoisTerms(whoisOutput, searchTerms) { if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) { return false; } const lowerOutput = whoisOutput.toLowerCase(); return searchTerms.every(term => lowerOutput.includes(term.toLowerCase())); } /** * Checks if whois output contains any of the specified search terms (OR logic) * @param {string} whoisOutput - The whois lookup output * @param {Array<string>} searchTerms - Array of terms where at least one must be present * @returns {boolean} True if any term is found */ function checkWhoisTermsOr(whoisOutput, searchTerms) { if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) { return false; } const lowerOutput = whoisOutput.toLowerCase(); return searchTerms.some(term => lowerOutput.includes(term.toLowerCase())); } /** * Checks if dig output contains all specified search terms (AND logic) * @param {string} digOutput - The dig lookup output * @param {Array<string>} searchTerms - Array of terms that must all be present * @returns {boolean} True if all terms are found */ function checkDigTerms(digOutput, searchTerms) { if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) { return false; } const lowerOutput = digOutput.toLowerCase(); return searchTerms.every(term => lowerOutput.includes(term.toLowerCase())); } /** * Checks if dig output contains any of the specified search terms (OR logic) * @param {string} digOutput - The dig lookup output * @param {Array<string>} searchTerms - Array of terms where at least one must be present * @returns {boolean} True if any term is found */ function checkDigTermsOr(digOutput, searchTerms) { if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) { return false; } const lowerOutput = digOutput.toLowerCase(); return searchTerms.some(term => lowerOutput.includes(term.toLowerCase())); } /** * Enhanced dry run callback factory for better nettools reporting * @param {Map} matchedDomains - The matched domains collection * @param {boolean} forceDebug - Debug logging flag * @returns {Function} Enhanced dry run callback */ function createEnhancedDryRunCallback(matchedDomains, forceDebug) { return (domain, tool, matchType, matchedTerm, details, additionalInfo = {}) => { const result = { domain, tool, matchType, matchedTerm, details, ...additionalInfo }; matchedDomains.get('dryRunNetTools').push(result); if (forceDebug) { const serverInfo = additionalInfo.server ? ` (server: ${additionalInfo.server})` : ''; const timingInfo = additionalInfo.duration ? ` [${additionalInfo.duration}ms]` : ''; console.log(formatLogMessage('debug', `[DRY RUN] NetTools match: ${domain} via ${tool.toUpperCase()} (${matchType})${serverInfo}${timingInfo}`)); } }; } /** * Creates a handler for network tools checks with enhanced error handling * @param {Object} config - Configuration object * @returns {Function} Async function that handles network tool lookups */ function createNetToolsHandler(config) { const { whoisTerms, whoisOrTerms, whoisDelay = 4000, whoisServer, whoisServerMode = 'random', debugLogFile = null, digTerms, digOrTerms, digRecordType = 'A', digSubdomain = false, dryRunCallback = null, matchedDomains, addMatchedDomain, isDomainAlreadyDetected, getRootDomain, siteConfig, processedWhoisDomains = new Set(), // Accept global sets, fallback to new for backward compatibility processedDigDomains = new Set(), dumpUrls, matchedUrlsLogFile, forceDebug, fs } = config; const hasWhois = whoisTerms && Array.isArray(whoisTerms) && whoisTerms.length > 0; const hasWhoisOr = whoisOrTerms && Array.isArray(whoisOrTerms) && whoisOrTerms.length > 0; const hasDig = digTerms && Array.isArray(digTerms) && digTerms.length > 0; const hasDigOr = digOrTerms && Array.isArray(digOrTerms) && digOrTerms.length > 0; // Pre-lowercase search terms once per handler so the per-domain check loop // doesn't re-lowercase the same constants for every output it scans. const whoisTermsLower = hasWhois ? whoisTerms.map(t => t.toLowerCase()) : null; const whoisOrTermsLower = hasWhoisOr ? whoisOrTerms.map(t => t.toLowerCase()) : null; const digTermsLower = hasDig ? digTerms.map(t => t.toLowerCase()) : null; const digOrTermsLower = hasDigOr ? digOrTerms.map(t => t.toLowerCase()) : null; // Hoisted out of handleNetToolsCheck so the closure is constructed once per // handler rather than once per invocation. References forceDebug, debugLogFile, // and fs from the destructured config above. function logToConsoleAndFile(message) { if (forceDebug) { console.log(formatLogMessage('debug', message)); } if (debugLogFile && fs) { try { const timestamp = new Date().toISOString(); const cleanMessage = stripAnsiColors(message); fs.appendFileSync(debugLogFile, `${timestamp} [debug nettools] ${cleanMessage}\n`); } catch (_) { // Silently fail file logging to avoid disrupting whois operations } } } // Create config-aware cache keys for deduplication // Whois: Only include search terms + server (domain registry data is consistent across subdomains) const whoisConfigKey = JSON.stringify({ terms: whoisTerms || [], orTerms: whoisOrTerms || [], server: whoisServer || 'default', serverMode: whoisServerMode || 'random' }); // Dig: Include all config (DNS records can vary by specific subdomain) const digConfigKey = JSON.stringify({ terms: digTerms || [], orTerms: digOrTerms || [], recordType: digRecordType, subdomain: digSubdomain }); // Whois cache is global (globalWhoisResultCache) — shared across all handler instances // Whois data is per root domain and doesn't change based on search terms // Dig cache is global (globalDigResultCache) — shared across all handler instances // DNS results are the same regardless of search terms return async function handleNetToolsCheck(domain, fullSubdomain) { const originalDomain = fullSubdomain; // Check if domain was already detected (skip expensive operations) if (typeof isDomainAlreadyDetected === 'function' && isDomainAlreadyDetected(fullSubdomain)) { if (forceDebug) { logToConsoleAndFile(`${messageColors.highlight('[nettools]')} Skipping already detected subdomain: ${fullSubdomain} (output domain: ${domain})`); } return; } // Determine which domain will be used for dig lookup const digDomain = digSubdomain && originalDomain ? originalDomain : domain; // For whois: use root domain only (whois data is consistent for entire domain) const whoisRootDomain = getRootDomain ? getRootDomain(`http://${domain}`) : domain; // Check if we need to perform any lookups with appropriate deduplication // Whois: root domain + config (whois data same for sub.example.com and example.com) const whoisDedupeKey = `${whoisRootDomain}:${whoisConfigKey}`; // Dig: specific subdomain + config (DNS records can differ between subdomains) const digDedupeKey = `${digDomain}:${digConfigKey}`; const needsWhoisLookup = (hasWhois || hasWhoisOr) && !processedWhoisDomains.has(whoisDedupeKey); const needsDigLookup = (hasDig || hasDigOr) && !processedDigDomains.has(digDedupeKey); // Skip if we don't need to perform any lookups if (!needsWhoisLookup && !needsDigLookup) { if (forceDebug) { const whoisSkipped = (hasWhois || hasWhoisOr) ? `cached(${whoisRootDomain})` : 'n/a'; const digSkipped = (hasDig || hasDigOr) ? `cached(${digDomain})` : 'n/a'; logToConsoleAndFile(`${messageColors.highlight('[nettools]')} Skipping duplicate lookups for ${domain} (whois: ${whoisSkipped}, dig: ${digSkipped})`); } return; } if (forceDebug) { const totalProcessed = processedWhoisDomains.size + processedDigDomains.size; logToConsoleAndFile(`${messageColors.highlight('[nettools]')} Processing domain: ${domain} (whois: ${needsWhoisLookup ? whoisRootDomain : 'skip'}, dig: ${needsDigLookup ? digDomain : 'skip'}) (${totalProcessed} total processed)`); } // Log site-specific whois delay if different from default if (forceDebug && whoisDelay !== 3000) { logToConsoleAndFile(`${messageColors.highlight('[nettools]')} Using site-specific whois delay: ${whoisDelay}ms`); } // Wrap entire function in timeout protection (single timer) let overallTimeoutId; return Promise.race([ (async () => { try { return await executeNetToolsLookup(); } finally { clearTimeout(overallTimeoutId); } })(), new Promise((_, reject) => { overallTimeoutId = setTimeout(() => reject(new Error('NetTools overall timeout')), 65000); }) ]).catch(err => { if (forceDebug) { logToConsoleAndFile(`${messageColors.highlight('[nettools]')} ${err.message} for ${domain}, continuing...`); } }); async function executeNetToolsLookup() { try { let whoisMatched = false; let whoisOrMatched = false; let digMatched = false; let digOrMatched = false; // Debug logging for digSubdomain logic if (forceDebug) { logToConsoleAndFile(`${messageColors.highlight('[nettools]')} digSubdomain setting: ${digSubdomain}`); logToConsoleAndFile(`${messageColors.highlight('[nettools]')} domain parameter: ${domain}`); logToConsoleAndFile(`${messageColors.highlight('[nettools]')} originalDomain parameter: ${originalDomain}`); logToConsoleAndFile(`${messageColors.highlight('[nettools]')} Final digDomain will be: ${digDomain}`); if (whoisServer) { const serverInfo = Array.isArray(whoisServer) ? `randomized from [${whoisServer.join(', ')}]` : whoisServer; logToConsoleAndFile(`${messageColors.highlight('[nettools]')} Custom whois server: ${serverInfo}`); } } // Enhanced dry run logging if (dryRunCallback && forceDebug) { logToConsoleAndFile(`${messageColors.highlight('[nettools-dryrun]')} Processing ${domain} (original: ${originalDomain})`); // Show what checks will be performed const checksToPerform = []; if (hasWhois) checksToPerform.push('whois-and'); if (hasWhoisOr) checksToPerform.push('whois-or'); if (hasDig) checksToPerform.push('dig-and'); if (hasDigOr) checksToPerform.push('dig-or'); logToConsoleAndFile(`${messageColors.highlight('[nettools-dryrun]')} Will perform: ${checksToPerform.join(', ')}`); // Show which domain will be used for dig if (hasDig || hasDigOr) { logToConsoleAndFile(`${messageColors.highlight('[dig-dryrun]')} Will check ${digDomain} (${digSubdomain ? 'subdomain mode' : 'root domain mode'})`); } // Show whois server selection if (hasWhois || hasWhoisOr) { const selectedServer = selectWhoisServer(whoisServer, whoisServerMode); const serverInfo = selectedServer ? selectedServer : 'system default'; logToConsoleAndFile(`${messageColors.highlight('[whois-dryrun]')} Will use server: ${serverInfo}`); } // Show retry configuration in dry-run if (hasWhois || hasWhoisOr) { const maxRetries = siteConfig.whois_max_retries || 2; logToConsoleAndFile(`${messageColors.highlight('[whois-dryrun]')} Max retries: ${maxRetries}, timeout multiplier: ${siteConfig.whois_timeout_multiplier || 1.5}`); } } // Perform whois lookup if either whois or whois-or is configured if (needsWhoisLookup) { // Mark whois root domain+config as being processed processedWhoisDomains.add(whoisDedupeKey); // Check whois cache first - cache key includes server for accuracy const selectedServer = selectWhoisServer(whoisServer, whoisServerMode); const whoisCacheKey = `${whoisRootDomain}-${(selectedServer && selectedServer !== '') ? selectedServer : 'default'}`; const now = Date.now(); let whoisResult = null; if (globalWhoisResultCache.has(whoisCacheKey)) { const cachedEntry = globalWhoisResultCache.get(whoisCacheKey); if (now - cachedEntry.timestamp < GLOBAL_WHOIS_CACHE_TTL) { if (forceDebug) { const age = Math.round((now - cachedEntry.timestamp) / 1000); const serverInfo = (selectedServer && selectedServer !== '') ? ` (server: ${selectedServer})` : ' (default server)'; logToConsoleAndFile(`${messageColors.highlight('[whois-cache]')} Using cached result for ${whoisRootDomain}${serverInfo} [age: ${age}s]`); } // V8 Optimized: Object.assign is faster than spread for object merging whoisResult = Object.assign({}, cachedEntry.result, { fromCache: true, cacheAge: now - cachedEntry.timestamp, originalTimestamp: cachedEntry.timestamp }); dnsCacheStats.whoisHits++; } else { // Cache expired, remove it globalWhoisResultCache.delete(whoisCacheKey); if (forceDebug) { logToConsoleAndFile(`${messageColors.highlight('[whois-cache]')} Cache expired for ${whoisRootDomain}, performing fresh lookup`); } } } // Perform fresh lookup if not cached if (!whoisResult) { // Deduplicate concurrent lookups — wait for in-flight request instead of starting a new one if (pendingWhoisLookups.has(whoisCacheKey)) { whoisResult = await pendingWhoisLookups.get(whoisCacheKey); } else { if (forceDebug) { const serverInfo = (selectedServer && selectedServer !== '') ? ` using server ${selectedServer}` : ' using default server'; logToConsoleAndFile(`${messageColors.highlight('[whois]')} Performing fresh whois lookup for ${whoisRootDomain}${serverInfo}`); } // Configure retry options based on site config or use defaults const retryOptions = { maxRetries: siteConfig.whois_max_retries || 3, timeoutMultiplier: siteConfig.whois_timeout_multiplier || 1.5, useFallbackServers: siteConfig.whois_use_fallback !== false, // Default true retryOnTimeout: siteConfig.whois_retry_on_timeout !== false, // Default true retryOnError: siteConfig.whois_retry_on_error === true // Default false }; try { const lookupPromise = whoisLookupWithRetry(whoisRootDomain, 8000, whoisServer, forceDebug, retryOptions, whoisDelay, logToConsoleAndFile); pendingWhoisLookups.set(whoisCacheKey, lookupPromise); // try/finally so a rejected lookup still clears the pending // entry — see matching comment on pendingDigLookups below. try { whoisResult = await lookupPromise; } finally { pendingWhoisLookups.delete(whoisCacheKey); } // Cache successful results (and certain types of failures) if (whoisResult.success || (whoisResult.error && !whoisResult.isTimeout && !whoisResult.error.toLowerCase().includes('connection') && !whoisResult.error.toLowerCase().includes('network'))) { globalWhoisResultCache.set(whoisCacheKey, { result: whoisResult, timestamp: now }); dnsCacheStats.whoisMisses++; dnsCacheStats.freshW