UNPKG

node-red-contrib-network-tools

Version:

Comprehensive network monitoring and discovery tools for Node-RED with Bonjour/mDNS support

678 lines (581 loc) 29.6 kB
module.exports = function(RED) { const ping = require('ping'); const net = require('net'); const dns = require('dns'); const { exec } = require('child_process'); const bonjour = require('bonjour'); function NetworkDiscoveryNode(config) { RED.nodes.createNode(this, config); var node = this; // Configuration node.subnet = config.subnet || '192.168.1.0/24'; node.portRange = config.portRange || '22,80,443'; node.timeout = config.timeout || 3000; node.concurrent = config.concurrent || 10; node.includeHostnames = config.includeHostnames || false; node.includePorts = config.includePorts || false; node.includeBonjourServices = config.includeBonjourServices || false; node.bonjourServiceTypes = config.bonjourServiceTypes || 'http,ssh,ftp,smb'; node.bonjourTimeout = config.bonjourTimeout || 5000; // Parse service types if it's a string if (typeof node.bonjourServiceTypes === 'string') { node.bonjourServiceTypes = node.bonjourServiceTypes.split(',').map(s => s.trim()).filter(s => s.length > 0); } var scanInProgress = false; var bonjourInstance = null; // Add initialization logging node.log(`Network Discovery Node initialized:`); node.log(` Default subnet: ${node.subnet}`); node.log(` Default timeout: ${node.timeout}ms`); node.log(` Default concurrent: ${node.concurrent}`); node.log(` Platform: ${process.platform}`); node.log(` Node.js version: ${process.version}`); node.on('input', function(msg) { if (scanInProgress) { node.warn("Scan already in progress"); return; } var subnet = msg.subnet || node.subnet; var portRange = msg.portRange || node.portRange; var timeout = msg.timeout || node.timeout; var concurrent = msg.concurrent || node.concurrent; var includeHostnames = msg.hasOwnProperty('includeHostnames') ? msg.includeHostnames : node.includeHostnames; var includePorts = msg.hasOwnProperty('includePorts') ? msg.includePorts : node.includePorts; var includeBonjourServices = msg.hasOwnProperty('includeBonjourServices') ? msg.includeBonjourServices : node.includeBonjourServices; var bonjourServiceTypes = msg.bonjourServiceTypes || node.bonjourServiceTypes; var bonjourTimeout = msg.bonjourTimeout || node.bonjourTimeout; // Parse service types if it's a string if (typeof bonjourServiceTypes === 'string') { bonjourServiceTypes = bonjourServiceTypes.split(',').map(s => s.trim()).filter(s => s.length > 0); } // Check for explicitly empty subnet configurations if (!subnet || subnet.trim() === '' || (config.subnet === "" && !msg.subnet)) { node.error("No subnet specified", msg); return; } scanInProgress = true; node.status({fill: "yellow", shape: "dot", text: "scanning " + subnet}); node.log(`=== Starting Discovery Process ===`); node.log(`Subnet: ${subnet}, Timeout: ${timeout}, Concurrent: ${concurrent}`); node.log(`Include hostnames: ${includeHostnames}, Include ports: ${includePorts}`); node.log(`Include Bonjour services: ${includeBonjourServices}, Service types: ${bonjourServiceTypes}`); discoverNetwork(subnet, portRange, timeout, concurrent, includeHostnames, includePorts, includeBonjourServices, bonjourServiceTypes, bonjourTimeout, msg); }); async function discoverNetwork(subnet, portRange, timeout, concurrent, includeHostnames, includePorts, includeBonjourServices, bonjourServiceTypes, bonjourTimeout, originalMsg) { try { var ipList = generateIPList(subnet); var discoveredDevices = []; var bonjourServices = []; var totalHosts = ipList.length; var scannedHosts = 0; node.log(`Starting network discovery for subnet: ${subnet}`); node.log(`Generated ${totalHosts} IPs to scan: ${ipList.slice(0, 5).join(', ')}${totalHosts > 5 ? '...' : ''}`); if (totalHosts === 0) { node.error("No IPs generated from subnet", originalMsg); return; } // Ping sweep with concurrency control var pingPromises = []; var semaphore = 0; for (let ip of ipList) { // Control concurrency while (semaphore >= concurrent) { await new Promise(resolve => setTimeout(resolve, 10)); } semaphore++; var promise = pingHost(ip, timeout).then(async (result) => { scannedHosts++; node.status({ fill: "yellow", shape: "dot", text: `scanning ${scannedHosts}/${totalHosts}` }); if (result.alive) { node.log(`Found alive host: ${ip} (${result.time}ms) via ${result.method}`); var device = { ip: ip, alive: true, responseTime: result.time, timestamp: new Date().toISOString(), detectionMethod: result.method }; // Add hostname if requested if (includeHostnames) { try { device.hostname = await resolveHostname(ip); } catch (e) { device.hostname = null; } } // Add port scan if requested if (includePorts) { device.openPorts = await scanPorts(ip, portRange, timeout); } discoveredDevices.push(device); } else { node.log(`Host ${ip} is not alive: ${result.error || 'No response'} (tried: ${result.method})`); } semaphore--; return result; }).catch(error => { scannedHosts++; semaphore--; node.log(`Exception pinging ${ip}: ${error.message}`); return { alive: false, error: error.message, method: 'exception' }; }); pingPromises.push(promise); } await Promise.all(pingPromises); // Perform Bonjour service discovery if enabled if (includeBonjourServices) { node.log(`=== Starting Bonjour Service Discovery ===`); node.status({fill: "yellow", shape: "dot", text: "discovering services"}); try { bonjourServices = await discoverBonjourServices(bonjourServiceTypes, bonjourTimeout); node.log(`Found ${bonjourServices.length} Bonjour services`); // Correlate Bonjour services with discovered devices correlateServicesWithDevices(discoveredDevices, bonjourServices); } catch (error) { node.warn(`Bonjour discovery failed: ${error.message}`); } } // Generate discovery report var report = { subnet: subnet, totalHosts: totalHosts, aliveHosts: discoveredDevices.length, bonjourServicesFound: bonjourServices.length, scanDuration: new Date().toISOString(), devices: discoveredDevices, bonjourServices: bonjourServices, scanOptions: { portRange: portRange, timeout: timeout, includeHostnames: includeHostnames, includePorts: includePorts, includeBonjourServices: includeBonjourServices, bonjourServiceTypes: bonjourServiceTypes } }; var outputMsg = { ...originalMsg, payload: report, topic: 'network-discovery' }; node.log(`=== Discovery Complete ===`); node.log(`Found ${discoveredDevices.length} devices out of ${totalHosts} scanned`); node.log(`Sending report: ${JSON.stringify(report, null, 2)}`); node.status({ fill: "green", shape: "dot", text: `found ${discoveredDevices.length}/${totalHosts} hosts` }); node.send(outputMsg); } catch (error) { node.error("Network discovery failed: " + error.message, originalMsg); node.status({fill: "red", shape: "ring", text: "error"}); } finally { scanInProgress = false; } } function generateIPList(subnet) { var ips = []; node.log(`Generating IP list for subnet: ${subnet}`); if (subnet.includes('/')) { // CIDR notation var [network, prefix] = subnet.split('/'); var prefixNum = parseInt(prefix); var networkParts = network.split('.').map(Number); node.log(`CIDR: network=${network}, prefix=${prefixNum}`); // Calculate network address and host bits var hostBits = 32 - prefixNum; var numHosts = Math.pow(2, hostBits) - 2; // Exclude network and broadcast if (hostBits > 16) { node.warn("Network too large, limiting scan to 254 hosts"); numHosts = 254; } // Simple approach for /24 networks (most common case) if (prefixNum === 24) { var baseIP = networkParts.slice(0, 3).join('.'); for (let i = 1; i <= 254; i++) { ips.push(`${baseIP}.${i}`); } } else { // More complex CIDR calculation for other prefix lengths var networkNum = (networkParts[0] << 24) + (networkParts[1] << 16) + (networkParts[2] << 8) + networkParts[3]; var subnetMask = ~((1 << hostBits) - 1); var networkAddress = networkNum & subnetMask; // Generate host IPs (skip network address and broadcast) for (let i = 1; i <= Math.min(numHosts, 254); i++) { var hostIP = networkAddress + i; ips.push(numberToIP(hostIP)); } } } else if (subnet.includes('-')) { // Range notation (e.g., 192.168.1.1-192.168.1.50 or 192.168.1.1-50) var [startIP, endPart] = subnet.split('-'); var startNum = ipToNumber(startIP); var endNum; // Check if endPart is a full IP or just the last octet if (endPart.includes('.')) { // Full IP address (e.g., 192.168.1.1-192.168.1.50) endNum = ipToNumber(endPart); } else { // Shorthand notation (e.g., 192.168.1.1-50) var startIPParts = startIP.split('.'); var endIPLastOctet = parseInt(endPart); // Validate the last octet if (endIPLastOctet < 0 || endIPLastOctet > 255) { node.error(`Invalid end octet in range: ${endPart}. Must be between 0-255.`); return []; } // Construct the full end IP using the same first 3 octets as start IP var endIP = startIPParts[0] + '.' + startIPParts[1] + '.' + startIPParts[2] + '.' + endIPLastOctet; endNum = ipToNumber(endIP); } // Validate range if (startNum > endNum) { node.error(`Invalid IP range: start IP (${startIP}) is greater than end IP`); return []; } for (let num = startNum; num <= endNum; num++) { ips.push(numberToIP(num)); } } else { // Single IP ips.push(subnet); } node.log(`Generated ${ips.length} IPs: ${ips.slice(0, 5).join(', ')}${ips.length > 5 ? '...' : ''}`); return ips; } function ipToNumber(ip) { return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0; } function numberToIP(num) { return [(num >>> 24), (num >>> 16 & 255), (num >>> 8 & 255), (num & 255)].join('.'); } async function pingHost(ip, timeout) { try { // Enhanced ping with multiple fallback methods node.log(`Attempting to ping ${ip} with timeout ${timeout}ms`); // Method 1: Try exec ping first (more reliable in Node-RED) node.log(`Trying exec ping for ${ip}`); try { var execResult = await execPing(ip, timeout); if (execResult.alive) { node.log(`Exec ping successful for ${ip}: ${execResult.time}ms`); return execResult; } } catch (execError) { node.log(`Exec ping failed for ${ip}: ${execError.message}`); } // Method 2: Try the ping library with detailed configuration var isWindows = process.platform === 'win32'; var config = { timeout: Math.max(1, Math.floor(timeout / 1000)), extra: isWindows ? ["-n", "1", "-w", timeout.toString()] : ["-c", "1", "-W", Math.floor(timeout / 1000).toString()], numeric: false, v6: false }; node.log(`Ping config for ${ip}: ${JSON.stringify(config)}`); try { var result = await ping.promise.probe(ip, config); node.log(`Ping library result for ${ip}: ${JSON.stringify(result)}`); if (result.alive) { return { alive: true, time: parseFloat(result.time || result.avg || result.min || 0), error: null, method: 'ping-library' }; } } catch (pingError) { node.log(`Ping library failed for ${ip}: ${pingError.message}`); } // Method 3: Last resort - TCP connect test on common ports node.log(`Trying TCP connect test for ${ip}`); try { var tcpResult = await tcpPingTest(ip, timeout); if (tcpResult.alive) { return tcpResult; } } catch (tcpError) { node.log(`TCP connect test failed for ${ip}: ${tcpError.message}`); } // All methods failed node.log(`All ping methods failed for ${ip}`); return { alive: false, time: 0, error: 'All ping methods failed', method: 'none' }; } catch (error) { node.error(`Critical ping error for ${ip}: ${error.message}`); return { alive: false, time: 0, error: error.message, method: 'error' }; } } async function execPing(ip, timeout) { return new Promise((resolve) => { var isWindows = process.platform === 'win32'; var command = isWindows ? `ping -n 1 -w ${timeout} ${ip}` : `ping -c 1 -W ${Math.floor(timeout/1000)} ${ip}`; node.log(`Executing: ${command}`); exec(command, { timeout: timeout + 1000 }, (error, stdout, stderr) => { if (error) { node.log(`Exec ping error for ${ip}: ${error.message}`); resolve({ alive: false, time: 0, error: error.message, method: 'exec-error' }); return; } var output = stdout + stderr; node.log(`Exec ping output for ${ip}: ${output.substring(0, 200)}`); // Parse Windows ping output if (isWindows) { if (output.includes('TTL=') || output.includes('time=')) { var timeMatch = output.match(/time[<=](\d+)ms/i); var time = timeMatch ? parseInt(timeMatch[1]) : 0; resolve({ alive: true, time: time, error: null, method: 'exec-windows' }); } else { resolve({ alive: false, time: 0, error: 'No TTL in output', method: 'exec-windows-fail' }); } } else { // Parse Linux/Unix ping output if (output.includes(' 0% packet loss') || output.includes('1 received')) { var timeMatch = output.match(/time=(\d+\.?\d*)/); var time = timeMatch ? parseFloat(timeMatch[1]) : 0; resolve({ alive: true, time: time, error: null, method: 'exec-unix' }); } else { resolve({ alive: false, time: 0, error: 'Packet loss detected', method: 'exec-unix-fail' }); } } }); }); } async function tcpPingTest(ip, timeout) { // Test common ports to see if host is reachable const testPorts = [80, 443, 22, 3389, 135, 445]; for (const port of testPorts) { try { var startTime = Date.now(); var isOpen = await testTcpPort(ip, port, Math.min(timeout, 2000)); if (isOpen) { var responseTime = Date.now() - startTime; node.log(`TCP ping successful for ${ip}:${port} in ${responseTime}ms`); return { alive: true, time: responseTime, error: null, method: `tcp-${port}`, port: port }; } } catch (e) { // Continue to next port } } return { alive: false, time: 0, error: 'No TCP ports responded', method: 'tcp-fail' }; } function testTcpPort(ip, port, timeout) { return new Promise((resolve) => { var socket = new net.Socket(); var timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeout); socket.connect(port, ip, () => { clearTimeout(timer); socket.destroy(); resolve(true); }); socket.on('error', () => { clearTimeout(timer); socket.destroy(); resolve(false); }); }); } async function resolveHostname(ip) { return new Promise((resolve, reject) => { dns.reverse(ip, (err, hostnames) => { if (err) reject(err); else resolve(hostnames[0] || null); }); }); } async function scanPorts(ip, portRange, timeout) { var ports = parsePortRange(portRange); var openPorts = []; var portPromises = ports.map(port => { return new Promise((resolve) => { var socket = new net.Socket(); var timer = setTimeout(() => { socket.destroy(); resolve(null); }, timeout); socket.connect(port, ip, () => { clearTimeout(timer); socket.destroy(); resolve(port); }); socket.on('error', () => { clearTimeout(timer); resolve(null); }); }); }); var results = await Promise.all(portPromises); return results.filter(port => port !== null); } function parsePortRange(portRange) { var ports = []; var ranges = portRange.split(','); ranges.forEach(range => { range = range.trim(); if (range.includes('-')) { var [start, end] = range.split('-').map(Number); for (let port = start; port <= end; port++) { ports.push(port); } } else { ports.push(Number(range)); } }); return ports; } async function discoverBonjourServices(serviceTypes, timeout) { return new Promise((resolve, reject) => { var services = []; var browsers = []; var timeoutId; try { // Initialize Bonjour instance if not already done if (!bonjourInstance) { bonjourInstance = bonjour(); } var completedTypes = 0; var totalTypes = serviceTypes.length; // Set up timeout timeoutId = setTimeout(() => { node.log(`Bonjour discovery timeout after ${timeout}ms`); cleanup(); resolve(services); }, timeout); function cleanup() { if (timeoutId) { clearTimeout(timeoutId); } browsers.forEach(browser => { try { browser.stop(); } catch (e) { // Ignore errors during cleanup } }); } function checkCompletion() { completedTypes++; if (completedTypes >= totalTypes) { setTimeout(() => { cleanup(); resolve(services); }, 1000); // Give it a second to catch any last services } } // Browse for each service type serviceTypes.forEach(serviceType => { try { var browser = bonjourInstance.find({ type: serviceType }, function(service) { node.log(`Found Bonjour service: ${service.name} (${service.type}) at ${service.host}:${service.port}`); // Add service to list if not already present var existingService = services.find(s => s.name === service.name && s.type === service.type && s.host === service.host && s.port === service.port ); if (!existingService) { services.push({ name: service.name, type: service.type, protocol: service.protocol || 'tcp', host: service.host, port: service.port, fqdn: service.fqdn, txt: service.txt || {}, addresses: service.addresses || [] }); } }); browsers.push(browser); // Mark this service type as completed after a brief delay setTimeout(() => { checkCompletion(); }, timeout / totalTypes); } catch (error) { node.log(`Error browsing for service type ${serviceType}: ${error.message}`); checkCompletion(); } }); // If no service types specified, resolve immediately if (totalTypes === 0) { cleanup(); resolve(services); } } catch (error) { node.log(`Bonjour discovery error: ${error.message}`); if (timeoutId) { clearTimeout(timeoutId); } reject(error); } }); } function correlateServicesWithDevices(devices, bonjourServices) { devices.forEach(device => { device.bonjourServices = []; bonjourServices.forEach(service => { // Try to match by IP address if (service.addresses && service.addresses.includes(device.ip)) { device.bonjourServices.push(service); } // Try to match by hostname else if (device.hostname && service.host === device.hostname) { device.bonjourServices.push(service); } // Try to match by resolving service host to IP else if (service.host === device.ip) { device.bonjourServices.push(service); } }); // Add discovered service types to device if (device.bonjourServices.length > 0) { device.serviceTypes = [...new Set(device.bonjourServices.map(s => s.type))]; device.bonjourServiceCount = device.bonjourServices.length; } else { device.serviceTypes = []; device.bonjourServiceCount = 0; } }); } node.on('close', function() { scanInProgress = false; if (bonjourInstance) { try { bonjourInstance.destroy(); bonjourInstance = null; } catch (error) { node.log(`Error destroying Bonjour instance: ${error.message}`); } } node.status({}); }); } RED.nodes.registerType("network-discovery", NetworkDiscoveryNode); };