From 3505121f3e090cd86e53a2bd6935b7648e8f165e Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 14 Sep 2026 22:50:40 +0100 Subject: [PATCH] Implement network scanning enhancements in SocketClient and WebSocketScanner - Added support for multiple scan sessions in `SocketClient`, allowing concurrent network scans with unique session IDs. - Enhanced `WebSocketScanner` to support continuous scanning with the option to stop scans gracefully. - Introduced detailed event emissions for scan progress and completion, improving real-time feedback during network scans. - Updated `handleScanNetworkStart` and `handleScanNetworkStop` methods to manage scan sessions effectively, including error handling and session cleanup. - Improved logging for better visibility into scanning operations and results. --- src/host/hostmanager.js | 10 +- src/network/websocketScanner.js | 131 ++++++++------- src/network/websocketScannerWorker.js | 117 ++++++++++--- src/socket/socketclient.js | 233 ++++++++++++++++++++------ 4 files changed, 357 insertions(+), 134 deletions(-) diff --git a/src/host/hostmanager.js b/src/host/hostmanager.js index 8e5420b..b228459 100644 --- a/src/host/hostmanager.js +++ b/src/host/hostmanager.js @@ -172,8 +172,16 @@ export class HostManager { logger.error("Failed to reload printers:", error); }); return; + case "scanNetwork": + this.socketClient.handleScanNetworkStart(action.data, callback); + return; + case "scanNetworkStop": + this.socketClient.handleScanNetworkStop(action.data, callback); + return; + } + if (typeof callback === "function") { + callback({ success: true }); } - callback({ success: true }); } async handleUpdate(data) { diff --git a/src/network/websocketScanner.js b/src/network/websocketScanner.js index 671b8a8..763290e 100644 --- a/src/network/websocketScanner.js +++ b/src/network/websocketScanner.js @@ -11,56 +11,84 @@ export class WebSocketScanner extends EventEmitter { constructor(options = {}) { super(); this.scanning = false; + this.stopped = false; this.workers = []; - // Default to number of CPU cores, but allow override this.maxThreads = options.maxThreads || os.cpus().length; this.totalIPs = 0; this.scannedIPs = 0; } /** - * Scans the local network for websocket services on a specified port - * @param {number} port - Port number to scan - * @returns {Promise} Array of IP addresses where websocket service was found + * Scans the local network for services on a specified port. + * When loop is true, repeats until stopScan() is called. */ - async scanNetwork(port, protocol) { - // Clean up any existing workers before starting a new scan - this.cleanupWorkers(); - console.log("Cleaned up workers"); - - if (this.scanning) { - throw new Error('Scan already in progress'); + async scanNetwork(port, protocol, objectType = 'printer', { loop = false } = {}) { + this.stopped = false; + this.scanning = true; + let foundServices = []; + + try { + do { + await this.cleanupWorkers(); + if (this.stopped) { + break; + } + + foundServices = await this.scanOnce(port, protocol, objectType); + + if (this.stopped) { + break; + } + + this.emit('scanPassComplete', foundServices); + } while (loop && !this.stopped); + } finally { + await this.cleanupWorkers(); + this.scanning = false; } - this.scanning = true; + return foundServices; + } + + async scanOnce(port, protocol, objectType) { this.scannedIPs = 0; const foundServices = []; - - // Get local network range + const { startIP, endIP } = this.getLocalNetworkRange(); const start = this.ipToNumber(startIP); const end = this.ipToNumber(endIP); - // Calculate IP ranges for each worker this.totalIPs = end - start + 1; const ipsPerWorker = Math.ceil(this.totalIPs / this.maxThreads); const workerPromises = []; for (let i = 0; i < this.maxThreads; i++) { + if (this.stopped) { + break; + } + const workerStart = start + (i * ipsPerWorker); const workerEnd = Math.min(workerStart + ipsPerWorker - 1, end); - - if (workerStart > end) break; - const workerStartIP = this.numberToIP(workerStart); - const workerEndIP = this.numberToIP(workerEnd); + if (workerStart > end) break; const worker = new Worker(path.join(__dirname, 'websocketScannerWorker.js')); this.workers.push(worker); - console.log("Created worker", i); const workerPromise = new Promise((resolve) => { + let settled = false; + const finish = (results) => { + if (settled) return; + settled = true; + resolve(results || []); + }; + worker.on('message', (message) => { + if (this.stopped) { + finish([]); + return; + } + switch (message.type) { case 'serviceFound': foundServices.push({ ip: message.ip, hostname: message.hostname }); @@ -75,44 +103,41 @@ export class WebSocketScanner extends EventEmitter { }); break; case 'scanComplete': - resolve(message.results); + finish(message.results); break; } }); + worker.on('error', () => finish([])); + worker.on('exit', () => finish([])); }); worker.postMessage({ type: 'scan', - startIP: workerStartIP, - endIP: workerEndIP, + startIP: this.numberToIP(workerStart), + endIP: this.numberToIP(workerEnd), port, - protocol + protocol, + objectType }); workerPromises.push(workerPromise); } await Promise.all(workerPromises); - this.cleanupWorkers(); - this.scanning = false; + await this.cleanupWorkers(); return foundServices; } - cleanupWorkers() { - this.workers.forEach(worker => worker.terminate()); - this.workers = []; + async cleanupWorkers() { + const workers = this.workers.splice(0, this.workers.length); + await Promise.all(workers.map((worker) => worker.terminate())); } - /** - * Gets the local network IP range - * @returns {Object} Object containing startIP and endIP - */ getLocalNetworkRange() { const interfaces = os.networkInterfaces(); let localIP = null; let subnetMask = null; - // Find the first non-internal IPv4 address for (const name of Object.keys(interfaces)) { for (const iface of interfaces[name]) { if (iface.family === 'IPv4' && !iface.internal) { @@ -128,39 +153,22 @@ export class WebSocketScanner extends EventEmitter { throw new Error('Could not determine local network IP address'); } - // Convert IP and subnet mask to numbers const ipNum = this.ipToNumber(localIP); const maskNum = this.ipToNumber(subnetMask); - - // Calculate network address const networkNum = ipNum & maskNum; - - // Calculate broadcast address const broadcastNum = networkNum | (~maskNum >>> 0); - // Start IP is network address + 1 - const startIP = this.numberToIP(networkNum + 1); - // End IP is broadcast address - 1 - const endIP = this.numberToIP(broadcastNum - 1); - - return { startIP, endIP }; + return { + startIP: this.numberToIP(networkNum + 1), + endIP: this.numberToIP(broadcastNum - 1) + }; } - /** - * Converts an IP address to a number - * @param {string} ip - IP address to convert - * @returns {number} Numeric representation of IP - */ ipToNumber(ip) { return ip.split('.') .reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0; } - /** - * Converts a number to an IP address - * @param {number} num - Number to convert - * @returns {string} IP address - */ numberToIP(num) { return [ (num >>> 24) & 255, @@ -170,16 +178,9 @@ export class WebSocketScanner extends EventEmitter { ].join('.'); } - /** - * Stops the current scan - */ - stopScan() { + async stopScan() { + this.stopped = true; this.scanning = false; - this.cleanupWorkers(); + await this.cleanupWorkers(); } } - - - -// To stop scanning at any time: -// scanner.stopScan(); \ No newline at end of file diff --git a/src/network/websocketScannerWorker.js b/src/network/websocketScannerWorker.js index 9ff0a1f..9ed8a42 100644 --- a/src/network/websocketScannerWorker.js +++ b/src/network/websocketScannerWorker.js @@ -1,48 +1,51 @@ -import { parentPort, workerData } from 'worker_threads'; +import { parentPort } from 'worker_threads'; import WebSocket from 'ws'; +import net from 'net'; +import http from 'http'; +import https from 'https'; import { loadConfig } from "../config.js"; import log4js from "log4js"; import dns from 'dns'; -// Load configuration const config = loadConfig(); class WebSocketScannerWorker { constructor() { this.randomId = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); this.logger = log4js.getLogger(`WS Scanner #${this.randomId}`); - this.logger.level = config.server.logLevel; - this.timeout = 2000; // 2 second timeout for each connection attempt + this.logger.level = config.logLevel; + this.timeout = 2000; } - async scanRange(startIP, endIP, port, protocol) { + async scanRange(startIP, endIP, port, protocol, objectType) { const start = this.ipToNumber(startIP); const end = this.ipToNumber(endIP); const foundServices = []; - this.logger.info(`Scanning ${startIP} - ${endIP} on port: ${port} using: ${protocol}`); + this.logger.info( + `Scanning ${startIP} - ${endIP} on port: ${port} using: ${protocol} objectType: ${objectType}` + ); for (let ip = start; ip <= end; ip++) { const currentIP = this.numberToIP(ip); - const url = `${protocol}://${currentIP}:${port}/websocket`; try { - this.logger.debug(`Checking ${currentIP} for websocket service on port ${port}`); - const isOpen = await this.checkWebSocket(url); + this.logger.debug(`Checking ${currentIP} on port ${port}`); + const isOpen = await this.probe(currentIP, port, protocol, objectType); if (isOpen) { const hostname = await this.resolveHostname(currentIP); foundServices.push({ ip: currentIP, hostname }); - this.logger.info(`WebSocket connection successful for ${currentIP}${hostname ? ` (${hostname})` : ''}`); + this.logger.info( + `Service found at ${currentIP}${hostname ? ` (${hostname})` : ''}` + ); parentPort.postMessage({ type: 'serviceFound', ip: currentIP, hostname }); - } else { - this.logger.debug(`WebSocket connection failed for ${currentIP}`); } } catch (error) { // Connection failed, continue scanning } - parentPort.postMessage({ - type: 'scanProgress', + parentPort.postMessage({ + type: 'scanProgress', currentIP, increment: 1 }); @@ -51,12 +54,28 @@ class WebSocketScannerWorker { return foundServices; } + async probe(ip, port, protocol, objectType) { + if (protocol === 'serial' || protocol === 'system') { + return false; + } + + if (objectType === 'printer' || protocol === 'ws' || protocol === 'wss') { + const url = `${protocol || 'ws'}://${ip}:${port}/websocket`; + return this.checkWebSocket(url); + } + + if (protocol === 'http' || protocol === 'https') { + return this.checkHttp(ip, port, protocol); + } + + return this.checkTcp(ip, port); + } + async resolveHostname(ip) { try { const hostnames = await dns.promises.reverse(ip); return hostnames[0] || null; } catch (error) { - // Only log errors that aren't ENOTFOUND (which is expected for many IPs) if (error.code !== 'ENOTFOUND') { this.logger.warn(`Unexpected error resolving hostname for ${ip}: ${error.message}`); } @@ -67,7 +86,7 @@ class WebSocketScannerWorker { checkWebSocket(url) { return new Promise((resolve) => { const ws = new WebSocket(url); - let timeout = setTimeout(() => { + const timeout = setTimeout(() => { ws.terminate(); resolve(false); }, this.timeout); @@ -85,6 +104,61 @@ class WebSocketScannerWorker { }); } + checkTcp(ip, port) { + return new Promise((resolve) => { + const socket = new net.Socket(); + const timeout = setTimeout(() => { + socket.destroy(); + resolve(false); + }, this.timeout); + + socket.once('connect', () => { + clearTimeout(timeout); + socket.destroy(); + resolve(true); + }); + + socket.once('error', () => { + clearTimeout(timeout); + socket.destroy(); + resolve(false); + }); + + socket.connect(port, ip); + }); + } + + checkHttp(ip, port, protocol) { + const client = protocol === 'https' ? https : http; + return new Promise((resolve) => { + const req = client.request( + { + host: ip, + port, + method: 'GET', + path: '/', + timeout: this.timeout, + rejectUnauthorized: false + }, + (res) => { + res.resume(); + resolve(true); + } + ); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.on('error', () => { + resolve(false); + }); + + req.end(); + }); + } + ipToNumber(ip) { return ip.split('.') .reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0; @@ -100,11 +174,16 @@ class WebSocketScannerWorker { } } -// Handle messages from the main thread parentPort.on('message', async (data) => { if (data.type === 'scan') { const scanner = new WebSocketScannerWorker(); - const results = await scanner.scanRange(data.startIP, data.endIP, data.port, data.protocol); + const results = await scanner.scanRange( + data.startIP, + data.endIP, + data.port, + data.protocol, + data.objectType + ); parentPort.postMessage({ type: 'scanComplete', results }); } -}); \ No newline at end of file +}); diff --git a/src/socket/socketclient.js b/src/socket/socketclient.js index 4291c71..ac57a74 100644 --- a/src/socket/socketclient.js +++ b/src/socket/socketclient.js @@ -27,7 +27,8 @@ export class SocketClient { this.fileManager = new FileManager(this); this.printerManager = new PrinterManager(this); this.documentPrinterManager = new DocumentPrinterManager(this); - this.scanner = new WebSocketScanner({ maxThreads: 50 }); + this.scanSessions = new Map(); + this.scanSessionTokens = new Map(); this.readLine = null; this.objectEventCallbacks = new Map(); sendIPC("setOnline", false); @@ -707,64 +708,198 @@ export class SocketClient { //-------------------------------------- RE-WRITE ENDS HERE --------------------------------------- + emitScanNetworkEvent(eventType, eventData) { + this.objectEvent({ + objectType: "host", + _id: this.id, + eventType, + eventData, + }); + } + + async stopScanSession(sessionId, { emitStopped = false } = {}) { + const scanner = this.scanSessions.get(sessionId); + if (!scanner) { + return false; + } + + this.scanSessions.delete(sessionId); + scanner.removeAllListeners("serviceFound"); + scanner.removeAllListeners("scanProgress"); + scanner.removeAllListeners("scanPassComplete"); + scanner.removeAllListeners("scanComplete"); + await scanner.stopScan(); + + if (emitStopped) { + this.emitScanNetworkEvent("scanNetworkComplete", { + sessionId, + success: true, + stopped: true, + }); + } + + return true; + } + async handleScanNetworkStart(data, callback) { - if (this.scanner.scanning == false) { - try { - this.scanner = new WebSocketScanner({ maxThreads: 50 }); - // Listen for found services - this.scanner.on("serviceFound", (data) => { - logger.info( - `Found websocket service at ${data.hostname} (${data.ip})`, - ); - this.socket.emit("notify_scan_network_found", data); - }); + const protocol = data?.protocol; + const sessionId = data?.sessionId; - // Listen for scan progress - this.scanner.on("scanProgress", ({ currentIP, progress }) => { - logger.info( - `Scanning ${currentIP} (${progress.toFixed(2)}% complete)`, - ); - this.socket.emit("notify_scan_network_progress", { - currentIP: currentIP, - progress: progress, - }); - }); + if (!sessionId) { + if (typeof callback === "function") { + callback({ success: false, error: "Scan sessionId is required" }); + } + return; + } - // Start scanning on port + if (protocol === "serial" || protocol === "system") { + if (typeof callback === "function") { + callback({ + success: false, + error: "Cannot scan serial or system protocols", + }); + } + return; + } + + const startToken = {}; + this.scanSessionTokens.set(sessionId, startToken); + + await this.stopScanSession(sessionId); + + if (this.scanSessionTokens.get(sessionId) !== startToken) { + return; + } + + try { + const scanner = new WebSocketScanner({ maxThreads: 50 }); + this.scanSessions.set(sessionId, scanner); + + scanner.on("serviceFound", (found) => { logger.info( - "Scanning network for websocket services on port:", - data?.port || 7125, - "using protocol:", - data?.protocol || "ws", + `Found service at ${found.hostname || found.ip} (${found.ip})`, ); - this.scanner - .scanNetwork(data?.port || 7125, data?.protocol || "ws") - .then((foundServices) => { - logger.info("Scan complete. Found services:", foundServices); - this.socket.emit("notify_scan_network_complete", foundServices); - }) - .catch((error) => { - logger.error("Scan error:", error); - this.socket.emit("notify_scan_network_complete", false); + this.emitScanNetworkEvent("scanNetworkFound", { + ...found, + sessionId, + }); + }); + + scanner.on("scanProgress", ({ currentIP, progress }) => { + logger.debug( + `Scanning ${currentIP} (${progress.toFixed(2)}% complete)`, + ); + this.emitScanNetworkEvent("scanNetworkProgress", { + currentIP, + progress, + sessionId, + }); + }); + + scanner.on("scanPassComplete", (foundServices) => { + if (this.scanSessions.get(sessionId) !== scanner) { + return; + } + logger.info("Scan pass complete. Found services:", foundServices); + this.emitScanNetworkEvent("scanNetworkComplete", { + success: true, + devices: foundServices, + sessionId, + looping: true, + }); + }); + + const port = + data?.port || (data?.objectType === "documentPrinter" ? 631 : 7125); + const scanProtocol = + protocol || (data?.objectType === "documentPrinter" ? "ipp" : "ws"); + const objectType = data?.objectType || "printer"; + + logger.info( + "Scanning network on port:", + port, + "using protocol:", + scanProtocol, + "objectType:", + objectType, + "sessionId:", + sessionId, + ); + + if (typeof callback === "function") { + callback({ success: true, sessionId }); + } + + scanner + .scanNetwork(port, scanProtocol, objectType, { loop: true }) + .then((foundServices) => { + if (this.scanSessions.get(sessionId) !== scanner) { + return; + } + this.scanSessions.delete(sessionId); + logger.info("Scan session ended. Found services:", foundServices); + this.emitScanNetworkEvent("scanNetworkComplete", { + success: true, + devices: foundServices, + sessionId, + looping: false, }); - } catch (error) { - logger.error("Scan error:", error); - this.socket.emit("notify_scan_network_complete", false); + }) + .catch((error) => { + if (this.scanSessions.get(sessionId) !== scanner) { + return; + } + this.scanSessions.delete(sessionId); + logger.error("Scan error:", error); + this.emitScanNetworkEvent("scanNetworkComplete", { + success: false, + error: error?.message || "Scan failed", + devices: [], + sessionId, + }); + }); + } catch (error) { + logger.error("Scan error:", error); + await this.stopScanSession(sessionId); + this.emitScanNetworkEvent("scanNetworkComplete", { + success: false, + error: error?.message || "Scan failed", + devices: [], + sessionId, + }); + if (typeof callback === "function") { + callback({ success: false, error: error?.message || "Scan failed" }); } } } - handleScanNetworkStop(callback) { - if (this.scanner.scanning == true) { - logger.info("Stopping network scan"); - this.scanner.removeAllListeners("serviceFound"); - this.scanner.removeAllListeners("scanProgress"); - this.scanner.removeAllListeners("scanComplete"); - this.scanner.stopScan(); - callback(true); - } else { - logger.info("Scan not in progress"); - callback(false); + async handleScanNetworkStop(data, callback) { + const sessionId = data?.sessionId; + + if (sessionId) { + const stopped = await this.stopScanSession(sessionId, { + emitStopped: true, + }); + if (typeof callback === "function") { + callback( + stopped + ? { success: true, sessionId } + : { + success: true, + sessionId, + error: "Scan session not in progress", + }, + ); + } + return; + } + + logger.info("Stopping all network scan sessions"); + for (const id of Array.from(this.scanSessions.keys())) { + await this.stopScanSession(id, { emitStopped: true }); + } + if (typeof callback === "function") { + callback({ success: true }); } }