Files

571 lines
20 KiB
JavaScript
Raw Permalink Normal View History

'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
* <name>.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,
};