import * as plugins from '../plugins.js'; import { EsclProtocol } from '../protocols/index.js'; import { cidrToIps, ipRangeToIps, isValidIp, } from '../helpers/helpers.iprange.js'; import type { INetworkScanOptions, INetworkScanResult, INetworkScanDevice, INetworkScanProgress, } from '../interfaces/index.js'; /** * Default ports to probe for device discovery */ const DEFAULT_PORTS = [ 631, // IPP printers 80, // eSCL scanners (HTTP) 443, // eSCL scanners (HTTPS) 6566, // SANE scanners 9100, // JetDirect printers 7000, // AirPlay speakers 5000, // AirPlay control / RTSP 3689, // DAAP (iTunes/AirPlay 2) 1400, // Sonos speakers 8009, // Chromecast devices ]; /** * Default scan options */ const DEFAULT_OPTIONS: Required> = { concurrency: 50, timeout: 2000, ports: DEFAULT_PORTS, probeEscl: true, probeIpp: true, probeSane: true, probeAirplay: true, probeSonos: true, probeChromecast: true, }; /** * Simple concurrency limiter */ class ConcurrencyLimiter { private running = 0; private queue: (() => void)[] = []; constructor(private limit: number) {} async run(fn: () => Promise): Promise { while (this.running >= this.limit) { await new Promise((resolve) => this.queue.push(resolve)); } this.running++; try { return await fn(); } finally { this.running--; const next = this.queue.shift(); if (next) next(); } } } /** * Network Scanner - scans IP ranges for scanners and printers */ export class NetworkScanner extends plugins.events.EventEmitter { private cancelled = false; private scanning = false; /** * Check if a scan is currently in progress */ public get isScanning(): boolean { return this.scanning; } /** * Scan a network range for devices */ public async scan(options: INetworkScanOptions): Promise { if (this.scanning) { throw new Error('A scan is already in progress'); } this.cancelled = false; this.scanning = true; const opts = { ...DEFAULT_OPTIONS, ...options }; const results: INetworkScanResult[] = []; try { // Get list of IPs to scan const ips = this.resolveIps(options); if (ips.length === 0) { throw new Error('No IPs to scan. Provide ipRange, or startIp and endIp.'); } const limiter = new ConcurrencyLimiter(opts.concurrency); let scanned = 0; // Progress tracking const emitProgress = (currentIp?: string) => { const progress: INetworkScanProgress = { total: ips.length, scanned, percentage: Math.round((scanned / ips.length) * 100), currentIp, devicesFound: results.reduce((sum, r) => sum + r.devices.length, 0), }; this.emit('progress', progress); }; emitProgress(); // Scan all IPs with concurrency limit const scanPromises = ips.map((ip) => limiter.run(async () => { if (this.cancelled) return; const devices = await this.probeIp(ip, opts); scanned++; if (devices.length > 0) { const result: INetworkScanResult = { address: ip, devices }; results.push(result); this.emit('device:found', result); } emitProgress(ip); }) ); await Promise.all(scanPromises); if (this.cancelled) { this.emit('cancelled'); } else { this.emit('complete', results); } return results; } finally { this.scanning = false; } } /** * Cancel an ongoing scan */ public async cancel(): Promise { if (this.scanning) { this.cancelled = true; } } /** * Resolve IPs from options (CIDR or start/end range) */ private resolveIps(options: INetworkScanOptions): string[] { if (options.ipRange) { return cidrToIps(options.ipRange); } if (options.startIp && options.endIp) { return ipRangeToIps(options.startIp, options.endIp); } if (options.startIp && !options.endIp) { // Single IP if (isValidIp(options.startIp)) { return [options.startIp]; } } return []; } /** * Probe a single IP address for devices */ private async probeIp( ip: string, opts: Required> ): Promise { const devices: INetworkScanDevice[] = []; const timeout = opts.timeout; // First, do a quick port scan to see which ports are open const openPorts = await this.scanPorts(ip, opts.ports, timeout / 2); if (openPorts.length === 0 || this.cancelled) { return devices; } // Probe each open port for specific protocols const probePromises: Promise[] = []; for (const port of openPorts) { // IPP probe (port 631) if (opts.probeIpp && port === 631) { probePromises.push( this.probeIpp(ip, port, timeout).then((device) => { if (device) devices.push(device); }) ); } // eSCL probe (ports 80, 443) if (opts.probeEscl && (port === 80 || port === 443)) { probePromises.push( this.probeEscl(ip, port, timeout).then((device) => { if (device) devices.push(device); }) ); } // SANE probe (port 6566) if (opts.probeSane && port === 6566) { probePromises.push( this.probeSane(ip, port, timeout).then((device) => { if (device) devices.push(device); }) ); } // JetDirect probe (port 9100) - just mark as raw printer if (port === 9100) { devices.push({ type: 'printer', protocol: 'jetdirect', port: 9100, name: `Raw Printer at ${ip}`, }); } // AirPlay probe (port 7000) - try HTTP endpoints if (opts.probeAirplay && port === 7000) { probePromises.push( this.probeAirplay(ip, port, timeout).then((device) => { if (device) devices.push(device); }) ); } // AirPlay ports 5000 (RTSP) and 3689 (DAAP) - if open, it's likely an AirPlay device if (opts.probeAirplay && (port === 5000 || port === 3689)) { devices.push({ type: 'speaker', protocol: 'airplay', port, name: `AirPlay Device at ${ip}`, }); } // Sonos probe (port 1400) if (opts.probeSonos && port === 1400) { probePromises.push( this.probeSonos(ip, port, timeout).then((device) => { if (device) devices.push(device); }) ); } // Chromecast probe (port 8009) if (opts.probeChromecast && port === 8009) { probePromises.push( this.probeChromecast(ip, port, timeout).then((device) => { if (device) devices.push(device); }) ); } } await Promise.all(probePromises); return devices; } /** * Quick TCP port scan */ private async scanPorts(ip: string, ports: number[], timeout: number): Promise { const openPorts: number[] = []; const scanPromises = ports.map(async (port) => { const isOpen = await this.isPortOpen(ip, port, timeout); if (isOpen) { openPorts.push(port); } }); await Promise.all(scanPromises); return openPorts; } /** * Check if a TCP port is open */ private isPortOpen(ip: string, port: number, timeout: number): Promise { return new Promise((resolve) => { const socket = new plugins.net.Socket(); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeout); socket.on('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); }); socket.on('error', () => { clearTimeout(timer); socket.destroy(); resolve(false); }); socket.connect(port, ip); }); } /** * Probe for IPP printer using a simple HTTP check * We avoid using the full ipp library for probing since it can hang and produce noisy output */ private async probeIpp( ip: string, port: number, timeout: number ): Promise { try { // Use a simple HTTP OPTIONS or POST to check if IPP endpoint exists // IPP uses HTTP POST to /ipp/print or /ipp/printer const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(`http://${ip}:${port}/ipp/print`, { method: 'POST', headers: { 'Content-Type': 'application/ipp', }, body: Buffer.from([ // Minimal IPP Get-Printer-Attributes request 0x01, 0x01, // IPP version 1.1 0x00, 0x0b, // operation-id: Get-Printer-Attributes 0x00, 0x00, 0x00, 0x01, // request-id: 1 0x01, // operation-attributes-tag 0x47, // charset 0x00, 0x12, // name-length: 18 ...Buffer.from('attributes-charset'), 0x00, 0x05, // value-length: 5 ...Buffer.from('utf-8'), 0x48, // naturalLanguage 0x00, 0x1b, // name-length: 27 ...Buffer.from('attributes-natural-language'), 0x00, 0x05, // value-length: 5 ...Buffer.from('en-us'), 0x03, // end-of-attributes-tag ]), signal: controller.signal, }); clearTimeout(timeoutId); // If we get a response with application/ipp content type, it's likely an IPP printer const contentType = response.headers.get('content-type') || ''; if (response.ok || contentType.includes('application/ipp')) { return { type: 'printer', protocol: 'ipp', port, name: `IPP Printer at ${ip}`, model: undefined, }; } } catch (fetchErr) { clearTimeout(timeoutId); // Fetch failed or was aborted } } catch { // Not an IPP printer } return null; } /** * Probe for eSCL scanner */ private async probeEscl( ip: string, port: number, timeout: number ): Promise { try { const secure = port === 443; const escl = new EsclProtocol(ip, port, secure); const caps = await Promise.race([ escl.getCapabilities(), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout) ), ]); if (caps) { return { type: 'scanner', protocol: 'escl', port, name: caps.makeAndModel || `eSCL Scanner at ${ip}`, model: caps.makeAndModel, }; } } catch { // Not an eSCL scanner } return null; } /** * Probe for SANE scanner (quick check) */ private async probeSane( ip: string, port: number, timeout: number ): Promise { // SANE probing requires full protocol implementation // For now, just check if port is open and assume it's SANE // A more thorough implementation would send SANE_NET_INIT try { const isOpen = await this.isPortOpen(ip, port, timeout); if (isOpen) { return { type: 'scanner', protocol: 'sane', port, name: `SANE Scanner at ${ip}`, }; } } catch { // Not a SANE scanner } return null; } /** * Probe for AirPlay speaker on port 7000 * Tries HTTP endpoints to identify the device. If no HTTP response, * but port 7000 is open, it's still likely an AirPlay device. */ private async probeAirplay( ip: string, port: number, timeout: number ): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); // Try /server-info first (older AirPlay devices) try { const response = await fetch(`http://${ip}:${port}/server-info`, { signal: controller.signal, }); clearTimeout(timeoutId); if (response.ok) { const text = await response.text(); // Parse model from plist if available const modelMatch = text.match(/model<\/key>\s*([^<]+)<\/string>/); const model = modelMatch?.[1]; return { type: 'speaker', protocol: 'airplay', port, name: model || `AirPlay Speaker at ${ip}`, model, }; } } catch { clearTimeout(timeoutId); } // Try /info endpoint (some AirPlay 2 devices) const controller2 = new AbortController(); const timeoutId2 = setTimeout(() => controller2.abort(), timeout); try { const response = await fetch(`http://${ip}:${port}/info`, { signal: controller2.signal, }); clearTimeout(timeoutId2); if (response.ok) { const text = await response.text(); // Try to parse model info const modelMatch = text.match(/model<\/key>\s*([^<]+)<\/string>/); const nameMatch = text.match(/name<\/key>\s*([^<]+)<\/string>/); const model = modelMatch?.[1]; const name = nameMatch?.[1]; return { type: 'speaker', protocol: 'airplay', port, name: name || model || `AirPlay Speaker at ${ip}`, model, }; } } catch { clearTimeout(timeoutId2); } // Port 7000 is open but no HTTP endpoints responded // Still likely an AirPlay device (AirPlay 2 / HomePod) return { type: 'speaker', protocol: 'airplay', port, name: `AirPlay Device at ${ip}`, }; } catch { // Not an AirPlay speaker } return null; } /** * Probe for Sonos speaker */ private async probeSonos( ip: string, port: number, timeout: number ): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { // Sonos devices respond to device description requests const response = await fetch(`http://${ip}:${port}/xml/device_description.xml`, { signal: controller.signal, }); clearTimeout(timeoutId); if (response.ok) { const text = await response.text(); // Check if it's actually a Sonos device if (text.includes('Sonos') || text.includes('schemas-upnp-org')) { // Parse friendly name and model const nameMatch = text.match(/([^<]+)<\/friendlyName>/); const modelMatch = text.match(/([^<]+)<\/modelName>/); return { type: 'speaker', protocol: 'sonos', port, name: nameMatch?.[1] || `Sonos Speaker at ${ip}`, model: modelMatch?.[1], }; } } } catch { clearTimeout(timeoutId); } } catch { // Not a Sonos speaker } return null; } /** * Probe for Chromecast device */ private async probeChromecast( ip: string, port: number, timeout: number ): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { // Chromecast devices have an info endpoint on port 8008 (HTTP) // Port 8009 is the Cast protocol port (TLS) // Try fetching the eureka_info endpoint const response = await fetch(`http://${ip}:8008/setup/eureka_info`, { signal: controller.signal, }); clearTimeout(timeoutId); if (response.ok) { const data = await response.json(); return { type: 'speaker', protocol: 'chromecast', port, name: data.name || `Chromecast at ${ip}`, model: data.cast_build_revision || data.model_name, }; } } catch { clearTimeout(timeoutId); } // Alternative: just check if port 8009 is open (Cast protocol) const isOpen = await this.isPortOpen(ip, port, timeout); if (isOpen) { return { type: 'speaker', protocol: 'chromecast', port, name: `Chromecast at ${ip}`, }; } } catch { // Not a Chromecast } return null; } }