Implement network scanning enhancements in SocketClient and WebSocketScanner
All checks were successful
farmcontrol/farmcontrol-server/pipeline/head This commit looks good

- 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.
This commit is contained in:
Tom Butcher 2026-09-14 22:50:40 +01:00
parent e7bf650e82
commit 3505121f3e
4 changed files with 357 additions and 134 deletions

View File

@ -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) {

View File

@ -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>} 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");
async scanNetwork(port, protocol, objectType = 'printer', { loop = false } = {}) {
this.stopped = false;
this.scanning = true;
let foundServices = [];
if (this.scanning) {
throw new Error('Scan already in progress');
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);
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();

View File

@ -1,41 +1,44 @@
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
@ -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 });
}
});

View File

@ -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 });
}
}