'use strict'; /** * A minimal multicast-DNS responder — just enough to make one name resolve. * * WHY THIS EXISTS: a bookmark has to survive DHCP handing the host a new address. * The obvious answer, "bookmark the computer's name", does not work here: Windows * ships an mDNS *resolver* but no *responder*, so it never publishes its own * .local, and an iPad or Mac asking for it gets silence. (Windows-to-Windows * appears to work only via LLMNR/NetBIOS, which Microsoft is ramping down.) * So the app publishes its OWN name — nownext.local by default — which macOS, iOS * and Windows 10 1703+/11 all resolve natively. * * Scope on purpose: one A record for one name. No service discovery (_http._tcp), * no PTR/SRV/TXT, no full RFC 6762 probing state machine. Zero dependencies. * * Limits worth knowing: mDNS is link-local (TTL 1) and does not cross VLANs or * subnets, and it needs inbound UDP 5353 through the firewall. The ranked IP * addresses remain the fallback for anywhere this can't reach. */ const dgram = require('dgram'); const os = require('os'); const MDNS_ADDRESS = '224.0.0.251'; const MDNS_PORT = 5353; const TYPE_A = 1; const TYPE_ANY = 255; const CLASS_IN = 1; /** Set on unique records so resolvers replace rather than accumulate. */ const CACHE_FLUSH = 0x8000; const DEFAULT_TTL = 120; function ipToInt(address) { const parts = String(address).split('.'); if (parts.length !== 4) return null; let value = 0; for (const part of parts) { const octet = Number.parseInt(part, 10); if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null; value = (value * 256) + octet; } return value; } function sameSubnet(addressA, addressB, netmask) { const a = ipToInt(addressA); const b = ipToInt(addressB); const mask = ipToInt(netmask); if (a === null || b === null || mask === null) return false; // >>> 0 keeps the result unsigned; a /0 mask would match everything, so guard it. return mask !== 0 && ((a & mask) >>> 0) === ((b & mask) >>> 0); } /** Encode a dotted name as length-prefixed DNS labels. */ function encodeName(name) { const labels = String(name).replace(/\.$/, '').split('.'); const parts = []; for (const label of labels) { const bytes = Buffer.from(label, 'utf8'); if (bytes.length > 63) throw new Error(`mDNS label too long: ${label}`); parts.push(Buffer.from([bytes.length]), bytes); } parts.push(Buffer.from([0])); return Buffer.concat(parts); } /** * Read a DNS name at `offset`, following compression pointers. * Returns the name plus the offset just past the name in the ORIGINAL position. */ function decodeName(buffer, offset) { const labels = []; let cursor = offset; let end = -1; let hops = 0; while (cursor < buffer.length) { const length = buffer[cursor]; if (length === 0) { cursor += 1; break; } if ((length & 0xc0) === 0xc0) { // Compression pointer: follow it, but the caller continues after the pointer. if (cursor + 1 >= buffer.length) return null; if (end === -1) end = cursor + 2; cursor = ((length & 0x3f) << 8) | buffer[cursor + 1]; hops += 1; if (hops > 16) return null; // malformed / pointer loop continue; } if (cursor + 1 + length > buffer.length) return null; labels.push(buffer.toString('utf8', cursor + 1, cursor + 1 + length)); cursor += 1 + length; } return { name: labels.join('.'), offset: end === -1 ? cursor : end }; } /** Parse the question section of a query. Returns null for anything malformed. */ function decodeQuestions(buffer) { if (buffer.length < 12) return null; const flags = buffer.readUInt16BE(2); if ((flags & 0x8000) !== 0) return { isResponse: true, questions: [] }; // a response, not a query const count = buffer.readUInt16BE(4); if (count === 0 || count > 32) return { isResponse: false, questions: [] }; const questions = []; let offset = 12; for (let i = 0; i < count; i += 1) { const decoded = decodeName(buffer, offset); if (!decoded) return null; offset = decoded.offset; if (offset + 4 > buffer.length) return null; const type = buffer.readUInt16BE(offset); const rawClass = buffer.readUInt16BE(offset + 2); offset += 4; questions.push({ name: decoded.name, type, class: rawClass & 0x7fff, // The top bit of QCLASS is the "unicast response wanted" flag (RFC 6762 §5.4). wantsUnicast: (rawClass & 0x8000) !== 0, }); } return { isResponse: false, questions }; } /** * Parse the answer section of a response, returning the A records only. * Needed for conflict detection: enterprise APs and mesh routers commonly REFLECT mDNS, * so our own announcement comes back from the reflector's address. Matching on raw bytes * flags that as a name clash; comparing the actual advertised addresses does not. */ function decodeAnswers(buffer) { if (buffer.length < 12) return []; const answerCount = buffer.readUInt16BE(6); if (answerCount === 0 || answerCount > 64) return []; const questionCount = buffer.readUInt16BE(4); let offset = 12; // Skip the question section, if the responder echoed it. for (let i = 0; i < questionCount; i += 1) { const decoded = decodeName(buffer, offset); if (!decoded) return []; offset = decoded.offset + 4; } const answers = []; for (let i = 0; i < answerCount; i += 1) { const decoded = decodeName(buffer, offset); if (!decoded) break; offset = decoded.offset; if (offset + 10 > buffer.length) break; const type = buffer.readUInt16BE(offset); const rdLength = buffer.readUInt16BE(offset + 8); offset += 10; if (offset + rdLength > buffer.length) break; if (type === TYPE_A && rdLength === 4) { answers.push({ name: decoded.name, address: `${buffer[offset]}.${buffer[offset + 1]}.${buffer[offset + 2]}.${buffer[offset + 3]}`, }); } offset += rdLength; } return answers; } /** Build a standard mDNS query for one name (type A, class IN). */ function buildQuery(name) { const header = Buffer.alloc(12); header.writeUInt16BE(0, 0); // mDNS queries use id 0 header.writeUInt16BE(0, 2); // standard query header.writeUInt16BE(1, 4); // one question const question = Buffer.alloc(4); question.writeUInt16BE(TYPE_A, 0); question.writeUInt16BE(CLASS_IN, 2); return Buffer.concat([header, encodeName(name), question]); } /** Build a response packet carrying one A record per address. */ function buildResponse(name, addresses, ttl, queryId) { const encodedName = encodeName(name); const answers = addresses.map((address) => { const rdata = Buffer.from(String(address).split('.').map((octet) => Number.parseInt(octet, 10))); const record = Buffer.alloc(10); record.writeUInt16BE(TYPE_A, 0); record.writeUInt16BE(CLASS_IN | CACHE_FLUSH, 2); record.writeUInt32BE(ttl, 4); record.writeUInt16BE(4, 8); return Buffer.concat([encodedName, record, rdata]); }); const header = Buffer.alloc(12); header.writeUInt16BE(queryId || 0, 0); header.writeUInt16BE(0x8400, 2); // QR=1 (response), AA=1 (authoritative) header.writeUInt16BE(0, 4); // no question echoed back header.writeUInt16BE(answers.length, 6); header.writeUInt16BE(0, 8); header.writeUInt16BE(0, 10); return Buffer.concat([header, ...answers]); } /** * @param {object} [options] * @param {string} [options.name] Bare label to publish; ".local" is appended. Default "nownext". * @param {() => string[]} [options.getAddresses] Current IPv4 addresses to advertise, best first. * @param {(message: string) => void} [options.log] */ function createMdnsResponder(options = {}) { const label = String(options.name || 'nownext').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase() || 'nownext'; const fqdn = `${label}.local`; const ttl = Number.isFinite(options.ttl) && options.ttl > 0 ? options.ttl : DEFAULT_TTL; const getAddresses = typeof options.getAddresses === 'function' ? options.getAddresses : () => []; const log = typeof options.log === 'function' ? options.log : () => {}; let socket = null; let started = false; let announceTimer = null; let lastError = ''; let conflictHost = ''; /** True while we have stood down from the name because another host really holds it. */ let relinquished = false; /** In-flight conflict verification: { foreign: string } while probing, else null. */ let probe = null; let recoveryTimer = null; /** Interfaces we can advertise on, as {address, netmask}. */ function localInterfaces() { const out = []; for (const entries of Object.values(os.networkInterfaces())) { for (const entry of entries || []) { const isIPv4 = entry.family === 'IPv4' || entry.family === 4; if (isIPv4 && !entry.internal) out.push({ address: entry.address, netmask: entry.netmask }); } } return out; } /** * Which of our addresses to hand a particular asker. On a multi-homed A/V machine * (house LAN + camera network) answering with everything would send a studio PC an * address it cannot route to, so prefer addresses on the asker's own subnet. */ function addressesForPeer(peerAddress) { const advertised = getAddresses().filter(Boolean); if (!advertised.length) return []; const interfaces = localInterfaces(); const onPeerSubnet = advertised.filter((address) => { const match = interfaces.find((entry) => entry.address === address); return match && sameSubnet(peerAddress, address, match.netmask); }); return onPeerSubnet.length ? onPeerSubnet : advertised; } function send(message, port, address) { if (!socket) return; try { socket.send(message, port, address, (err) => { if (err) lastError = err.message; }); } catch (err) { lastError = err instanceof Error ? err.message : String(err); } } /** Aim the next multicast send out of a specific interface. Best effort. */ function setOutgoingInterface(address) { try { socket.setMulticastInterface(address); return true; } catch { return false; } } /** * Announce (or, with ttlValue 0, withdraw) the name. * * One packet PER INTERFACE, each advertising only that interface's own address. Sending * every address to every interface is the tempting shortcut and it is wrong: a studio PC * would cache all of them, and browsers try cached addresses in turn — so a machine that * can only route to the house LAN might sit waiting on the camera-network address first. * A listener should only ever learn the address it can actually reach us on. */ function announce(ttlValue = ttl) { const advertised = getAddresses().filter(Boolean); if (!advertised.length) return; const interfaces = localInterfaces(); let sent = 0; for (const address of advertised) { if (!interfaces.some((entry) => entry.address === address)) continue; if (!setOutgoingInterface(address)) continue; send(buildResponse(fqdn, [address], ttlValue, 0), MDNS_PORT, MDNS_ADDRESS); sent += 1; } if (!sent) { // No interface would take the hint — fall back to one packet on the default route. setOutgoingInterface('0.0.0.0'); send(buildResponse(fqdn, advertised, ttlValue, 0), MDNS_PORT, MDNS_ADDRESS); } setOutgoingInterface('0.0.0.0'); } function handleMessage(buffer, rinfo) { let parsed; try { parsed = decodeQuestions(buffer); } catch { return; // never let a malformed packet from the network throw } if (!parsed) return; if (parsed.isResponse) { // A genuine clash is another host advertising OUR name with an address that is not // ours. A reflected copy of our own announcement carries only our own addresses, so // it no longer trips this. const mine = new Set(localInterfaces().map((entry) => entry.address)); for (const answer of decodeAnswers(buffer)) { if (answer.name.toLowerCase() !== fqdn) continue; if (mine.has(answer.address)) continue; // Only believe a claim from something on one of our own subnets. An off-link // packet naming our record is a spoof, not a neighbour. if (!onOurLink(rinfo.address)) continue; if (probe) { probe.foreign = answer.address; // a probe is running: this answers it } else if (!relinquished) { void verifyThenConcede(answer.address); } break; } return; } // Once the name is contested we must not answer for it (RFC 6762 §9). Two responders // both answering is worse than neither: the name resolves to a different machine each // time, so half the screens land on the wrong host and show nothing. if (relinquished) return; for (const question of parsed.questions) { if (question.name.toLowerCase() !== fqdn) continue; if (question.type !== TYPE_A && question.type !== TYPE_ANY) continue; if (question.class !== CLASS_IN && question.class !== TYPE_ANY) continue; const addresses = addressesForPeer(rinfo.address); if (!addresses.length) continue; const queryId = buffer.readUInt16BE(0); // Legacy resolvers (source port != 5353) and QU queries want a direct reply. if (question.wantsUnicast || rinfo.port !== MDNS_PORT) { send(buildResponse(fqdn, addresses, ttl, queryId), rinfo.port, rinfo.address); } else { // Reply out of the interface the answer is valid for, so nobody else on another // network caches an address they cannot reach. setOutgoingInterface(addresses[0]); send(buildResponse(fqdn, addresses, ttl, 0), MDNS_PORT, MDNS_ADDRESS); setOutgoingInterface('0.0.0.0'); } } } /** Is this address on one of our own subnets? */ function onOurLink(address) { return localInterfaces().some((entry) => sameSubnet(address, entry.address, entry.netmask)); } /** * Ask whether anyone else is holding our name (RFC 6762 §8.1-style probing: three * queries, 250ms apart). Resolves with the foreign address, or '' if the link is quiet. * * This is the difference between a robust responder and a one-packet kill switch. A * genuine second host answers a direct question; a spoofer who fires one unsolicited * packet and leaves does not. */ function probeForConflict() { return new Promise((resolve) => { if (!socket) { resolve(''); return; } probe = { foreign: '' }; const query = buildQuery(fqdn); let sent = 0; const tick = () => { if (!socket || !probe) return; if (probe.foreign || sent >= 3) { const found = probe ? probe.foreign : ''; probe = null; resolve(found); return; } sent += 1; send(query, MDNS_PORT, MDNS_ADDRESS); const next = setTimeout(tick, 250); next.unref?.(); }; tick(); // Give the third query time to be answered before declaring the link quiet. const done = setTimeout(() => { const found = probe ? probe.foreign : ''; probe = null; resolve(found); }, 1200); done.unref?.(); }); } /** * A neighbour appears to hold our name. Verify it before standing down, then keep * checking so the name comes back when they go away. * * Deliberately NOT the RFC's rename-on-conflict: the URL is the whole point of this * feature, and renaming would break every bookmark in the building — and would hand a * spoofer the good name. Standing aside temporarily keeps resolution unambiguous while * the numeric addresses (which the host app lists) keep working throughout. */ async function verifyThenConcede(claimedBy) { if (relinquished || probe) return; const confirmed = await probeForConflict(); if (!confirmed) { log(`ignored an unverified claim on ${fqdn} (nobody answered a probe) — keeping the name`); return; } conflictHost = confirmed; relinquished = true; log(`another host at ${confirmed} really holds ${fqdn} — standing down until it leaves`); if (announceTimer) { clearInterval(announceTimer); announceTimer = null; } try { announce(0); // TTL 0: resolvers drop OUR records so the name stops being ambiguous } catch { /* nothing more we can do */ } startRecoveryWatch(); } /** While stood down, re-probe periodically and reclaim the name once the link is quiet. */ function startRecoveryWatch() { if (recoveryTimer) return; recoveryTimer = setInterval(async () => { if (!socket || !relinquished) return; const stillThere = await probeForConflict(); if (stillThere) return; log(`${fqdn} is free again — reclaiming it`); relinquished = false; conflictHost = ''; clearInterval(recoveryTimer); recoveryTimer = null; announce(); announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000); announceTimer.unref?.(); }, 60000); recoveryTimer.unref?.(); } function start() { if (started) return Promise.resolve({ ok: true, name: fqdn }); started = true; return new Promise((resolve) => { let settled = false; const finish = (result) => { if (settled) return; settled = true; resolve(result); }; try { // reuseAddr is essential: macOS already runs mDNSResponder on 5353 and we must // coexist with it (it will not answer for our name, so there is no conflict). socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); } catch (err) { lastError = err instanceof Error ? err.message : String(err); started = false; finish({ ok: false, name: fqdn, error: lastError }); return; } socket.on('error', (err) => { lastError = err instanceof Error ? err.message : String(err); log(`socket error: ${lastError}`); try { socket.close(); } catch { /* already closed */ } socket = null; started = false; finish({ ok: false, name: fqdn, error: lastError }); }); socket.on('message', (buffer, rinfo) => { try { handleMessage(buffer, rinfo); } catch (err) { lastError = err instanceof Error ? err.message : String(err); } }); socket.bind({ port: MDNS_PORT, exclusive: false }, () => { try { socket.setMulticastTTL(255); // RFC 6762 §11 } catch { /* not fatal */ } let joined = 0; for (const entry of localInterfaces()) { try { socket.addMembership(MDNS_ADDRESS, entry.address); joined += 1; } catch { /* some adapters refuse multicast; others still work */ } } if (!joined) { try { socket.addMembership(MDNS_ADDRESS); joined = 1; } catch (err) { lastError = err instanceof Error ? err.message : String(err); } } // Announce twice a second apart (RFC 6762 §8.3 asks for repeats), then hourly-ish // so a resolver that missed both still learns the name. announce(); const second = setTimeout(announce, 1000); second.unref?.(); announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000); announceTimer.unref?.(); finish({ ok: joined > 0, name: fqdn, interfaces: joined, error: joined ? '' : lastError }); }); }); } function stop() { if (announceTimer) { clearInterval(announceTimer); announceTimer = null; } if (recoveryTimer) { clearInterval(recoveryTimer); recoveryTimer = null; } probe = null; if (socket) { // Goodbye: TTL 0 tells resolvers to forget the name immediately, so a screen opened // right after we stop fails fast instead of hanging on a stale cache entry. try { announce(0); } catch { /* going away anyway */ } try { socket.close(); } catch { /* already closed */ } socket = null; } started = false; } return { start, stop, get name() { return fqdn; }, get url() { return fqdn; }, isRunning: () => Boolean(socket), /** True only while we still own the name and are answering for it. */ isPublishing: () => Boolean(socket) && !relinquished, getLastError: () => lastError, getConflictHost: () => conflictHost, }; } module.exports = { createMdnsResponder, MDNS_PORT, // exported for tests buildQuery, encodeName, decodeName, decodeQuestions, decodeAnswers, buildResponse, sameSubnet, };