From def8d6fd5cf300d65b8406d72289bf3e0c972651 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Wed, 1 Jul 2026 15:20:34 -0300 Subject: [PATCH 1/2] Add passive mDNS discovery as a second RokuFinder source RokuFinder now runs a passive mDNS/DNS-SD listener alongside SSDP. It joins the mDNS multicast group and listens for device announcements without sending queries, identifies Rokus by the integrator=Roku TXT marker or the _display service, and reads the serial from the .local hostname. Sightings feed the existing found/lost events and bypass the ssdp:alive heartbeat-suppression path, which is specific to SSDP. --- src/deviceDiscovery/MdnsListener.spec.ts | 315 ++++++++++++++++ src/deviceDiscovery/MdnsListener.ts | 452 +++++++++++++++++++++++ src/deviceDiscovery/RokuFinder.spec.ts | 68 ++++ src/deviceDiscovery/RokuFinder.ts | 33 ++ 4 files changed, 868 insertions(+) create mode 100644 src/deviceDiscovery/MdnsListener.spec.ts create mode 100644 src/deviceDiscovery/MdnsListener.ts diff --git a/src/deviceDiscovery/MdnsListener.spec.ts b/src/deviceDiscovery/MdnsListener.spec.ts new file mode 100644 index 00000000..8c58a3cb --- /dev/null +++ b/src/deviceDiscovery/MdnsListener.spec.ts @@ -0,0 +1,315 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import type { RokuSighting } from './MdnsListener'; +import { MdnsListener, parseMdnsMessage, extractRokuFields } from './MdnsListener'; + +// DNS record type numbers, mirrored here so the test builder is self-contained. +const TYPE_A = 1; +const TYPE_PTR = 12; +const TYPE_TXT = 16; +const TYPE_SRV = 33; + +interface TestRecord { + name: string; + type: number; + ttl?: number; + data: any; +} + +function encodeName(name: string): Buffer { + const parts = name.split('.').filter((part) => part.length > 0); + const chunks: Buffer[] = []; + for (const part of parts) { + const labelBytes = Buffer.from(part, 'utf8'); + chunks.push(Buffer.from([labelBytes.length]), labelBytes); + } + chunks.push(Buffer.from([0])); + return Buffer.concat(chunks); +} + +function encodeRdata(record: TestRecord): Buffer { + switch (record.type) { + case TYPE_A: + return Buffer.from((record.data as string).split('.').map((octet) => Number(octet))); + case TYPE_PTR: + return encodeName(record.data as string); + case TYPE_TXT: { + const chunks: Buffer[] = []; + for (const entry of record.data as string[]) { + const bytes = Buffer.from(entry, 'utf8'); + chunks.push(Buffer.from([bytes.length]), bytes); + } + return Buffer.concat(chunks); + } + case TYPE_SRV: { + const head = Buffer.alloc(6); + head.writeUInt16BE(record.data.priority ?? 0, 0); + head.writeUInt16BE(record.data.weight ?? 0, 2); + head.writeUInt16BE(record.data.port ?? 0, 4); + return Buffer.concat([head, encodeName(record.data.target as string)]); + } + default: + return Buffer.alloc(0); + } +} + +function encodeRecord(record: TestRecord): Buffer { + const name = encodeName(record.name); + const rdata = encodeRdata(record); + const middle = Buffer.alloc(10); + middle.writeUInt16BE(record.type, 0); // TYPE + middle.writeUInt16BE(0x0001, 2); // CLASS = IN + middle.writeUInt32BE(record.ttl ?? 120, 4); // TTL + middle.writeUInt16BE(rdata.length, 8); // RDLENGTH + return Buffer.concat([name, middle, rdata]); +} + +/** Build an mDNS response packet (QR bit set) containing the given answer records. */ +function buildResponse(records: TestRecord[]): Buffer { + const header = Buffer.alloc(12); + header.writeUInt16BE(0x8400, 2); // flags: QR + AA + header.writeUInt16BE(records.length, 6); // ANCOUNT + return Buffer.concat([header, ...records.map(encodeRecord)]); +} + +/** Build an mDNS query packet (QR bit clear) for a service name. */ +function buildQuery(serviceName: string): Buffer { + const header = Buffer.alloc(12); + header.writeUInt16BE(1, 4); // QDCOUNT + const question = Buffer.concat([ + encodeName(serviceName), + Buffer.from([0x00, TYPE_PTR, 0x00, 0x01]) + ]); + return Buffer.concat([header, question]); +} + +/** A realistic bundled Roku AirPlay/display response, like the ones observed on-network. */ +function rokuRecords(overrides?: { ip?: string; serial?: string; model?: string; name?: string }): TestRecord[] { + const ip = overrides?.ip ?? '192.168.1.91'; + const serial = overrides?.serial ?? 'X01300A3Y71Y'; + const model = overrides?.model ?? 'G220X'; + const name = overrides?.name ?? '65in Hisense Roku TV'; + return [ + { name: '_airplay._tcp.local', type: TYPE_PTR, data: `${name}._airplay._tcp.local` }, + { name: `${name}._airplay._tcp.local`, type: TYPE_SRV, data: { port: 7000, target: `${serial}.local` } }, + { name: `${name}._airplay._tcp.local`, type: TYPE_TXT, data: ['integrator=Roku', `model=${model}`, `manufacturer=Hisense`] }, + { name: `${serial}.local`, type: TYPE_A, data: ip } + ]; +} + +describe('MdnsListener', () => { + let listener: MdnsListener; + + afterEach(() => { + listener?.dispose(); + sinon.restore(); + }); + + describe('parseMdnsMessage', () => { + it('parses a response with A and TXT records', () => { + const buffer = buildResponse([ + { name: 'X01300A3Y71Y.local', type: TYPE_A, ttl: 120, data: '192.168.1.91' }, + { name: 'inst._airplay._tcp.local', type: TYPE_TXT, data: ['integrator=Roku', 'model=G220X'] } + ]); + + const parsed = parseMdnsMessage(buffer); + + expect(parsed.isResponse).to.be.true; + expect(parsed.records).to.have.length(2); + expect(parsed.records[0]).to.include({ name: 'X01300A3Y71Y.local', type: TYPE_A, ttl: 120, data: '192.168.1.91' }); + expect(parsed.records[1].data).to.deep.equal(['integrator=Roku', 'model=G220X']); + }); + + it('marks a query as not a response', () => { + const parsed = parseMdnsMessage(buildQuery('_airplay._tcp.local')); + expect(parsed.isResponse).to.be.false; + }); + + it('throws on a too-short packet', () => { + expect(() => parseMdnsMessage(Buffer.from([0, 0, 0]))).to.throw(); + }); + }); + + describe('extractRokuFields', () => { + it('identifies a Roku and extracts serial, model, name, and ip', () => { + const fields = extractRokuFields(parseMdnsMessage(buildResponse(rokuRecords())).records, '192.168.1.91'); + + expect(fields.isRoku).to.be.true; + expect(fields.serialNumber).to.equal('X01300A3Y71Y'); + expect(fields.model).to.equal('G220X'); + expect(fields.name).to.equal('65in Hisense Roku TV'); + expect(fields.ipv4).to.equal('192.168.1.91'); + expect(fields.goodbye).to.be.false; + }); + + it('identifies a Roku by the _display service even without the integrator marker', () => { + const records = parseMdnsMessage(buildResponse([ + { name: '_display._tcp.local', type: TYPE_PTR, data: 'Roku._display._tcp.local' } + ])).records; + + expect(extractRokuFields(records, '192.168.1.91').isRoku).to.be.true; + }); + + it('does not flag a non-Roku responder', () => { + const records = parseMdnsMessage(buildResponse([ + { name: 'Apple TV._airplay._tcp.local', type: TYPE_TXT, data: ['model=AppleTV6,2', 'srcvers=950.7.1'] }, + { name: 'apple-tv.local', type: TYPE_A, data: '192.168.1.93' } + ])).records; + + const fields = extractRokuFields(records, '192.168.1.93'); + expect(fields.isRoku).to.be.false; + }); + + it('detects a goodbye when the A record TTL is 0', () => { + const records = parseMdnsMessage(buildResponse([ + { name: '_airplay._tcp.local', type: TYPE_PTR, data: 'Roku._airplay._tcp.local' }, + { name: 'Roku._airplay._tcp.local', type: TYPE_TXT, data: ['integrator=Roku'] }, + { name: 'X01300A3Y71Y.local', type: TYPE_A, ttl: 0, data: '192.168.1.91' } + ])).records; + + const fields = extractRokuFields(records, '192.168.1.91'); + expect(fields.isRoku).to.be.true; + expect(fields.goodbye).to.be.true; + }); + + it('does not treat a service-instance name as a serial', () => { + const records = parseMdnsMessage(buildResponse([ + { name: 'Living Room._airplay._tcp.local', type: TYPE_TXT, data: ['integrator=Roku'] } + ])).records; + + expect(extractRokuFields(records, '192.168.1.91').serialNumber).to.be.undefined; + }); + }); + + describe('handlePacket', () => { + it('emits roku-found with the full sighting for a bundled response', () => { + listener = new MdnsListener(); + const foundSpy = sinon.spy(); + listener.on('roku-found', foundSpy); + + listener.handlePacket(buildResponse(rokuRecords()), '192.168.1.91'); + + expect(foundSpy.calledOnce).to.be.true; + const sighting = foundSpy.firstCall.args[0] as RokuSighting; + expect(sighting).to.deep.equal({ + ip: '192.168.1.91', + serialNumber: 'X01300A3Y71Y', + model: 'G220X', + name: '65in Hisense Roku TV' + }); + }); + + it('ignores queries', () => { + listener = new MdnsListener(); + const foundSpy = sinon.spy(); + listener.on('roku-found', foundSpy); + + listener.handlePacket(buildQuery('_airplay._tcp.local'), '192.168.1.91'); + + expect(foundSpy.called).to.be.false; + }); + + it('ignores non-Roku responders', () => { + listener = new MdnsListener(); + const foundSpy = sinon.spy(); + listener.on('roku-found', foundSpy); + + listener.handlePacket(buildResponse([ + { name: 'apple-tv.local', type: TYPE_A, data: '192.168.1.93' }, + { name: 'Apple TV._airplay._tcp.local', type: TYPE_TXT, data: ['model=AppleTV6,2'] } + ]), '192.168.1.93'); + + expect(foundSpy.called).to.be.false; + }); + + it('ignores malformed packets without throwing', () => { + listener = new MdnsListener(); + const foundSpy = sinon.spy(); + listener.on('roku-found', foundSpy); + + expect(() => listener.handlePacket(Buffer.from([0, 1, 2]), '192.168.1.91')).to.not.throw(); + expect(foundSpy.called).to.be.false; + }); + + it('debounces repeated announcements from the same device', () => { + const clock = sinon.useFakeTimers(); + try { + listener = new MdnsListener(); + const foundSpy = sinon.spy(); + listener.on('roku-found', foundSpy); + + listener.handlePacket(buildResponse(rokuRecords()), '192.168.1.91'); + listener.handlePacket(buildResponse(rokuRecords()), '192.168.1.91'); + expect(foundSpy.calledOnce).to.be.true; + + // After the debounce window, the same device emits again. + clock.tick(30_000); + listener.handlePacket(buildResponse(rokuRecords()), '192.168.1.91'); + expect(foundSpy.calledTwice).to.be.true; + } finally { + clock.restore(); + } + }); + + it('debounces independently per device', () => { + listener = new MdnsListener(); + const foundSpy = sinon.spy(); + listener.on('roku-found', foundSpy); + + listener.handlePacket(buildResponse(rokuRecords({ ip: '192.168.1.91', serial: 'X01300A3Y71Y' })), '192.168.1.91'); + listener.handlePacket(buildResponse(rokuRecords({ ip: '192.168.1.249', serial: 'X00000907DRH' })), '192.168.1.249'); + + expect(foundSpy.calledTwice).to.be.true; + expect(foundSpy.firstCall.args[0].ip).to.equal('192.168.1.91'); + expect(foundSpy.secondCall.args[0].ip).to.equal('192.168.1.249'); + }); + + it('resolves the serial from a later packet when records arrive split', () => { + listener = new MdnsListener(); + const foundSpy = sinon.spy(); + listener.on('roku-found', foundSpy); + + // First packet: the Roku marker in a TXT, but no A record (no serial yet). + listener.handlePacket(buildResponse([ + { name: 'Roku._airplay._tcp.local', type: TYPE_TXT, data: ['integrator=Roku', 'model=G220X'] } + ]), '192.168.1.91'); + expect(foundSpy.firstCall.args[0].serialNumber).to.be.undefined; + + // Second packet from the same IP: the A record carries the serial (no marker). + listener.handlePacket(buildResponse([ + { name: 'X01300A3Y71Y.local', type: TYPE_A, data: '192.168.1.91' } + ]), '192.168.1.91'); + + expect(foundSpy.calledTwice).to.be.true; + expect(foundSpy.secondCall.args[0].serialNumber).to.equal('X01300A3Y71Y'); + }); + + it('emits roku-lost on a goodbye record', () => { + listener = new MdnsListener(); + const lostSpy = sinon.spy(); + listener.on('roku-lost', lostSpy); + + // Learn the device first. + listener.handlePacket(buildResponse(rokuRecords()), '192.168.1.91'); + // Then it announces going away. + listener.handlePacket(buildResponse([ + { name: 'X01300A3Y71Y.local', type: TYPE_A, ttl: 0, data: '192.168.1.91' }, + { name: 'Roku._airplay._tcp.local', type: TYPE_TXT, data: ['integrator=Roku'] } + ]), '192.168.1.91'); + + expect(lostSpy.calledOnce).to.be.true; + expect(lostSpy.firstCall.args[0]).to.equal('192.168.1.91'); + }); + }); + + describe('start/stop', () => { + it('start resolves and stop is idempotent', async () => { + listener = new MdnsListener(); + await listener.start(); + expect(() => { + listener.stop(); + listener.stop(); + }).to.not.throw(); + }); + }); +}); diff --git a/src/deviceDiscovery/MdnsListener.ts b/src/deviceDiscovery/MdnsListener.ts new file mode 100644 index 00000000..46c4ca5a --- /dev/null +++ b/src/deviceDiscovery/MdnsListener.ts @@ -0,0 +1,452 @@ +import * as dgram from 'dgram'; +import { EventEmitter } from 'eventemitter3'; + +const MDNS_MULTICAST_ADDRESS = '224.0.0.251'; +const MDNS_PORT = 5353; + +// DNS resource-record type numbers (RFC 1035 / RFC 6763) +const RECORD_TYPE_A = 1; +const RECORD_TYPE_PTR = 12; +const RECORD_TYPE_TXT = 16; +const RECORD_TYPE_AAAA = 28; +const RECORD_TYPE_SRV = 33; + +/** + * Roku devices advertise this TXT key/value regardless of the TV brand (Roku, TCL, Hisense, + * ...), which makes it the most reliable Roku signal in an mDNS response. Compared lowercase. + */ +const ROKU_TXT_MARKER = 'integrator=roku'; + +/** + * Service type observed to be advertised only by Roku devices (never by the Apple / Google / + * printer responders on the same network). Used as a secondary Roku signal. + */ +const ROKU_DISPLAY_SERVICE = '_display._tcp.local'; + +/** Service-instance suffixes we strip to recover the friendly device name. */ +const FRIENDLY_NAME_SUFFIXES = ['._display._tcp.local', '._airplay._tcp.local']; + +/** Do not re-emit `roku-found` for the same device more often than this. */ +const FOUND_DEBOUNCE_MS = 30_000; + +/** Drop accumulated device state that has not been seen within this window. */ +const DEVICE_EXPIRY_MS = 20 * 60 * 1000; + +/** + * Passive mDNS / DNS-SD listener for Roku devices. + * + * Unlike an active scanner this never sends a query. It joins the mDNS multicast group and + * listens for the announcements devices broadcast on their own, plus any responses that other + * hosts' queries elicit. That adds zero traffic of our own and sidesteps the responder + * rate-limiting that makes repeated active scanning unreliable. It is intended as a supplement + * to the active SSDP discovery in RokuFinder, not a replacement. + */ +export class MdnsListener extends EventEmitter { + constructor( + private log: (message: string) => void = () => { } + ) { + super(); + } + + private socket: dgram.Socket | undefined; + private readonly devices = new Map(); + private lastCleanupTime = 0; + + /** + * Bind the multicast socket and begin listening. Best-effort: any bind/membership failure + * is logged and swallowed rather than thrown, so a busy port never breaks the extension. + * Resolves once the socket is listening or the attempt has failed. + */ + public start(): Promise { + if (this.socket) { + return Promise.resolve(); + } + return new Promise((resolve) => { + let settled = false; + const settle = () => { + if (!settled) { + settled = true; + resolve(); + } + }; + + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + this.socket = socket; + + socket.on('error', (error) => { + this.log(`mDNS listener socket error: ${error.message}`); + this.stop(); + settle(); + }); + + socket.on('message', (message, remote) => { + this.handlePacket(message, remote.address); + }); + + socket.on('listening', () => { + try { + socket.addMembership(MDNS_MULTICAST_ADDRESS); + } catch (error) { + this.log(`mDNS listener could not join multicast group: ${(error as Error).message}`); + } + settle(); + }); + + try { + socket.bind(MDNS_PORT); + } catch (error) { + this.log(`mDNS listener bind failed: ${(error as Error).message}`); + this.stop(); + settle(); + } + }); + } + + public stop(): void { + if (this.socket) { + try { + this.socket.removeAllListeners(); + this.socket.close(); + } catch { + // socket may already be closed; ignore + } + this.socket = undefined; + } + } + + /** + * Parse one received packet and emit `roku-found` / `roku-lost` as warranted. + * Exposed (not private) so it can be exercised in tests without binding a real socket. + */ + public handlePacket(message: Buffer, sourceIp: string): void { + let parsed: ParsedMdnsMessage; + try { + parsed = parseMdnsMessage(message); + } catch { + return; // malformed packet, ignore + } + + // Only responses describe advertised services. Queries (including the ones Roku devices + // send themselves) carry an EDNS OPT record but no answers we care about. + if (!parsed.isResponse) { + return; + } + + const fields = extractRokuFields(parsed.records, sourceIp); + + // Keyed by packet source, which is the device itself and stays stable even when a given + // packet omits the A record. Once an IP is known to be a Roku, later packets from it + // update its details even if they no longer carry the Roku marker. + const alreadyKnown = this.devices.has(sourceIp); + if (!fields.isRoku && !alreadyKnown) { + return; + } + + const now = Date.now(); + this.cleanupExpiredDevices(now); + + if (fields.goodbye) { + this.devices.delete(sourceIp); + this.emit('roku-lost', fields.ipv4 ?? sourceIp); + return; + } + + // Accumulate across packets: a Roku often splits its records so the TXT (which carries + // the Roku marker) and the A record (which carries the serial) arrive separately. + const existing = this.devices.get(sourceIp); + const previousSerial = existing?.serialNumber; + const device: DeviceState = existing ?? { ip: sourceIp, lastSeen: now, lastEmit: 0 }; + device.ip = fields.ipv4 ?? device.ip; + device.serialNumber = fields.serialNumber ?? device.serialNumber; + device.model = fields.model ?? device.model; + device.name = fields.name ?? device.name; + device.lastSeen = now; + this.devices.set(sourceIp, device); + + // Emit on the debounce interval, or immediately when the serial first resolves so a + // split announcement does not delay the useful identifier by a full debounce window. + const serialJustResolved = device.serialNumber !== undefined && device.serialNumber !== previousSerial; + if (serialJustResolved || now - device.lastEmit >= FOUND_DEBOUNCE_MS) { + device.lastEmit = now; + const sighting: RokuSighting = { + ip: device.ip, + serialNumber: device.serialNumber, + model: device.model, + name: device.name + }; + this.emit('roku-found', sighting); + } + } + + private cleanupExpiredDevices(now: number): void { + if (now - this.lastCleanupTime < DEVICE_EXPIRY_MS) { + return; + } + this.lastCleanupTime = now; + for (const [ip, device] of this.devices) { + if (now - device.lastSeen > DEVICE_EXPIRY_MS) { + this.devices.delete(ip); + } + } + } + + public dispose(): void { + this.stop(); + this.removeAllListeners(); + this.devices.clear(); + } +} + +/** + * Inspect one packet's records and report what they say about a Roku device. Pure and + * stateless so it can be unit-tested directly. The device is considered a Roku when it carries + * the `integrator=Roku` TXT marker or advertises the Roku-only `_display` service. + */ +export function extractRokuFields(records: ParsedRecord[], sourceIp: string): PacketRokuFields { + let isRoku = false; + let goodbye = false; + let ipv4: string | undefined; + let serialNumber: string | undefined; + let model: string | undefined; + let name: string | undefined; + + for (const record of records) { + // Roku signal 1: the integrator=Roku TXT marker + if (record.type === RECORD_TYPE_TXT && Array.isArray(record.data)) { + for (const entry of record.data as string[]) { + const lower = entry.toLowerCase(); + if (lower === ROKU_TXT_MARKER) { + isRoku = true; + } else if (lower.startsWith('model=')) { + model = entry.slice('model='.length); + } + } + } + + // Roku signal 2: the _display service type, which only Rokus were seen to advertise + if (recordReferencesService(record, ROKU_DISPLAY_SERVICE)) { + isRoku = true; + } + + // The A record's name is the device hostname, which for Roku is its serial number. + if (record.type === RECORD_TYPE_A) { + ipv4 ??= record.data; + const candidateSerial = hostnameToSerial(record.name); + if (candidateSerial) { + serialNumber ??= candidateSerial; + } + if (record.ttl === 0) { + goodbye = true; + } + } + + // Friendly name from a service-instance record name + const friendly = friendlyNameFromInstance(record.name); + if (friendly) { + name ??= friendly; + } + } + + return { isRoku: isRoku, goodbye: goodbye, ipv4: ipv4, serialNumber: serialNumber, model: model, name: name }; +} + +/** True when the record is, or points at, the given service type. */ +function recordReferencesService(record: ParsedRecord, serviceType: string): boolean { + if (record.name === serviceType) { + return true; + } + if (record.name.endsWith(`.${serviceType}`)) { + return true; + } + if (record.type === RECORD_TYPE_PTR && typeof record.data === 'string' && record.data.endsWith(serviceType)) { + return true; + } + return false; +} + +/** + * Convert an mDNS hostname to a Roku serial number, or undefined when it does not look like a + * bare hostname. Roku uses its serial as the host label, e.g. `X01300A3Y71Y.local`. + */ +function hostnameToSerial(recordName: string): string | undefined { + const withoutLocal = recordName.replace(/\.local\.?$/i, ''); + // A bare hostname has no dots and is not a service instance (no `._` service segment). + if (withoutLocal.length === 0 || withoutLocal.includes('.') || withoutLocal.includes('._') || withoutLocal.includes(' ')) { + return undefined; + } + return withoutLocal; +} + +/** Recover the friendly name from a service-instance record name, if it is one. */ +function friendlyNameFromInstance(recordName: string): string | undefined { + for (const suffix of FRIENDLY_NAME_SUFFIXES) { + if (recordName.endsWith(suffix)) { + return recordName.slice(0, -suffix.length); + } + } + return undefined; +} + +/** + * Parse a DNS/mDNS message into whether it is a response plus its resource records. + * Handles name-compression pointers. Pure and exported for testing. + */ +export function parseMdnsMessage(buffer: Buffer): ParsedMdnsMessage { + if (buffer.length < 12) { + throw new Error('mDNS packet too short'); + } + const flags = buffer.readUInt16BE(2); + // The QR bit is the most-significant bit of the 16-bit flags field (0x8000). + const isResponse = flags >= 0x8000; + const questionCount = buffer.readUInt16BE(4); + const answerCount = buffer.readUInt16BE(6); + const authorityCount = buffer.readUInt16BE(8); + const additionalCount = buffer.readUInt16BE(10); + + let offset = 12; + for (let index = 0; index < questionCount; index++) { + const { nextOffset } = readName(buffer, offset); + offset = nextOffset + 4; // skip QTYPE (2) + QCLASS (2) + } + + const totalRecords = answerCount + authorityCount + additionalCount; + const records: ParsedRecord[] = []; + for (let index = 0; index < totalRecords; index++) { + if (offset >= buffer.length) { + break; + } + const { name, nextOffset } = readName(buffer, offset); + const type = buffer.readUInt16BE(nextOffset); + const ttl = buffer.readUInt32BE(nextOffset + 4); // skip type (2) + class (2) + const rdataLength = buffer.readUInt16BE(nextOffset + 8); + const rdataStart = nextOffset + 10; + const data = parseRecordData(buffer, type, rdataStart, rdataLength); + records.push({ name: name, type: type, ttl: ttl, data: data }); + offset = rdataStart + rdataLength; + } + + return { isResponse: isResponse, records: records }; +} + +/** + * Read a (possibly compression-pointer-using) DNS name starting at `offset`. Returns the + * decoded dotted name and the offset immediately after the name in the buffer. + */ +function readName(buffer: Buffer, offset: number): { name: string; nextOffset: number } { + const labels: string[] = []; + let position = offset; + let nextOffset = -1; + let safetyCounter = 0; + + while (position < buffer.length && safetyCounter++ < 128) { + const lengthByte = buffer[position]; + + if (lengthByte === 0) { + position += 1; + break; + } + + // A compression pointer has its two most-significant bits set, i.e. a byte >= 0xC0. + if (lengthByte >= 0xc0) { + // The low 6 bits of this byte plus the next byte form the 14-bit target offset. + const pointer = ((lengthByte - 0xc0) * 256) + buffer[position + 1]; + if (nextOffset === -1) { + nextOffset = position + 2; + } + position = pointer; + continue; + } + + labels.push(buffer.toString('utf8', position + 1, position + 1 + lengthByte)); + position += 1 + lengthByte; + } + + if (nextOffset === -1) { + nextOffset = position; + } + return { name: labels.join('.'), nextOffset: nextOffset }; +} + +/** Parse the RDATA of a single record into a friendly representation based on its type. */ +function parseRecordData(buffer: Buffer, type: number, rdataStart: number, rdataLength: number): any { + switch (type) { + case RECORD_TYPE_A: + return Array.from(buffer.subarray(rdataStart, rdataStart + 4)).join('.'); + case RECORD_TYPE_AAAA: { + const groups: string[] = []; + for (let index = 0; index < 16; index += 2) { + groups.push(buffer.readUInt16BE(rdataStart + index).toString(16)); + } + return groups.join(':'); + } + case RECORD_TYPE_PTR: + return readName(buffer, rdataStart).name; + case RECORD_TYPE_TXT: { + const entries: string[] = []; + let position = rdataStart; + const end = rdataStart + rdataLength; + while (position < end) { + const length = buffer[position]; + position += 1; + if (length > 0) { + entries.push(buffer.toString('utf8', position, position + length)); + position += length; + } + } + return entries; + } + case RECORD_TYPE_SRV: + return { + priority: buffer.readUInt16BE(rdataStart), + weight: buffer.readUInt16BE(rdataStart + 2), + port: buffer.readUInt16BE(rdataStart + 4), + target: readName(buffer, rdataStart + 6).name + }; + default: + return null; + } +} + +export interface RokuSighting { + /** IPv4 address of the device (from its A record, falling back to the packet source). */ + ip: string; + /** Roku serial number, taken from the device's mDNS hostname (e.g. `X01300A3Y71Y`). */ + serialNumber?: string; + /** Hardware model code from the TXT record (e.g. `G220X`). */ + model?: string; + /** Friendly device name from the AirPlay/display instance (e.g. `Living Room Roku`). */ + name?: string; +} + +export interface ParsedRecord { + name: string; + type: number; + ttl: number; + data: any; +} + +export interface ParsedMdnsMessage { + /** True when the QR header bit is set (a response rather than a query). */ + isResponse: boolean; + records: ParsedRecord[]; +} + +/** Fields a single packet can tell us about a device, before cross-packet accumulation. */ +interface PacketRokuFields { + isRoku: boolean; + /** True when the packet says the device is going away (a record with TTL 0). */ + goodbye: boolean; + ipv4?: string; + serialNumber?: string; + model?: string; + name?: string; +} + +interface DeviceState { + ip: string; + serialNumber?: string; + model?: string; + name?: string; + lastSeen: number; + lastEmit: number; +} diff --git a/src/deviceDiscovery/RokuFinder.spec.ts b/src/deviceDiscovery/RokuFinder.spec.ts index 25864621..279d1af4 100644 --- a/src/deviceDiscovery/RokuFinder.spec.ts +++ b/src/deviceDiscovery/RokuFinder.spec.ts @@ -766,6 +766,74 @@ describe('RokuFinder', () => { }); }); + describe('mDNS handling', () => { + it('emits "found" with IP and serial number for an mDNS sighting', () => { + finder = new RokuFinder(mockGlobalStateManager); + + const foundSpy = sinon.spy(); + finder.on('found', foundSpy); + + (finder['mdnsListener'] as any).emit('roku-found', { + ip: '192.168.1.91', + serialNumber: 'X01300A3Y71Y', + model: 'G220X', + name: '65in Hisense Roku TV' + }); + + expect(foundSpy.calledOnce).to.be.true; + expect(foundSpy.firstCall.args[0]).to.equal('192.168.1.91'); + expect(foundSpy.firstCall.args[1].serialNumber).to.equal('X01300A3Y71Y'); + }); + + it('does not run mDNS sightings through the ssdp:alive heartbeat suppression', () => { + finder = new RokuFinder(mockGlobalStateManager); + + const deviceOnlineSpy = sinon.spy(); + finder.on('device-online', deviceOnlineSpy); + + (finder['mdnsListener'] as any).emit('roku-found', { ip: '192.168.1.91', serialNumber: 'X01300A3Y71Y' }); + + // mDNS feeds `found`, not `device-online` (that path is SSDP-cadence specific). + expect(deviceOnlineSpy.called).to.be.false; + }); + + it('emits "lost" when the mDNS listener reports a device gone', () => { + finder = new RokuFinder(mockGlobalStateManager); + + const lostSpy = sinon.spy(); + finder.on('lost', lostSpy); + + (finder['mdnsListener'] as any).emit('roku-lost', '192.168.1.91'); + + expect(lostSpy.calledOnce).to.be.true; + expect(lostSpy.firstCall.args[0]).to.equal('192.168.1.91'); + }); + + it('resets the scan settle timer when an mDNS sighting arrives during a scan', () => { + const clock = sinon.useFakeTimers(); + try { + finder = new RokuFinder(mockGlobalStateManager); + + const scanEndedSpy = sinon.spy(); + finder.on('scan-ended', scanEndedSpy); + + finder.scan(); + + // mDNS sighting at 2.9s resets the settle timer to 2.9s + 1.5s = 4.4s + clock.tick(2_900); + (finder['mdnsListener'] as any).emit('roku-found', { ip: '192.168.1.91', serialNumber: 'X01300A3Y71Y' }); + + clock.tick(100); // 3s: min timer fired, settle still pending + expect(scanEndedSpy.called).to.be.false; + + clock.tick(1_400); // 4.4s: settle fires + expect(scanEndedSpy.calledOnce).to.be.true; + } finally { + clock.restore(); + } + }); + }); + describe('scan orchestration', () => { it('emits scan-started when scan begins', () => { finder = new RokuFinder(mockGlobalStateManager); diff --git a/src/deviceDiscovery/RokuFinder.ts b/src/deviceDiscovery/RokuFinder.ts index e5381fee..79f003ed 100644 --- a/src/deviceDiscovery/RokuFinder.ts +++ b/src/deviceDiscovery/RokuFinder.ts @@ -2,6 +2,8 @@ import { EventEmitter } from 'eventemitter3'; import type { SsdpHeaders } from 'node-ssdp'; import { Client, Server } from 'node-ssdp'; import type { GlobalStateManager } from '../GlobalStateManager'; +import type { RokuSighting } from './MdnsListener'; +import { MdnsListener } from './MdnsListener'; export class RokuFinder extends EventEmitter { constructor( @@ -27,10 +29,20 @@ export class RokuFinder extends EventEmitter { this.server.on('advertise-bye', (headers: SsdpHeaders) => { this.processSsdpNotify(headers); }); + + // Passive mDNS discovery as a second source, feeding the same events as SSDP. + this.mdnsListener = new MdnsListener(this.log); + this.mdnsListener.on('roku-found', (sighting: RokuSighting) => { + this.processMdnsSighting(sighting); + }); + this.mdnsListener.on('roku-lost', (ip: string) => { + this.emit('lost', ip); + }); } private client: Client; private server: Server; + private mdnsListener: MdnsListener; private running = false; private scanTimers: ReturnType[] = []; private aliveDebounceMap = new Map(); @@ -150,6 +162,8 @@ export class RokuFinder extends EventEmitter { if (!this.running) { this.running = true; await this.server.start(); + // Best-effort: never rejects, so a busy mDNS port cannot break SSDP discovery. + await this.mdnsListener.start(); } } @@ -165,6 +179,23 @@ export class RokuFinder extends EventEmitter { if (this.running) { this.running = false; this.server.stop(); + this.mdnsListener.stop(); + } + } + + /** + * Handle a passive mDNS sighting. mDNS fires irregularly and often, so it feeds the same + * `found` event as SSDP (for presence and IP/serial refresh) but deliberately does NOT run + * through the ssdp:alive heartbeat-suppression logic in maybeEmitDeviceOnline, which is + * tuned to Roku's ~20-minute cadence and has no equivalent in mDNS. The MdnsListener does + * its own debouncing, so repeated announcements do not spam `found`. + */ + private processMdnsSighting(sighting: RokuSighting) { + this.emit('found', sighting.ip, { serialNumber: sighting.serialNumber }); + + // Let mDNS sightings extend an in-progress active scan, same as SSDP responses. + if (this.isScanning) { + this.resetSettleTimer(); } } @@ -319,6 +350,8 @@ export class RokuFinder extends EventEmitter { this.server.removeAllListeners(); this.server.stop(); delete this.server; + + this.mdnsListener.dispose(); } } From 20f1d9e473b064d13816bce89b9ea7ea13383be4 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Wed, 1 Jul 2026 15:20:45 -0300 Subject: [PATCH 2/2] Add mdns-scan skill for on-demand mDNS/DNS-SD network scans A zero-dependency raw scanner plus SKILL.md that enumerates service types, resolves instances, and highlights matching devices. Useful for finding Rokus and inspecting device TXT metadata during development. --- .claude/skills/mdns-scan/SKILL.md | 45 +++ .claude/skills/mdns-scan/mdns-scan.js | 439 ++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 .claude/skills/mdns-scan/SKILL.md create mode 100644 .claude/skills/mdns-scan/mdns-scan.js diff --git a/.claude/skills/mdns-scan/SKILL.md b/.claude/skills/mdns-scan/SKILL.md new file mode 100644 index 00000000..e6662b4d --- /dev/null +++ b/.claude/skills/mdns-scan/SKILL.md @@ -0,0 +1,45 @@ +--- +name: mdns-scan +description: Discover and inspect devices on the local network via mDNS / DNS-SD (Bonjour) scanning. Use when the user wants to find Roku devices, AirPlay/Chromecast targets, printers, or any mDNS responder on the LAN; enumerate the service types a device advertises; read a device's TXT metadata (model, serial, firmware); or debug device discovery. Zero-dependency raw scanner, runs with plain node. +--- + +# Scan the local network for mDNS / DNS-SD devices + +This skill runs a self-contained mDNS scanner (`mdns-scan.js`, raw UDP sockets, no dependencies) that enumerates every service type advertised on the local link, resolves each device's instance name, host, IP, port, and TXT metadata, and highlights the responders matching a filter term. + +Roku devices in particular advertise over mDNS: they carry `integrator=Roku` in their TXT records and use their serial number as the `.local` hostname (e.g. `X01300A3Y71Y.local`), which matches the SSDP USN serial one-to-one. + +## Step 1 — Gather parameters + +Only ask if the request is ambiguous; otherwise use the defaults. + +- **Filter term** (`--filter`, default `roku`) — highlights responders whose service types, instance names, hostnames, or TXT records contain this substring. Examples: `chromecast`, `airplay`, `printer`, `_matter`. +- **Listen duration** (`--seconds`, default `8`) — mDNS is lossy and responders rate-limit, so for a thorough sweep use `15`–`20`. See the reliability note below. +- **Active vs passive** (`--passive`) — active (default) sends queries to prompt responses; passive sends nothing and only reports what devices broadcast on their own. + +## Step 2 — Run the scan + +mDNS needs real multicast/LAN access, which the sandbox blocks, so run with the sandbox disabled: + +```bash +node .claude/skills/mdns-scan/mdns-scan.js --seconds=15 --all +``` + +Common variations: +- Find Rokus specifically: `node .claude/skills/mdns-scan/mdns-scan.js --seconds=15` +- Inspect everything on the network: add `--all` +- Find a different device class: `--filter=chromecast --all` +- See every raw record as it arrives: `--verbose` +- Listen-only (no queries added to the network): `--passive --seconds=30` + +When invoking via the Bash tool, set `dangerouslyDisableSandbox: true` for the run (multicast will not work sandboxed) and give it a timeout a few seconds longer than `--seconds`. + +## Step 3 — Interpret the output + +The scanner prints a per-responder summary: source IP, advertised service types, service instances, hostnames, IP addresses (v4 + v6), ports, and all TXT key/values. Responders matching the filter are tagged `<-- matches ""`. + +For Rokus, the useful fields are: TXT `integrator=Roku` (reliable identifier), `manufacturer` / `model`, `fv` (firmware), and the `.local` hostname (the ECP serial number). + +## Reliability note + +mDNS is best-effort UDP and responders suppress repeated answers, so results vary run to run — a device present one scan may be absent the next, especially over WiFi and when scanning repeatedly in quick succession. If a device you expect is missing, run again, raise `--seconds`, or wait a bit between runs. For finding Rokus reliably and quickly, SSDP (`roku:ecp`) is more dependable; mDNS is best as a supplementary source or for reading the richer TXT metadata SSDP does not provide. diff --git a/.claude/skills/mdns-scan/mdns-scan.js b/.claude/skills/mdns-scan/mdns-scan.js new file mode 100644 index 00000000..a1661273 --- /dev/null +++ b/.claude/skills/mdns-scan/mdns-scan.js @@ -0,0 +1,439 @@ +/** + * Discover devices on the local network via mDNS / DNS-SD scanning. + * + * Standalone and dependency-free (raw `dgram` sockets, hand-rolled DNS wire parsing), so it + * runs with plain `node` in any directory. It enumerates every service type advertised on the + * link, follows the PTR -> SRV -> A/TXT chain to resolve each device's instance name, host, ip, + * port, and TXT metadata, and highlights the responders that match a filter term. + * + * Run it with: + * node .claude/skills/mdns-scan/mdns-scan.js + * + * Optional flags: + * --seconds=12 how long to listen before printing the summary (default 8) + * --filter=chromecast highlight responders whose records contain this term (default "roku") + * --all print every responder, not just the ones matching the filter + * --verbose log each parsed record as it arrives + * --passive listen only; never send a query (catch what devices broadcast on their own) + * + * Background: + * - mDNS uses multicast group 224.0.0.251 on UDP port 5353. + * - We send a DNS-SD PTR query for the service-enumeration meta-record + * (_services._dns-sd._udp.local) plus a few common service types, then recursively query + * every service type we discover and resolve the instances behind them. + * - In --passive mode we send nothing and simply report whatever arrives. + */ + +const dgram = require('dgram'); +const os = require('os'); + +const MDNS_ADDRESS = '224.0.0.251'; +const MDNS_PORT = 5353; + +// The DNS-SD meta-query: asks every responder to list the service types it offers. +const SERVICE_ENUMERATION_QUERY = '_services._dns-sd._udp.local'; + +// A few common service types to seed discovery. The meta-query above surfaces everything else. +const SEED_SERVICE_TYPES = [ + '_airplay._tcp.local', + '_raop._tcp.local', + '_googlecast._tcp.local', + '_display._tcp.local' +]; + +// DNS record type numbers we care about +const TYPE_A = 1; +const TYPE_PTR = 12; +const TYPE_TXT = 16; +const TYPE_AAAA = 28; +const TYPE_SRV = 33; + +function parseArguments() { + const argv = process.argv.slice(2); + const getNumberFlag = (flagName, fallback) => { + const match = argv.find((entry) => entry.startsWith(`${flagName}=`)); + if (!match) { + return fallback; + } + const value = Number(match.split('=')[1]); + return Number.isFinite(value) ? value : fallback; + }; + const getStringFlag = (flagName, fallback) => { + const match = argv.find((entry) => entry.startsWith(`${flagName}=`)); + return match ? match.split('=').slice(1).join('=') : fallback; + }; + return { + listenSeconds: getNumberFlag('--seconds', 8), + filter: getStringFlag('--filter', 'roku').toLowerCase(), + showAll: argv.includes('--all'), + verbose: argv.includes('--verbose'), + passive: argv.includes('--passive') + }; +} + +/** Encode a dotted name (e.g. "_airplay._tcp.local") into DNS label wire format. */ +function encodeName(name) { + const parts = name.split('.').filter((part) => part.length > 0); + const chunks = []; + for (const part of parts) { + const labelBytes = Buffer.from(part, 'utf8'); + chunks.push(Buffer.from([labelBytes.length]), labelBytes); + } + chunks.push(Buffer.from([0])); // root label terminator + return Buffer.concat(chunks); +} + +/** + * Build an mDNS query packet asking for PTR records for the given service name. The top bit of + * the query class (the "QU" bit) requests a unicast response, which helps us receive replies. + */ +function buildPtrQuery(serviceName) { + const header = Buffer.alloc(12); + header.writeUInt16BE(1, 4); // QDCOUNT = 1 question + const question = Buffer.concat([ + encodeName(serviceName), + Buffer.from([0x00, TYPE_PTR, 0x80, 0x01]) // QTYPE = PTR, QCLASS = IN with unicast-response bit + ]); + return Buffer.concat([header, question]); +} + +/** + * Read a (possibly compression-pointer-using) DNS name starting at `offset`. Returns the decoded + * dotted name and the offset immediately after the name in the buffer. + */ +function readName(buffer, offset) { + const labels = []; + let position = offset; + let nextOffset = -1; + let safetyCounter = 0; + + while (position < buffer.length && safetyCounter++ < 128) { + const lengthByte = buffer[position]; + + if (lengthByte === 0) { + position += 1; + break; + } + + // A compression pointer is signalled by the top two bits being set (a byte >= 0xC0). + if (lengthByte >= 0xc0) { + const pointer = ((lengthByte - 0xc0) * 256) + buffer[position + 1]; + if (nextOffset === -1) { + nextOffset = position + 2; + } + position = pointer; + continue; + } + + labels.push(buffer.toString('utf8', position + 1, position + 1 + lengthByte)); + position += 1 + lengthByte; + } + + if (nextOffset === -1) { + nextOffset = position; + } + return { name: labels.join('.'), nextOffset: nextOffset }; +} + +/** Parse the RDATA of a single record into a friendly representation based on its type. */ +function parseRecordData(buffer, type, rdataStart, rdataLength) { + switch (type) { + case TYPE_A: + return Array.from(buffer.subarray(rdataStart, rdataStart + 4)).join('.'); + case TYPE_AAAA: { + const groups = []; + for (let index = 0; index < 16; index += 2) { + groups.push(buffer.readUInt16BE(rdataStart + index).toString(16)); + } + return groups.join(':'); + } + case TYPE_PTR: + return readName(buffer, rdataStart).name; + case TYPE_TXT: { + const entries = []; + let position = rdataStart; + const end = rdataStart + rdataLength; + while (position < end) { + const length = buffer[position]; + position += 1; + if (length > 0) { + entries.push(buffer.toString('utf8', position, position + length)); + position += length; + } + } + return entries; + } + case TYPE_SRV: + return { + priority: buffer.readUInt16BE(rdataStart), + weight: buffer.readUInt16BE(rdataStart + 2), + port: buffer.readUInt16BE(rdataStart + 4), + target: readName(buffer, rdataStart + 6).name + }; + default: + return null; + } +} + +/** Parse a full DNS/mDNS message into its questions and answer/additional records. */ +function parseMessage(buffer) { + const flags = buffer.readUInt16BE(2); + const isResponse = flags >= 0x8000; // QR bit is the most-significant flag bit + const questionCount = buffer.readUInt16BE(4); + const answerCount = buffer.readUInt16BE(6); + const authorityCount = buffer.readUInt16BE(8); + const additionalCount = buffer.readUInt16BE(10); + const totalRecords = answerCount + authorityCount + additionalCount; + + let offset = 12; + const questions = []; + for (let index = 0; index < questionCount; index++) { + const { name, nextOffset } = readName(buffer, offset); + const type = buffer.readUInt16BE(nextOffset); + questions.push({ name: name, type: type }); + offset = nextOffset + 4; // skip QTYPE (2) + QCLASS (2) + } + + const answers = []; + for (let index = 0; index < totalRecords; index++) { + if (offset >= buffer.length) { + break; + } + const { name, nextOffset } = readName(buffer, offset); + const type = buffer.readUInt16BE(nextOffset); + const rdataLength = buffer.readUInt16BE(nextOffset + 8); // skip type(2)+class(2)+ttl(4) + const rdataStart = nextOffset + 10; + const data = parseRecordData(buffer, type, rdataStart, rdataLength); + answers.push({ name: name, type: type, data: data }); + offset = rdataStart + rdataLength; + } + + return { isResponse: isResponse, questions: questions, answers: answers }; +} + +function listLocalIPv4Interfaces() { + const addresses = []; + for (const entries of Object.values(os.networkInterfaces())) { + for (const entry of entries || []) { + if (entry.family === 'IPv4' && !entry.internal) { + addresses.push(entry.address); + } + } + } + return addresses; +} + +function matchesFilter(responder, filterTerm) { + if (!filterTerm) { + return false; + } + const haystacks = [ + ...responder.instanceNames, + ...responder.hostnames, + ...responder.txtEntries, + ...responder.serviceTypes + ]; + return haystacks.some((value) => value.toLowerCase().includes(filterTerm)); +} + +function main() { + const options = parseArguments(); + const responders = new Map(); + const queriedServiceTypes = new Set(); + + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + + const getOrCreateResponder = (sourceIp) => { + let responder = responders.get(sourceIp); + if (!responder) { + responder = { + sourceIp: sourceIp, + serviceTypes: new Set(), + instanceNames: new Set(), + hostnames: new Set(), + addresses: new Set(), + ports: new Set(), + txtEntries: new Set() + }; + responders.set(sourceIp, responder); + } + return responder; + }; + + const sendPtrQuery = (serviceName) => { + if (options.passive) { + return; // listen-only: never emit queries + } + if (queriedServiceTypes.has(serviceName)) { + return; + } + queriedServiceTypes.add(serviceName); + const packet = buildPtrQuery(serviceName); + socket.send(packet, MDNS_PORT, MDNS_ADDRESS, (error) => { + if (error) { + console.error(` ! failed to send query for ${serviceName}: ${error.message}`); + } + }); + }; + + socket.on('error', (error) => { + console.error(`Socket error: ${error.message}`); + socket.close(); + }); + + socket.on('message', (message, remote) => { + let parsed; + try { + parsed = parseMessage(message); + } catch (error) { + if (options.verbose) { + console.error(` ! failed to parse packet from ${remote.address}: ${error.message}`); + } + return; + } + + // Only responses describe advertised services. Queries (including the mDNS queries + // devices send themselves) carry an EDNS OPT record but no answers we care about. + if (!parsed.isResponse || parsed.answers.length === 0) { + if (options.verbose && !parsed.isResponse) { + console.log(` [${remote.address}] (query) ${parsed.questions.map((question) => question.name).join(', ')}`); + } + return; + } + + const responder = getOrCreateResponder(remote.address); + + for (const record of parsed.answers) { + if (options.verbose) { + console.log(` [${remote.address}] ${typeName(record.type)} ${record.name} -> ${JSON.stringify(record.data)}`); + } + switch (record.type) { + case TYPE_PTR: + if (record.name === SERVICE_ENUMERATION_QUERY) { + // The meta-query answer is itself a service type; go probe it. + responder.serviceTypes.add(record.data); + sendPtrQuery(record.data); + } else { + // A service-type PTR answer names a concrete service instance. + responder.serviceTypes.add(record.name); + responder.instanceNames.add(record.data); + } + break; + case TYPE_SRV: + responder.instanceNames.add(record.name); + responder.hostnames.add(record.data.target); + responder.ports.add(record.data.port); + break; + case TYPE_TXT: + responder.instanceNames.add(record.name); + for (const entry of record.data) { + responder.txtEntries.add(entry); + } + break; + case TYPE_A: + responder.addresses.add(record.data); + break; + case TYPE_AAAA: + responder.addresses.add(record.data); + break; + default: + break; + } + } + }); + + socket.on('listening', () => { + const address = socket.address(); + console.log(`Listening on ${address.address}:${address.port}`); + try { + socket.addMembership(MDNS_ADDRESS); + } catch (error) { + console.error(` ! could not join multicast group: ${error.message}`); + } + socket.setMulticastTTL(255); + + const localInterfaces = listLocalIPv4Interfaces(); + console.log(`Local IPv4 interfaces: ${localInterfaces.join(', ') || '(none found)'}`); + + if (options.passive) { + console.log(`Passive listen-only mode: sending no queries, listening for ${options.listenSeconds}s...\n`); + return; + } + + console.log(`Scanning for ${options.listenSeconds}s...\n`); + + const initialQueries = [SERVICE_ENUMERATION_QUERY, ...SEED_SERVICE_TYPES]; + const sendInitialBurst = () => { + for (const serviceName of initialQueries) { + // Clear the dedup guard so repeated bursts actually re-send (UDP is lossy). + queriedServiceTypes.delete(serviceName); + sendPtrQuery(serviceName); + } + }; + // UDP is unreliable, so fire the initial burst a few times. + sendInitialBurst(); + setTimeout(sendInitialBurst, 250); + setTimeout(sendInitialBurst, 750); + }); + + // Bind to the standard mDNS port so we also catch multicast responses. On systems where + // another responder already holds 5353 (notably macOS, with mDNSResponder), reuseAddr lets + // us coexist. If the bind fails outright the 'error' handler logs it and closes the socket. + socket.bind(MDNS_PORT); + + setTimeout(() => { + socket.close(); + printSummary(responders, options); + }, options.listenSeconds * 1000); +} + +function typeName(type) { + switch (type) { + case TYPE_A: return 'A '; + case TYPE_AAAA: return 'AAAA'; + case TYPE_PTR: return 'PTR '; + case TYPE_TXT: return 'TXT '; + case TYPE_SRV: return 'SRV '; + default: return `#${type}`; + } +} + +function printSummary(responders, options) { + const all = [...responders.values()]; + const matched = all.filter((responder) => matchesFilter(responder, options.filter)); + const toPrint = options.showAll ? all : (matched.length > 0 ? matched : all); + + console.log('\n========================================'); + console.log(`Discovered ${all.length} responder(s); ${matched.length} match "${options.filter}".`); + if (!options.showAll && matched.length === 0) { + console.log(`(No matches for "${options.filter}". Showing everything so you can inspect. Use --all to force this.)`); + } + console.log('========================================\n'); + + for (const responder of toPrint) { + const matchTag = matchesFilter(responder, options.filter) ? ` <-- matches "${options.filter}"` : ''; + console.log(`Responder ${responder.sourceIp}${matchTag}`); + printSet(' service types', responder.serviceTypes); + printSet(' instances', responder.instanceNames); + printSet(' hostnames', responder.hostnames); + printSet(' addresses', responder.addresses); + if (responder.ports.size > 0) { + console.log(` ports: ${[...responder.ports].join(', ')}`); + } + printSet(' txt', responder.txtEntries); + console.log(''); + } +} + +function printSet(label, values) { + if (values.size === 0) { + return; + } + const items = [...values]; + console.log(`${label}: ${items[0]}`); + for (const item of items.slice(1)) { + console.log(`${' '.repeat(label.length + 2)}${item}`); + } +} + +main();