UNPKG

@homebridge/ciao

Version:

ciao is a RFC 6763 compliant dns-sd library, advertising on multicast dns (RFC 6762) implemented in plain Typescript/JavaScript

675 lines 32 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MDNSServer = void 0; exports.SendResultFailedRatio = SendResultFailedRatio; exports.SendResultFormatError = SendResultFormatError; const tslib_1 = require("tslib"); const assert_1 = tslib_1.__importDefault(require("assert")); const crypto_1 = require("crypto"); const debug_1 = tslib_1.__importDefault(require("debug")); const dgram_1 = tslib_1.__importDefault(require("dgram")); const DNSPacket_1 = require("./coder/DNSPacket"); const NetworkManager_1 = require("./NetworkManager"); const domain_formatter_1 = require("./util/domain-formatter"); const errors_1 = require("./util/errors"); const promise_utils_1 = require("./util/promise-utils"); const debug = (0, debug_1.default)("ciao:MDNSServer"); /** * Returns the ration of rejected SendResults in the array. * A ratio of 0 indicates all sends were successful. * A ration of 1 indicates all sends failed. * A number in between signals that some of the sends failed. * * @param results - Array of {@link SendResult} */ function SendResultFailedRatio(results) { if (results.length === 0) { return 0; } let failedCount = 0; for (const result of results) { if (result.status !== "fulfilled") { failedCount++; } } return failedCount / results.length; } function SendResultFormatError(results, prefix, includeStack = false) { let failedCount = 0; for (const result of results) { if (result.status !== "fulfilled") { failedCount++; } } if (!prefix) { prefix = "Failed to send packets"; } if (failedCount < results.length) { prefix += ` (${failedCount}/${results.length}):`; } else { prefix += ":"; } if (includeStack) { let string = "=============================\n" + prefix; for (const result of results) { if (result.status === "rejected") { string += "\n--------------------\n" + "Failed to send packet on interface " + result.interface + ": " + result.reason.stack; } else if (result.status === "timeout") { string += "\n--------------------\n" + "Sending packet on interface " + result.interface + " timed out!"; } } string += "\n============================="; return string; } else { let string = prefix; for (const result of results) { if (result.status === "rejected") { string += "\n- Failed to send packet on interface " + result.interface + ": " + result.reason.message; } else if (result.status === "timeout") { string += "\n- Sending packet on interface " + result.interface + " timed out!"; } } return string; } } /** * This class can be used to create a mdns server to send and receive mdns packets on the local network. * * Currently only udp4 sockets will be advertised. */ class MDNSServer { constructor(handler, options) { this.sockets = new Map(); // Per-interface map of packet-hash -> array of send timestamps. An array (not a single // timestamp) is required so that duplicate packets sent in quick succession each have a // corresponding loopback suppression — matching the original array-based semantics. this.sentPackets = new Map(); // RFC 6762 15.1. If we are not the first responder bound to 5353 we can't receive unicast responses // thus the QU flag must not be used in queries. Responders are only affected when sending probe queries. // Probe queries should be sent with QU set, though can't be sent with QU when we can't receive unicast responses. this.suppressUnicastResponseFlag = false; this.bound = false; this.closed = false; this.advertiseFamilies = []; (0, assert_1.default)(handler, "handler cannot be undefined"); this.handler = handler; this.networkManager = new NetworkManager_1.NetworkManager({ interface: options && options.interface, excludeIpv6: options && options.disableIpv6, excludeIpv6Only: options && options.excludeIpv6Only, }); if (!(options && options.advertiseIpv4 === false)) { // IPv4 advertisements default to on this.advertiseFamilies.push("IPv4" /* IPFamily.IPv4 */); } if (options && options.advertiseIpv6) { // IPv6 advertisements default to off this.advertiseFamilies.push("IPv6" /* IPFamily.IPv6 */); } this.networkManager.on("network-update" /* NetworkManagerEvent.NETWORK_UPDATE */, this.handleUpdatedNetworkInterfaces.bind(this)); } getNetworkManager() { return this.networkManager; } getBoundInterfaceNames() { return this.sockets.keys(); } async bind() { if (this.closed) { throw new Error("Cannot rebind closed server!"); } // RFC 6762 15.1. suggest that we probe if we are not the only socket. // though as ciao will probably always be installed besides an existing mdns responder, we just assume that without probing // As it only affects probe queries, impact isn't that big. this.suppressUnicastResponseFlag = true; // wait for the first network interfaces to be discovered await this.networkManager.waitForInit(); const promises = []; for (const [name, networkInterface] of this.networkManager.getInterfaceMap()) { this.advertiseFamilies.forEach((family) => { const socket = this.createDgramSocket(name, true, family === "IPv6" /* IPFamily.IPv6 */ ? "udp6" : "udp4"); const promise = this.bindSocket(socket, networkInterface, family) .catch(reason => { // TODO if bind errors we probably will never bind again console.log("Could not bind detected network interface: " + reason.stack); }); promises.push(promise); }); } return Promise.all(promises).then(() => { this.bound = true; // map void[] to void }); } shutdown() { this.networkManager.shutdown(); for (const socket of this.sockets.values()) { socket.close(); } if (this.sentPacketsCleanupTimer !== undefined) { clearTimeout(this.sentPacketsCleanupTimer); this.sentPacketsCleanupTimer = undefined; } this.bound = false; this.closed = true; this.sockets.clear(); this.sentPackets.clear(); } sendQueryBroadcast(query, service) { const packets = DNSPacket_1.DNSPacket.createDNSQueryPackets(query); if (packets.length > 1) { debug("Query broadcast is split into %d packets!", packets.length); } const promises = []; for (const packet of packets) { promises.push(this.sendOnAllNetworksForService(packet, service)); } return Promise.all(promises).then((values) => { const results = []; for (const value of values) { // Array.concat returns a new array without mutating; use push so per-packet // results are actually accumulated. The previous form silently produced an // empty array, hiding socket failures from probe/query failure-ratio checks. results.push(...value); } return results; }); } sendResponseBroadcast(response, service) { const packet = DNSPacket_1.DNSPacket.createDNSResponsePacketsFromRRSet(response); return this.sendOnAllNetworksForService(packet, service); } sendResponse(response, endpointOrInterface, callback) { if (this.closed) { // silently drop responses during shutdown if (callback) { callback(); } return; } this.send(response, endpointOrInterface).then(result => { if (result.status === "rejected") { if (callback) { callback(new Error("Encountered socket error on " + result.interface + ": " + result.reason.message)); } else { MDNSServer.logSocketError(result.interface, result.reason); } } else if (callback) { callback(); } }); } sendOnAllNetworksForService(packet, service) { if (this.closed) { return Promise.resolve([]); // silently drop broadcasts during shutdown } this.checkUnicastResponseFlag(packet); const message = packet.encode(); this.assertBeforeSend(message, "IPv4" /* IPFamily.IPv4 */); const promises = []; for (const [name, socket] of this.sockets) { if (!service.advertisesOnInterface(name)) { // I don't like the fact that we put the check inside the MDNSServer, as it should be independent of the above layer. // Though I think this is currently the easiest approach. continue; } const isIPv6 = name.endsWith("/6"); const promise = new Promise(resolve => { socket.send(message, MDNSServer.MDNS_PORT, isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4, error => { if (error) { if (!MDNSServer.isSilencedSocketError(error)) { resolve({ status: "rejected", interface: name, reason: error, }); return; } } else { this.maintainSentPacketsInterface(name, message); } resolve({ status: "fulfilled", interface: name, }); }); }); promises.push(Promise.race([ promise, (0, promise_utils_1.PromiseTimeout)(MDNSServer.SEND_TIMEOUT).then(() => ({ status: "timeout", interface: name, })), ])); } return Promise.all(promises); } send(packet, endpointOrInterface) { if (this.closed) { const name = typeof endpointOrInterface === "string" ? endpointOrInterface : endpointOrInterface.interface; return Promise.resolve({ status: "rejected", interface: name, reason: new Error("Server is closed") }); } this.checkUnicastResponseFlag(packet); const message = packet.encode(); let address; let port; let name; let isIPv6; if (typeof endpointOrInterface === "string") { // its a network interface name isIPv6 = endpointOrInterface.endsWith("/6"); address = isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4; port = MDNSServer.MDNS_PORT; name = endpointOrInterface; } else { isIPv6 = endpointOrInterface.interface.endsWith("/6"); address = endpointOrInterface.address; port = endpointOrInterface.port; name = endpointOrInterface.interface; } this.assertBeforeSend(message, isIPv6 ? "IPv6" /* IPFamily.IPv6 */ : "IPv4" /* IPFamily.IPv4 */); const socket = this.sockets.get(name); if (!socket) { throw new errors_1.InterfaceNotFoundError(`Could not find socket for given network interface '${name}'`); } return new Promise(resolve => { socket.send(message, port, address, error => { if (error) { if (!MDNSServer.isSilencedSocketError(error)) { resolve({ status: "rejected", interface: name, reason: error, }); return; } } else { this.maintainSentPacketsInterface(name, message); } resolve({ status: "fulfilled", interface: name, }); }); }); } checkUnicastResponseFlag(packet) { if (this.suppressUnicastResponseFlag && packet.type === 0 /* PacketType.QUERY */) { packet.questions.forEach(record => record.unicastResponseFlag = false); } } assertBeforeSend(message, family) { (0, assert_1.default)(this.bound, "Cannot send packets before server is not bound!"); const ipHeaderSize = family === "IPv4" /* IPFamily.IPv4 */ ? MDNSServer.DEFAULT_IP4_HEADER : MDNSServer.DEFAULT_IP6_HEADER; // RFC 6762 17. (0, assert_1.default)(ipHeaderSize + MDNSServer.UDP_HEADER + message.length <= 9000, "DNS cannot exceed the size of 9000 bytes even with IP Fragmentation!"); } maintainSentPacketsInterface(name, packet) { const hash = MDNSServer.hashPacket(packet); let packets = this.sentPackets.get(name); if (!packets) { packets = new Map(); this.sentPackets.set(name, packets); } const timestamps = packets.get(hash); if (timestamps) { timestamps.push(Date.now()); } else { packets.set(hash, [Date.now()]); } this.scheduleSentPacketsCleanup(); } checkIfPacketWasPreviouslySentFromUs(name, packet) { const packets = this.sentPackets.get(name); if (!packets) { return false; } const hash = MDNSServer.hashPacket(packet); const timestamps = packets.get(hash); if (timestamps && timestamps.length > 0) { timestamps.shift(); // remove exactly one entry, preserving duplicate-packet semantics if (timestamps.length === 0) { packets.delete(hash); } return true; } return false; } /** * Schedules a one-shot cleanup pass that prunes {@link sentPackets} entries older than * {@link SENT_PACKETS_TTL}. Reschedules itself while entries remain. Does nothing if a * pass is already scheduled or the server is closed. * * Opportunistic pruning on receive would not suffice: on setups where some packets * never loop back (RFC 6762 doesn't guarantee loopback delivery over UDP), the map * would otherwise grow without bound on quiet or outbound-heavy interfaces. */ scheduleSentPacketsCleanup() { var _a, _b; if (this.sentPacketsCleanupTimer !== undefined || this.closed) { return; } this.sentPacketsCleanupTimer = setTimeout(() => { this.sentPacketsCleanupTimer = undefined; const cutoff = Date.now() - MDNSServer.SENT_PACKETS_TTL; let remaining = 0; for (const packets of this.sentPackets.values()) { for (const [hash, timestamps] of packets) { while (timestamps.length > 0 && timestamps[0] <= cutoff) { timestamps.shift(); } if (timestamps.length === 0) { packets.delete(hash); } else { remaining += timestamps.length; } } } if (remaining > 0) { this.scheduleSentPacketsCleanup(); } }, MDNSServer.SENT_PACKETS_TTL); // Don't let the cleanup timer hold the event loop open on otherwise-idle processes. (_b = (_a = this.sentPacketsCleanupTimer).unref) === null || _b === void 0 ? void 0 : _b.call(_a); } static hashPacket(packet) { // md5 over raw bytes: non-cryptographic use (loopback dedup only), operates on the // buffer directly (no lossy UTF-8 conversion), and ~2-3x faster than sha256 for the // small DNS packets we handle in this hot path. return (0, crypto_1.createHash)("md5").update(packet).digest("base64"); } createDgramSocket(name, reuseAddr = false, type = "udp4") { const socket = dgram_1.default.createSocket({ type: type, reuseAddr: reuseAddr, }); socket.on("message", (data, rinfo) => this.handleMessage(name, data, rinfo, type === "udp6" ? "IPv6" /* IPFamily.IPv6 */ : "IPv4" /* IPFamily.IPv4 */)); socket.on("error", error => { if (!MDNSServer.isSilencedSocketError(error)) { MDNSServer.logSocketError(name, error); } }); return socket; } bindSocket(socket, networkInterface, family) { return new Promise((resolve, reject) => { const errorHandler = (error) => reject(new Error("Failed to bind on interface " + networkInterface.name + ": " + error.message)); const isIPv6 = family === "IPv6" /* IPFamily.IPv6 */; socket.once("error", errorHandler); socket.on("close", () => { this.sockets.delete(networkInterface.name + (isIPv6 ? "/6" : "")); }); socket.bind(MDNSServer.MDNS_PORT, () => { socket.setRecvBufferSize(800 * 1024); // setting max recv buffer size to 800KiB (Pi will max out at 352KiB) socket.removeListener("error", errorHandler); const multicastAddress = isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4; const interfaceAddress = isIPv6 ? networkInterface.ipv6 : networkInterface.ipv4; // assert(interfaceAddress, "Interface address for " + networkInterface.name + " cannot be undefined!"); if (!interfaceAddress) { // There isn't necessarily an IPv4 and IPv6 address assigned to every interface even on dual-stack systems console.log("Warning: no " + (isIPv6 ? "IPv6" : "IPv4") + " address available on " + networkInterface.name); try { socket.close(); } catch (error) { // Ignore } resolve(); return; } try { socket.addMembership(multicastAddress, interfaceAddress); // socket.setMulticastInterface(isIPv6 ? "::%" + networkInterface.name : interfaceAddress!); socket.setMulticastInterface(isIPv6 ? interfaceAddress + "%" + networkInterface.name : interfaceAddress); socket.setMulticastTTL(MDNSServer.MDNS_TTL); // outgoing multicast datagrams socket.setTTL(MDNSServer.MDNS_TTL); // outgoing unicast datagrams socket.setMulticastLoopback(true); // We can't disable multicast loopback, as otherwise queriers on the same host won't receive our packets this.sockets.set(isIPv6 ? networkInterface.name + "/6" : networkInterface.name, socket); resolve(); } catch (error) { try { socket.close(); } catch (error) { debug("Error while closing socket which failed to bind. Error may be expected: " + error.message); } reject(new Error("Error binding socket on " + networkInterface.name + ": " + error.stack)); } }); }); } handleMessage(name, buffer, rinfo, family) { if (!this.bound) { return; } const networkInterface = this.networkManager.getInterface(name); if (!networkInterface) { debug("Received packet on non existing network interface: %s!", name); return; } if (this.checkIfPacketWasPreviouslySentFromUs(networkInterface.name, buffer)) { // multicastLoopback is enabled for every interface, meaning we would receive our own response // packets here. Thus, we silence them. We can't disable multicast loopback, as otherwise // queriers on the same host won't receive our packets return; } // We have the following problem on linux based platforms: // When setting up a socket like above (binding on 0.0.0.0:5353) and then adding membership for 224.0.0.251 for // A CERTAIN! interface, we will nonetheless receive packets from ALL other interfaces even the loopback interfaces. // This is not the case on platforms like e.g. macOS. There stuff is properly filtered, and we only receive packets // for the given interface we specified in our membership. // This has the problem, that when receiving packets from other interfaces, that we leak addresses which don't // exist on the given interface. We can't do much about it, as in typically multiple subnet networks, it is valid // that we receive a packet from an ip address which doesn't belong into the subnet of our given interface. // What we can do at least, is the following two things: // * if we are listening on the loopback interface, we filter out any traffic which doesn't belong to the loopback interface // * if we receive a packet from the loopback interface, we filter those out as well. // With that we at least ensure that the loopback address is never sent out to the network. // This is what we do below: const isIPv6 = family === "IPv6" /* IPFamily.IPv6 */; if (isIPv6) { if (networkInterface.loopback !== rinfo.address.includes("%lo")) { debug("Received packet on a %s interface (%s) which is coming from a %s interface (%s)", networkInterface.loopback ? "loopback" : "non-loopback", name, rinfo.address.includes("%lo") ? "loopback" : "non-loopback", rinfo.address); // return; } } else { const ip4Netaddress = (0, domain_formatter_1.getNetAddress)(rinfo.address, networkInterface.ip4Netmask); if (networkInterface.loopback) { if (ip4Netaddress !== networkInterface.ipv4Netaddress) { return; } } else if (this.networkManager.isLoopbackNetaddressV4(ip4Netaddress)) { debug("Received packet on interface '%s' which is not coming from the same subnet: %o", name, { address: rinfo.address, netaddress: ip4Netaddress, interface: networkInterface.ipv4 }); return; } } let packet; try { packet = DNSPacket_1.DNSPacket.decode(rinfo, buffer); } catch (error) { debug("Received a malformed packet from %o on interface %s. This might or might not be a problem. " + "Here is the received packet for debugging purposes '%s'. " + "Packet decoding failed with %s", rinfo, name, buffer.toString("base64"), error.stack); return; } if (packet.opcode !== 0 /* OpCode.QUERY */) { // RFC 6762 18.3 we MUST ignore messages with opcodes other than zero (QUERY) return; } if (packet.rcode !== 0 /* RCode.NoError */) { // RFC 6762 18.3 we MUST ignore messages with response code other than zero (NoError) return; } const endpoint = { address: rinfo.address, port: rinfo.port, interface: name + (isIPv6 ? "/6" : ""), }; if (packet.type === 0 /* PacketType.QUERY */) { try { this.handler.handleQuery(packet, endpoint); } catch (error) { console.warn("Error occurred handling incoming (on " + name + ") dns query packet: " + error.stack); } } else if (packet.type === 1 /* PacketType.RESPONSE */) { if (rinfo.port !== MDNSServer.MDNS_PORT) { // RFC 6762 6. Multicast DNS implementations MUST silently ignore any Multicast DNS responses // they receive where the source UDP port is not 5353. return; } try { this.handler.handleResponse(packet, endpoint); } catch (error) { console.warn("Error occurred handling incoming (on " + name + ") dns response packet: " + error.stack); } } } static isSilencedSocketError(error) { // silence those errors // they happen when the host is not reachable (EADDRNOTAVAIL for 224.0.0.251 or EHOSTDOWN for any unicast traffic) // caused by yet undetected network changes. // as we listen to 0.0.0.0 and the socket stays valid, this is not a problem const silenced = error.message.includes("EADDRNOTAVAIL") || error.message.includes("EHOSTDOWN") || error.message.includes("ENETUNREACH") || error.message.includes("EHOSTUNREACH") || error.message.includes("EPERM") || error.message.includes("EINVAL"); if (silenced) { debug("Silenced and ignored error (This is/should not be a problem, this message is only for informational purposes): " + error.message); } return silenced; } static logSocketError(name, error) { console.warn(`Encountered MDNS socket error on socket '${name}' : ${error.stack}`); return; } handleUpdatedNetworkInterfaces(networkUpdate) { if (networkUpdate.removed) { for (const networkInterface of networkUpdate.removed) { // Handle IPv4 let socket = this.sockets.get(networkInterface.name); this.sockets.delete(networkInterface.name); this.sentPackets.delete(networkInterface.name); if (socket) { socket.close(); } // Handle IPv6 socket = this.sockets.get(networkInterface.name + "/6"); this.sockets.delete(networkInterface.name + "/6"); this.sentPackets.delete(networkInterface.name + "/6"); if (socket) { socket.close(); } } } if (networkUpdate.changes) { for (const change of networkUpdate.changes) { // Handle IPv4 let socket = this.sockets.get(change.name); if (!change.outdatedIpv4 && change.updatedIpv4) { // On dynamic networks (e.g., Starlink, cellular), an interface may gain // an IPv4 address after initially appearing as IPv6-only. Handle gracefully // by treating it like a new interface addition for IPv4. debug("IPv4 address appeared on changed interface %s (was undefined, now %s). Adding membership.", change.name, change.updatedIpv4); if (socket) { try { socket.addMembership(MDNSServer.MULTICAST_IPV4, change.updatedIpv4); socket.setMulticastInterface(change.updatedIpv4); } catch (error) { debug("Error adding membership for newly appeared IPv4 on %s: %s", change.name, error.message); } } } else if (change.outdatedIpv4 && !change.updatedIpv4) { // On dynamic networks, an interface may lose its IPv4 address temporarily // (e.g., Starlink network flap, DHCP lease expiry). Handle gracefully by // dropping the old membership instead of crashing. debug("IPv4 address disappeared on changed interface %s (was %s, now undefined). Dropping membership.", change.name, change.outdatedIpv4); if (socket) { try { socket.dropMembership(MDNSServer.MULTICAST_IPV4, change.outdatedIpv4); } catch (error) { debug("Error dropping membership for disappeared IPv4 on %s: %s", change.name, error.message); } } } else if (socket && change.outdatedIpv4 && change.updatedIpv4) { try { socket.dropMembership(MDNSServer.MULTICAST_IPV4, change.outdatedIpv4); } catch (error) { debug("Thrown unexpected error when dropping outdated address membership: " + error.message); } try { socket.addMembership(MDNSServer.MULTICAST_IPV4, change.updatedIpv4); } catch (error) { debug("Thrown unexpected error when adding new address membership: " + error.message); } socket.setMulticastInterface(change.updatedIpv4); } // Handle IPv6 socket = this.sockets.get(change.name + "/6"); if (socket && change.outdatedIpv6 && change.updatedIpv6) { try { socket.dropMembership(MDNSServer.MULTICAST_IPV6, change.outdatedIpv6); } catch (error) { debug("Thrown unexpected error when dropping outdated address membership: " + error.message); } try { socket.addMembership(MDNSServer.MULTICAST_IPV6, change.updatedIpv6); } catch (error) { debug("Thrown unexpected error when adding new address membership: " + error.message); } } } } if (networkUpdate.added) { for (const networkInterface of networkUpdate.added) { this.advertiseFamilies.forEach((family) => { const socket = this.createDgramSocket(networkInterface.name, true, family === "IPv6" /* IPFamily.IPv6 */ ? "udp6" : "udp4"); this.bindSocket(socket, networkInterface, family).catch(reason => { // TODO if bind errors we probably will never bind again console.log("Could not bind detected network interface: " + reason.stack); }); }); } } } } exports.MDNSServer = MDNSServer; MDNSServer.DEFAULT_IP4_HEADER = 20; MDNSServer.DEFAULT_IP6_HEADER = 40; MDNSServer.UDP_HEADER = 8; MDNSServer.MDNS_PORT = 5353; MDNSServer.MDNS_TTL = 255; MDNSServer.MULTICAST_IPV4 = "224.0.0.251"; MDNSServer.MULTICAST_IPV6 = "FF02::FB"; MDNSServer.SEND_TIMEOUT = 200; // milliseconds // Sent packets are tracked to suppress multicast loopback. Entries older than this // are pruned: loopback normally completes in <1ms, so 5s gives ample headroom while // preventing unbounded growth when packets never loop back (observed on some Linux // setups and busy mDNS networks). MDNSServer.SENT_PACKETS_TTL = 5000; // milliseconds //# sourceMappingURL=MDNSServer.js.map