@homebridge/ciao
Version:
ciao is a RFC 6763 compliant dns-sd library, advertising on multicast dns (RFC 6762) implemented in plain Typescript/JavaScript
863 lines (862 loc) • 59.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Responder = void 0;
const tslib_1 = require("tslib");
const assert_1 = tslib_1.__importDefault(require("assert"));
const debug_1 = tslib_1.__importDefault(require("debug"));
const CiaoService_1 = require("./CiaoService");
const DNSPacket_1 = require("./coder/DNSPacket");
const Question_1 = require("./coder/Question");
const AAAARecord_1 = require("./coder/records/AAAARecord");
const ARecord_1 = require("./coder/records/ARecord");
const PTRRecord_1 = require("./coder/records/PTRRecord");
const SRVRecord_1 = require("./coder/records/SRVRecord");
const TXTRecord_1 = require("./coder/records/TXTRecord");
const MDNSServer_1 = require("./MDNSServer");
const Announcer_1 = require("./responder/Announcer");
const Prober_1 = require("./responder/Prober");
const QueryResponse_1 = require("./responder/QueryResponse");
const QueuedResponse_1 = require("./responder/QueuedResponse");
const TruncatedQuery_1 = require("./responder/TruncatedQuery");
const errors_1 = require("./util/errors");
const promise_utils_1 = require("./util/promise-utils");
const sorted_array_1 = require("./util/sorted-array");
const debug = (0, debug_1.default)("ciao:Responder");
const queuedResponseComparator = (a, b) => {
return a.estimatedTimeToBeSent - b.estimatedTimeToBeSent;
};
var ConflictType;
(function (ConflictType) {
ConflictType[ConflictType["NO_CONFLICT"] = 0] = "NO_CONFLICT";
ConflictType[ConflictType["CONFLICTING_RDATA"] = 1] = "CONFLICTING_RDATA";
ConflictType[ConflictType["CONFLICTING_TTL"] = 2] = "CONFLICTING_TTL";
})(ConflictType || (ConflictType = {}));
/**
* A Responder instance represents a running MDNSServer and a set of advertised services.
*
* It will handle any service related operations, like advertising, sending goodbye packets or sending record updates.
* It handles answering questions arriving at the multicast address.
*/
class Responder {
/**
* Refer to {@link getResponder} in the index file
*
* @private should not be used directly. Please use the getResponder method defined in index file.
*/
static getResponder(options) {
const optionsString = options ? JSON.stringify(options) : "";
const responder = this.INSTANCES.get(optionsString);
if (responder) {
responder.refCount++;
return responder;
}
else {
const responder = new Responder(options);
this.INSTANCES.set(optionsString, responder);
responder.optionsString = optionsString;
return responder;
}
}
constructor(options) {
this.refCount = 1;
this.optionsString = "";
this.bound = false;
/**
* Announced services is indexed by the {@link dnsLowerCase} if the fqdn (as of RFC 1035 3.1).
* As soon as the probing step is finished the service is added to the announced services Map.
*/
this.announcedServices = new Map();
/**
* map representing all our shared PTR records.
* Typically, we hold stuff like '_services._dns-sd._udp.local' (RFC 6763 9.), '_hap._tcp.local'.
* Also, pointers for every subtype like '_printer._sub._http._tcp.local' are inserted here.
*
* For every pointer we may hold multiple entries (like multiple services can advertise on _hap._tcp.local).
* The key as well as all values are {@link dnsLowerCase}
*/
this.servicePointer = new Map();
this.truncatedQueries = {}; // indexed by <ip>:<port>
this.delayedMulticastResponses = [];
this.server = new MDNSServer_1.MDNSServer(this, options);
this.promiseChain = this.start();
this.server.getNetworkManager().on("network-update" /* NetworkManagerEvent.NETWORK_UPDATE */, this.handleNetworkUpdate.bind(this));
this.ignoreUnicastResponseFlag = options === null || options === void 0 ? void 0 : options.ignoreUnicastResponseFlag;
if (options === null || options === void 0 ? void 0 : options.periodicBroadcasts) {
this.broadcastInterval = setTimeout(this.handlePeriodicBroadcasts.bind(this), 30000).unref();
}
}
handlePeriodicBroadcasts() {
this.broadcastInterval = undefined;
debug("Sending periodic announcement on " + Array.from(this.server.getNetworkManager().getInterfaceMap().keys()).join(", "));
const boundInterfaceNames = Array.from(this.server.getBoundInterfaceNames());
for (const networkInterface of this.server.getNetworkManager().getInterfaceMap().values()) {
const question = new Question_1.Question("_hap._tcp.local.", 12 /* QType.PTR */, false);
let responses4 = [], responses6 = [];
if (boundInterfaceNames.includes(networkInterface.name)) {
responses4 = this.answerQuestion(question, {
port: 5353,
address: networkInterface.ipv4Netaddress,
interface: networkInterface.name,
});
}
if (boundInterfaceNames.includes(networkInterface.name + "/6")) {
responses6 = this.answerQuestion(question, {
port: 5353,
address: networkInterface.ipv6,
interface: networkInterface.name + "/6",
});
}
const responses = [...responses4, ...responses6];
QueryResponse_1.QueryResponse.combineResponses(responses);
for (const response of responses) {
if (!response.hasAnswers()) {
continue;
}
this.server.sendResponse(response.asPacket(), networkInterface.name);
}
}
this.broadcastInterval = setTimeout(this.handlePeriodicBroadcasts.bind(this), Math.random() * 3000 + 27000).unref();
}
/**
* Creates a new CiaoService instance and links it to this Responder instance.
*
* @param {ServiceOptions} options - Defines all information about the service which should be created.
* @returns The newly created {@link CiaoService} instance can be used to advertise and manage the created service.
*/
createService(options) {
const service = new CiaoService_1.CiaoService(this.server.getNetworkManager(), options);
service.on("publish" /* InternalServiceEvent.PUBLISH */, this.advertiseService.bind(this, service));
service.on("unpublish" /* InternalServiceEvent.UNPUBLISH */, this.unpublishService.bind(this, service));
service.on("republish" /* InternalServiceEvent.REPUBLISH */, this.republishService.bind(this, service));
service.on("records-update" /* InternalServiceEvent.RECORD_UPDATE */, this.handleServiceRecordUpdate.bind(this, service));
service.on("records-update-interface" /* InternalServiceEvent.RECORD_UPDATE_ON_INTERFACE */, this.handleServiceRecordUpdateOnInterface.bind(this, service));
return service;
}
/**
* This method should be called when you want to unpublish all service exposed by this Responder.
* This method SHOULD be called before the node application exists, so any host on the
* network is informed of the shutdown of this machine.
* Calling the shutdown method is mandatory for a clean termination (sending goodbye packets).
*
* The shutdown method must only be called ONCE.
*
* @returns The Promise resolves once all goodbye packets were sent
* (or immediately if any other users have a reference to this Responder instance).
*/
shutdown() {
this.refCount--; // we trust the user here, that the shutdown will not be executed twice or something :thinking:
if (this.refCount > 0) {
return Promise.resolve();
}
if (this.currentProber) {
// Services which are in Probing step aren't included in announcedServices Map
// thus we need to cancel them as well
this.currentProber.cancel();
}
if (this.broadcastInterval) {
clearTimeout(this.broadcastInterval);
}
Responder.INSTANCES.delete(this.optionsString);
debug("Shutting down Responder...");
const promises = [];
for (const service of this.announcedServices.values()) {
promises.push(this.unpublishService(service));
}
return Promise.all(promises).then(() => {
this.server.shutdown();
this.bound = false;
});
}
getAnnouncedServices() {
return this.announcedServices.values();
}
start() {
if (this.bound) {
throw new Error("Server is already bound!");
}
this.bound = true;
return this.server.bind();
}
advertiseService(service, callback) {
if (service.serviceState === "announced" /* ServiceState.ANNOUNCED */) {
throw new Error("Can't publish a service that is already announced. Received " + service.serviceState + " for service " + service.getFQDN());
}
else if (service.serviceState === "probing" /* ServiceState.PROBING */) {
return this.promiseChain.then(() => {
if (service.currentAnnouncer) {
return service.currentAnnouncer.awaitAnnouncement();
}
});
}
else if (service.serviceState === "announcing" /* ServiceState.ANNOUNCING */) {
(0, assert_1.default)(service.currentAnnouncer, "Service is in state ANNOUNCING though has no linked announcer!");
if (service.currentAnnouncer.isSendingGoodbye()) {
return service.currentAnnouncer.awaitAnnouncement().then(() => this.advertiseService(service, callback));
}
else {
return service.currentAnnouncer.cancel().then(() => this.advertiseService(service, callback));
}
}
debug("[%s] Going to advertise service...", service.getFQDN()); // TODO include restricted addresses and stuff
// multicast loopback is not enabled for our sockets, though we do some stuff, so Prober will handle potential
// name conflicts with our own services:
// - One Responder will always run ONE prober: no need to handle simultaneous probe tiebreaking
// - Prober will call the Responder to generate responses to its queries to
// resolve name conflicts the same way as with other services on the network
this.promiseChain = this.promiseChain // we synchronize all ongoing probes here
.then(() => service.rebuildServiceRecords()) // build the records the first time for the prober
.then(() => this.probe(service)); // probe errors are catch below
return this.promiseChain.then(() => {
// we are not returning the promise returned by announced here, only PROBING is synchronized
this.announce(service).catch(reason => {
// handle announce errors
console.log(`[${service.getFQDN()}] failed announcing with reason: ${reason}. Trying again in 2 seconds!`);
return (0, promise_utils_1.PromiseTimeout)(2000).then(() => this.advertiseService(service, () => {
// empty
}));
});
callback(); // service is considered announced. After the call to the announce() method the service state is set to ANNOUNCING
}, reason => {
/*
* I know seems unintuitive to place the probe error handling below here, miles away from the probe method call.
* Trust me it makes sense (encountered regression now two times in a row).
* 1. We can't put it in the THEN call above, since then errors simply won't be handled from the probe method call.
* (CANCEL error would be passed through and would result in some unwanted stack trace)
* 2. We can't add a catch call above, since otherwise we would silence the CANCEL would be silenced and announce
* would be called anyway.
*/
// handle probe error
if (reason === Prober_1.Prober.CANCEL_REASON) {
callback();
}
else { // other errors are only thrown when sockets error occur
console.log(`[${service.getFQDN()}] failed probing with reason: ${reason}. Trying again in 2 seconds!`);
return (0, promise_utils_1.PromiseTimeout)(2000).then(() => this.advertiseService(service, callback));
}
});
}
async republishService(service, callback, delayAnnounce = false) {
if (service.serviceState !== "announced" /* ServiceState.ANNOUNCED */ && service.serviceState !== "announcing" /* ServiceState.ANNOUNCING */) {
throw new Error("Can't unpublish a service which isn't announced yet. Received " + service.serviceState + " for service " + service.getFQDN());
}
debug("[%s] Readvertising service...", service.getFQDN());
if (service.serviceState === "announcing" /* ServiceState.ANNOUNCING */) {
(0, assert_1.default)(service.currentAnnouncer, "Service is in state ANNOUNCING though has no linked announcer!");
const promise = service.currentAnnouncer.isSendingGoodbye()
? service.currentAnnouncer.awaitAnnouncement()
: service.currentAnnouncer.cancel();
return promise.then(() => this.advertiseService(service, callback));
}
// first of all remove it from our advertisedService Map and remove all the maintained PTRs
this.clearService(service);
service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */; // the service is now considered unannounced
// and now we basically just announce the service by doing probing and the 'announce' step
if (delayAnnounce) {
return (0, promise_utils_1.PromiseTimeout)(1000)
.then(() => this.advertiseService(service, callback));
}
else {
return this.advertiseService(service, callback);
}
}
unpublishService(service, callback) {
if (service.serviceState === "unannounced" /* ServiceState.UNANNOUNCED */) {
throw new Error("Can't unpublish a service which isn't announced yet. Received " + service.serviceState + " for service " + service.getFQDN());
}
if (service.serviceState === "announced" /* ServiceState.ANNOUNCED */ || service.serviceState === "announcing" /* ServiceState.ANNOUNCING */) {
if (service.serviceState === "announcing" /* ServiceState.ANNOUNCING */) {
(0, assert_1.default)(service.currentAnnouncer, "Service is in state ANNOUNCING though has no linked announcer!");
if (service.currentAnnouncer.isSendingGoodbye()) {
return service.currentAnnouncer.awaitAnnouncement(); // we are already sending a goodbye
}
return service.currentAnnouncer.cancel().then(() => {
service.serviceState = "announced" /* ServiceState.ANNOUNCED */; // unpublishService requires announced state
return this.unpublishService(service, callback);
});
}
debug("[%s] Removing service from the network", service.getFQDN());
this.clearService(service);
service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */;
let promise = this.goodbye(service);
if (callback) {
promise = promise.then(() => callback(), reason => {
console.log(`[${service.getFQDN()}] failed goodbye with reason: ${reason}.`);
callback();
});
}
return promise;
}
else if (service.serviceState === "probing" /* ServiceState.PROBING */) {
debug("[%s] Canceling probing", service.getFQDN());
if (this.currentProber && this.currentProber.getService() === service) {
this.currentProber.cancel();
this.currentProber = undefined;
}
service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */;
}
if (typeof callback === "function") {
callback();
}
return Promise.resolve();
}
clearService(service) {
const serviceFQDN = service.getLowerCasedFQDN();
const typePTR = service.getLowerCasedTypePTR();
const subtypePTRs = service.getLowerCasedSubtypePTRs(); // possibly undefined
this.removePTR(Responder.SERVICE_TYPE_ENUMERATION_NAME, typePTR);
this.removePTR(typePTR, serviceFQDN);
if (subtypePTRs) {
for (const ptr of subtypePTRs) {
this.removePTR(ptr, serviceFQDN);
}
}
this.announcedServices.delete(service.getLowerCasedFQDN());
}
addPTR(ptr, name) {
// we don't call lower case here, as we expect the caller to have done that already
// name = dnsLowerCase(name); // worst case is that the meta query ptr record contains lower cased destination
const names = this.servicePointer.get(ptr);
if (names) {
if (!names.includes(name)) {
names.push(name);
}
}
else {
this.servicePointer.set(ptr, [name]);
}
}
removePTR(ptr, name) {
const names = this.servicePointer.get(ptr);
if (names) {
const index = names.indexOf(name);
if (index !== -1) {
names.splice(index, 1);
}
if (names.length === 0) {
this.servicePointer.delete(ptr);
}
}
}
probe(service) {
if (service.serviceState !== "unannounced" /* ServiceState.UNANNOUNCED */) {
throw new Error("Can't probe for a service which is announced already. Received " + service.serviceState + " for service " + service.getFQDN());
}
service.serviceState = "probing" /* ServiceState.PROBING */;
(0, assert_1.default)(this.currentProber === undefined, "Tried creating new Prober when there already was one active!");
this.currentProber = new Prober_1.Prober(this, this.server, service);
return this.currentProber.probe()
.then(() => {
this.currentProber = undefined;
service.serviceState = "probed" /* ServiceState.PROBED */;
}, reason => {
service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */;
this.currentProber = undefined;
return Promise.reject(reason); // forward reason
});
}
announce(service) {
if (service.serviceState !== "probed" /* ServiceState.PROBED */) {
throw new Error("Cannot announce service which was not probed unique. Received " + service.serviceState + " for service " + service.getFQDN());
}
(0, assert_1.default)(service.currentAnnouncer === undefined, "Service " + service.getFQDN() + " is already announcing!");
service.serviceState = "announcing" /* ServiceState.ANNOUNCING */;
const announcer = new Announcer_1.Announcer(this.server, service, {
repetitions: 3,
});
service.currentAnnouncer = announcer;
const serviceFQDN = service.getLowerCasedFQDN();
const typePTR = service.getLowerCasedTypePTR();
const subtypePTRs = service.getLowerCasedSubtypePTRs(); // possibly undefined
this.addPTR(Responder.SERVICE_TYPE_ENUMERATION_NAME, typePTR);
this.addPTR(typePTR, serviceFQDN);
if (subtypePTRs) {
for (const ptr of subtypePTRs) {
this.addPTR(ptr, serviceFQDN);
}
}
this.announcedServices.set(serviceFQDN, service);
return announcer.announce().then(() => {
service.serviceState = "announced" /* ServiceState.ANNOUNCED */;
service.currentAnnouncer = undefined;
}, reason => {
service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */;
service.currentAnnouncer = undefined;
this.clearService(service); // also removes entry from announcedServices
if (reason !== Announcer_1.Announcer.CANCEL_REASON) {
// forward reason if it is not a cancellation.
// We do not forward cancel reason. Announcements only get cancelled if we have something "better" to do.
// So the race is already handled by us.
return Promise.reject(reason);
}
});
}
handleServiceRecordUpdate(service, response, callback) {
var _a;
// when updating we just repeat the 'announce' step
if (service.serviceState !== "announced" /* ServiceState.ANNOUNCED */) { // different states are already handled in CiaoService where this event handler is fired
throw new Error("Cannot update txt of service which is not announced yet. Received " + service.serviceState + " for service " + service.getFQDN());
}
debug("[%s] Updating %d record(s) for given service!", service.getFQDN(), response.answers.length + (((_a = response.additionals) === null || _a === void 0 ? void 0 : _a.length) || 0));
// TODO we should do a announcement at this point "in theory"
this.server.sendResponseBroadcast(response, service).then(results => {
const failRatio = (0, MDNSServer_1.SendResultFailedRatio)(results);
if (failRatio === 1) {
console.log((0, MDNSServer_1.SendResultFormatError)(results, `Failed to send records update for '${service.getFQDN()}'`), true);
if (callback) {
callback(new Error("Updating records failed as of socket errors!"));
}
return; // all failed => updating failed
}
if (failRatio > 0) {
// some queries on some interfaces failed, but not all. We log that but consider that to be a success
// at this point we are not responsible for removing stale network interfaces or something
debug((0, MDNSServer_1.SendResultFormatError)(results, `Some of the record updates for '${service.getFQDN()}' failed`));
// SEE no return here
}
if (callback) {
callback();
}
});
}
handleServiceRecordUpdateOnInterface(service, name, records, callback) {
// when updating we just repeat the 'announce' step
if (service.serviceState !== "announced" /* ServiceState.ANNOUNCED */) { // different states are already handled in CiaoService where this event handler is fired
throw new Error("Cannot update txt of service which is not announced yet. Received " + service.serviceState + " for service " + service.getFQDN());
}
debug("[%s] Updating %d record(s) for given service on interface %s!", service.getFQDN(), records.length, name);
const packet = DNSPacket_1.DNSPacket.createDNSResponsePacketsFromRRSet({ answers: records });
this.server.sendResponse(packet, name, callback);
}
goodbye(service) {
(0, assert_1.default)(service.currentAnnouncer === undefined, "Service " + service.getFQDN() + " is already announcing!");
service.serviceState = "announcing" /* ServiceState.ANNOUNCING */;
const announcer = new Announcer_1.Announcer(this.server, service, {
repetitions: 1,
goodbye: true,
});
service.currentAnnouncer = announcer;
return announcer.announce().then(() => {
service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */;
service.currentAnnouncer = undefined;
}, reason => {
// just assume unannounced. we won't be answering anymore, so the record will be flushed from cache sometime.
service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */;
service.currentAnnouncer = undefined;
return Promise.reject(reason);
});
}
handleNetworkUpdate(change) {
for (const service of this.announcedServices.values()) {
service.handleNetworkInterfaceUpdate(change);
}
}
/**
* @private method called by the MDNSServer when an incoming query needs ot be handled
*/
handleQuery(packet, endpoint) {
const start = new Date().getTime();
const endpointId = endpoint.address + ":" + endpoint.port + ":" + endpoint.interface; // used to match truncated queries
const previousQuery = this.truncatedQueries[endpointId];
if (previousQuery) {
const truncatedQueryResult = previousQuery.appendDNSPacket(packet);
switch (truncatedQueryResult) {
case 1 /* TruncatedQueryResult.ABORT */: // returned when we detect, that continuously TC queries are sent
delete this.truncatedQueries[endpointId];
debug("[%s] Aborting to wait for more truncated queries. Waited a total of %d ms receiving %d queries", endpointId, previousQuery.getTotalWaitTime(), previousQuery.getArrivedPacketCount());
return;
case 2 /* TruncatedQueryResult.AGAIN_TRUNCATED */:
debug("[%s] Received a query marked as truncated, waiting for more to arrive", endpointId);
return; // wait for the next packet
case 3 /* TruncatedQueryResult.FINISHED */:
delete this.truncatedQueries[endpointId];
packet = previousQuery.getPacket(); // replace packet with the complete deal
debug("[%s] Last part of the truncated query arrived. Received %d packets taking a total of %d ms", endpointId, previousQuery.getArrivedPacketCount(), previousQuery.getTotalWaitTime());
break;
}
}
else if (packet.flags.truncation) {
// RFC 6763 18.5 truncate flag indicates that additional known-answer records follow shortly
debug("Received truncated query from " + JSON.stringify(endpoint) + " waiting for more to come!");
const truncatedQuery = new TruncatedQuery_1.TruncatedQuery(packet);
this.truncatedQueries[endpointId] = truncatedQuery;
truncatedQuery.on("timeout" /* TruncatedQueryEvent.TIMEOUT */, () => {
// called when more than 400-500ms pass until the next packet arrives
debug("[%s] Timeout passed since the last truncated query was received. Discarding %d packets received in %d ms.", endpointId, truncatedQuery.getArrivedPacketCount(), truncatedQuery.getTotalWaitTime());
delete this.truncatedQueries[endpointId];
});
return; // wait for the next query
}
const isUnicastQuerier = endpoint.port !== MDNSServer_1.MDNSServer.MDNS_PORT; // explained below
const isProbeQuery = packet.authorities.size > 0;
let udpPayloadSize = undefined; // payload size supported by the querier
for (const record of packet.additionals.values()) {
if (record.type === 41 /* RType.OPT */) {
udpPayloadSize = record.udpPayloadSize;
break;
}
}
// responses must not include questions RFC 6762 6.
// known answer suppression according to RFC 6762 7.1.
const multicastResponses = [];
const unicastResponses = [];
// gather answers for all the questions
packet.questions.forEach(question => {
const responses = this.answerQuestion(question, endpoint, packet.answers);
if (isUnicastQuerier || question.unicastResponseFlag && !this.ignoreUnicastResponseFlag) {
unicastResponses.push(...responses);
}
else {
multicastResponses.push(...responses);
}
});
if (this.currentProber) {
this.currentProber.handleQuery(packet, endpoint);
}
if (isUnicastQuerier) {
// we are dealing with a legacy unicast dns query (RFC 6762 6.7.)
// * MUSTS: response via unicast, repeat query ID, repeat questions, clear cache flush bit
// * SHOULDS: ttls should not be greater than 10s as legacy resolvers don't take part in the cache coherency mechanism
for (let i = 0; i < unicastResponses.length; i++) {
const response = unicastResponses[i];
// only add questions to the first packet (will be combined anyway) and we must ensure
// each packet stays unique in its records
response.markLegacyUnicastResponse(packet.id, i === 0 ? Array.from(packet.questions.values()) : undefined);
}
}
// RFC 6762 6.4. Response aggregation:
// When possible, a responder SHOULD, for the sake of network
// efficiency, aggregate as many responses as possible into a single
// Multicast DNS response message. For example, when a responder has
// several responses it plans to send, each delayed by a different
// interval, then earlier responses SHOULD be delayed by up to an
// additional 500 ms if that will permit them to be aggregated with
// other responses scheduled to go out a little later.
QueryResponse_1.QueryResponse.combineResponses(multicastResponses, udpPayloadSize);
QueryResponse_1.QueryResponse.combineResponses(unicastResponses, udpPayloadSize);
if (isUnicastQuerier && unicastResponses.length > 1) {
// RFC 6762 18.5. In legacy unicast response messages, the TC bit has the same meaning
// as in conventional Unicast DNS: it means that the response was too
// large to fit in a single packet, so the querier SHOULD reissue its
// query using TCP in order to receive the larger response.
unicastResponses.splice(1, unicastResponses.length - 1); // discard all other
unicastResponses[0].markTruncated();
}
for (const unicastResponse of unicastResponses) {
if (!unicastResponse.hasAnswers()) {
continue;
}
this.server.sendResponse(unicastResponse.asPacket(), endpoint);
const time = new Date().getTime() - start;
debug("Sending response via unicast to %s (took %d ms): %s", JSON.stringify(endpoint), time, unicastResponse.asString(udpPayloadSize));
}
for (const multicastResponse of multicastResponses) {
if (!multicastResponse.hasAnswers()) {
continue;
}
if ((multicastResponse.containsSharedAnswer() || packet.questions.size > 1) && !isProbeQuery) {
// We must delay the response on an interval of 20-120ms if we can't assure that we are the only one responding (shared records).
// This is also the case if there are multiple questions. If multiple questions are asked
// we probably could not answer them all (because not all of them were directed to us).
// All those conditions are overridden if this is a probe query. To those queries we must respond instantly!
const time = new Date().getTime() - start;
this.enqueueDelayedMulticastResponse(multicastResponse.asPacket(), endpoint.interface, time);
}
else {
// otherwise the response is sent immediately, if there isn't any packet in the queue
// so first step is, check if there is a packet in the queue we are about to send out
// which can be combined with our current packet without adding a delay > 500ms
let sentWithLaterPacket = false;
for (let i = 0; i < this.delayedMulticastResponses.length; i++) {
const delayedResponse = this.delayedMulticastResponses[i];
if (delayedResponse.getTimeTillSent() > QueuedResponse_1.QueuedResponse.MAX_DELAY) {
// all packets following won't be compatible either
break;
}
if (delayedResponse.combineWithUniqueResponseIfPossible(multicastResponse, endpoint.interface)) {
const time = new Date().getTime() - start;
sentWithLaterPacket = true;
debug("Multicast response on interface %s containing unique records (took %d ms) was combined with response which is sent out later", endpoint.interface, time);
break;
}
}
if (!sentWithLaterPacket) {
this.server.sendResponse(multicastResponse.asPacket(), endpoint.interface);
const time = new Date().getTime() - start;
debug("Sending response via multicast on network %s (took %d ms): %s", endpoint.interface, time, multicastResponse.asString(udpPayloadSize));
}
}
}
}
/**
* @private method called by the MDNSServer when an incoming response needs to be handled
*/
handleResponse(packet, endpoint) {
// any questions in a response must be ignored RFC 6762 6.
if (this.currentProber) { // if there is a probing process running currently, just forward all messages to it
this.currentProber.handleResponse(packet, endpoint);
}
for (const service of this.announcedServices.values()) {
let conflictingRData = false;
let ttlConflicts = 0; // we currently do a full-blown announcement with all records, we could in the future track which records have invalid ttl
for (const record of packet.answers.values()) {
const type = Responder.checkRecordConflictType(service, record, endpoint);
if (type === 1 /* ConflictType.CONFLICTING_RDATA */) {
conflictingRData = true;
break; // we will republish in any case
}
else if (type === 2 /* ConflictType.CONFLICTING_TTL */) {
ttlConflicts++;
}
}
if (!conflictingRData) {
for (const record of packet.additionals.values()) {
const type = Responder.checkRecordConflictType(service, record, endpoint);
if (type === 1 /* ConflictType.CONFLICTING_RDATA */) {
conflictingRData = true;
break; // we will republish in any case
}
else if (type === 2 /* ConflictType.CONFLICTING_TTL */) {
ttlConflicts++;
}
}
}
if (conflictingRData) {
// noinspection JSIgnoredPromiseFromCall
this.republishService(service, error => {
if (error) {
console.log(`FATAL Error occurred trying to resolve conflict for service ${service.getFQDN()}! We can't recover from this!`);
console.log(error.stack);
process.exit(1); // we have a service which should be announced, though we failed to reannounce.
// if this should ever happen in reality, whe might want to introduce a more sophisticated recovery
// for situations where it makes sense
}
}, true);
}
else if (ttlConflicts && !service.currentAnnouncer) {
service.serviceState = "announcing" /* ServiceState.ANNOUNCING */; // all code above doesn't expect an Announcer object in state ANNOUNCED
const announcer = new Announcer_1.Announcer(this.server, service, {
repetitions: 1, // we send exactly one packet to correct any ttl values in neighbouring caches
});
service.currentAnnouncer = announcer;
announcer.announce().then(() => {
service.currentAnnouncer = undefined;
service.serviceState = "announced" /* ServiceState.ANNOUNCED */;
}, reason => {
service.currentAnnouncer = undefined;
service.serviceState = "announced" /* ServiceState.ANNOUNCED */;
if (reason === Announcer_1.Announcer.CANCEL_REASON) {
return; // nothing to worry about
}
console.warn("When trying to resolve a ttl conflict on the network, we were unable to send our response packet: " + reason.message);
});
}
}
}
static checkRecordConflictType(service, record, endpoint) {
// RFC 6762 9. Conflict Resolution:
// A conflict occurs when a Multicast DNS responder has a unique record
// for which it is currently authoritative, and it receives a Multicast
// DNS response message containing a record with the same name, rrtype
// and rrclass, but inconsistent rdata. What may be considered
// inconsistent is context-sensitive, except that resource records with
// identical rdata are never considered inconsistent, even if they
// originate from different hosts. This is to permit use of proxies and
// other fault-tolerance mechanisms that may cause more than one
// responder to be capable of issuing identical answers on the network.
//
// A common example of a resource record type that is intended to be
// unique, not shared between hosts, is the address record that maps a
// host's name to its IP address. Should a host witness another host
// announce an address record with the same name but a different IP
// address, then that is considered inconsistent, and that address
// record is considered to be in conflict.
//
// Whenever a Multicast DNS responder receives any Multicast DNS
// response (solicited or otherwise) containing a conflicting resource
// record in any of the Resource Record Sections, the Multicast DNS
// responder MUST immediately reset its conflicted unique record to
// probing state, and go through the startup steps described above in
// Section 8, "Probing and Announcing on Startup". The protocol used in
// the Probing phase will determine a winner and a loser, and the loser
// MUST cease using the name, and reconfigure.
if (!service.advertisesOnInterface(endpoint.interface)) {
return 0 /* ConflictType.NO_CONFLICT */;
}
const recordName = record.getLowerCasedName();
if (recordName === service.getLowerCasedFQDN()) {
if (record.type === 33 /* RType.SRV */) {
const srvRecord = record;
if (srvRecord.getLowerCasedHostname() !== service.getLowerCasedHostname()) {
debug("[%s] Noticed conflicting record on the network. SRV with hostname: %s", service.getFQDN(), srvRecord.hostname);
return 1 /* ConflictType.CONFLICTING_RDATA */;
}
else if (srvRecord.port !== service.getPort()) {
debug("[%s] Noticed conflicting record on the network. SRV with port: %s", service.getFQDN(), srvRecord.port);
return 1 /* ConflictType.CONFLICTING_RDATA */;
}
if (srvRecord.ttl < SRVRecord_1.SRVRecord.DEFAULT_TTL / 2) {
return 2 /* ConflictType.CONFLICTING_TTL */;
}
}
else if (record.type === 16 /* RType.TXT */) {
const txtRecord = record;
const txt = service.getTXT();
if (txt.length !== txtRecord.txt.length) { // length differs, can't be the same data
debug("[%s] Noticed conflicting record on the network. TXT with differing data length", service.getFQDN());
return 1 /* ConflictType.CONFLICTING_RDATA */;
}
for (let i = 0; i < txt.length; i++) {
const buffer0 = txt[i];
const buffer1 = txtRecord.txt[i];
if (buffer0.length !== buffer1.length || buffer0.toString("hex") !== buffer1.toString("hex")) {
debug("[%s] Noticed conflicting record on the network. TXT with differing data.", service.getFQDN());
return 1 /* ConflictType.CONFLICTING_RDATA */;
}
}
if (txtRecord.ttl < TXTRecord_1.TXTRecord.DEFAULT_TTL / 2) {
return 2 /* ConflictType.CONFLICTING_TTL */;
}
}
}
else if (recordName === service.getLowerCasedHostname()) {
if (record.type === 1 /* RType.A */) {
const aRecord = record;
if (!service.hasAddress(aRecord.ipAddress)) {
// if the service doesn't expose the listed address we have a conflict
debug("[%s] Noticed conflicting record on the network. A with ip address: %s", service.getFQDN(), aRecord.ipAddress);
return 1 /* ConflictType.CONFLICTING_RDATA */;
}
if (aRecord.ttl < ARecord_1.ARecord.DEFAULT_TTL / 2) {
return 2 /* ConflictType.CONFLICTING_TTL */;
}
}
else if (record.type === 28 /* RType.AAAA */) {
const aaaaRecord = record;
if (!service.hasAddress(aaaaRecord.ipAddress)) {
// if the service doesn't expose the listed address we have a conflict
debug("[%s] Noticed conflicting record on the network. AAAA with ip address: %s", service.getFQDN(), aaaaRecord.ipAddress);
return 1 /* ConflictType.CONFLICTING_RDATA */;
}
if (aaaaRecord.ttl < AAAARecord_1.AAAARecord.DEFAULT_TTL / 2) {
return 2 /* ConflictType.CONFLICTING_TTL */;
}
}
}
else if (record.type === 12 /* RType.PTR */) {
const ptrRecord = record;
if (recordName === service.getLowerCasedTypePTR()) {
if (ptrRecord.getLowerCasedPTRName() === service.getLowerCasedFQDN() && ptrRecord.ttl < PTRRecord_1.PTRRecord.DEFAULT_TTL / 2) {
return 2 /* ConflictType.CONFLICTING_TTL */;
}
}
else if (recordName === Responder.SERVICE_TYPE_ENUMERATION_NAME) {
// nothing to do here, I guess
}
else {
const subTypes = service.getLowerCasedSubtypePTRs();
if (subTypes && subTypes.includes(recordName)
&& ptrRecord.getLowerCasedPTRName() === service.getLowerCasedFQDN() && ptrRecord.ttl < PTRRecord_1.PTRRecord.DEFAULT_TTL / 2) {
return 2 /* ConflictType.CONFLICTING_TTL */;
}
}
}
return 0 /* ConflictType.NO_CONFLICT */;
}
enqueueDelayedMulticastResponse(packet, interfaceName, time) {
const response = new QueuedResponse_1.QueuedResponse(packet, interfaceName);
response.calculateRandomDelay();
(0, sorted_array_1.sortedInsert)(this.delayedMulticastResponses, response, queuedResponseComparator);
// run combine/delay checks
for (let i = 0; i < this.delayedMulticastResponses.length; i++) {
const response0 = this.delayedMulticastResponses[i];
// search for any packets sent out after this packet
for (let j = i + 1; j < this.delayedMulticastResponses.length; j++) {
const response1 = this.delayedMulticastResponses[j];
if (!response0.delayWouldBeInTimelyManner(response1)) {
// all packets following won't be compatible either
break;
}
if (response0.combineWithNextPacketIfPossible(response1)) {
// combine was a success and the packet got delay
// remove the packet from the queue
const index = this.delayedMulticastResponses.indexOf(response0);
if (index !== -1) {
this.delayedMulticastResponses.splice(index, 1);
}
i--; // reduce i, as one element got removed from the queue
break;
}
// otherwise we continue with maybe some packets further ahead
}
}
if (!response.delayed) {
// only set timer if packet got not delayed
response.scheduleResponse(() => {
const index = this.delayedMulticastResponses.indexOf(response);
if (index !== -1) {
this.delayedMulticastResponses.splice(index, 1);
}
try {
this.server.sendResponse(response.getPacket(), interfaceName);
debug("Sending (delayed %dms) response via multicast on network interface %s (took %d ms): %s", Math.round(response.getTimeSinceCreation()), interfaceName, time, response.getPacket().asLoggingString());
}
catch (error) {
if (error.name === errors_1.ERR_INTERFACE_NOT_FOUND) {
debug("Multicast response (delayed %dms) was cancelled as the network interface %s is no longer available!", Math.round(response.getTimeSinceCreation()), interfaceName);
}
else if (error.name === errors_1.ERR_SERVER_CLOSED) {
debug("Multicast response (delayed %dms) was cancelled as the server is about to be shutdown!", Math.round(response.getTimeSinceCreation()));
}
else {
throw error;
}
}
});
}
}
answerQuestion(question, endpoint, knownAnswers) {
// RFC 6762 6: The determination of whether a given record answers a given question
// is made using the standard DNS rules: the record name must match the
// question name, the record rrtype must match the question qtype unless
// the qtype is "ANY" (255) or the rrtype is "CNAME" (5), and the record
// rrclass must match the question qclass unless the qclass is "ANY" (255).
if (question.class !== 1 /* QClass.IN */ && question.class !== 255 /* QClass.ANY */) {
// We just publish answers with IN class. So only IN or ANY questions classes will match
return [];
}
const serviceResponses = [];
let metaQueryResponse = undefined;
if (question.type === 12 /* QType.PTR */ || question.type === 255 /* QType.ANY */ || question.type === 5 /* QType.CNAME */) {
const destinations = this.servicePointer.get(question.getLowerCasedName()); // look up the pointer, all entries are dnsLowerCased
if (destinations) {
// if it's a pointer name, we handle it here
for (const data of destinations) {
// check if the PTR is pointing towards a service, like in questions for PTR '_hap._tcp.local'
// if that's the case, let the question be answered by the service itself
const service = this.announcedServices.get(data);
if (service) {
if (service.advertisesOnInterface(endpoint.interface)) {
// call the method for original question, so additionals get added properly
const response = Responder.answerServiceQuestion(service, question, endpoint, knownAnswers);
if (response.hasAnswers()) {
serviceResponses.push(response);
}
}
}
else {
if (!metaQueryResponse) {
metaQueryResponse = new QueryResponse_1.QueryResponse(knownAnswers);
serviceResponses.unshift(metaQueryResponse);
}
// it's probably question for PTR '_services._dns-sd._udp.local'
// the PTR will just point to something like '_hap._tcp.local' thus no additional records need to be included
metaQueryResponse.addAnswer(new PTRRecord_1.PTRRecord(question.name, data));
// we may send out meta queries on interfaces where there aren't any services, because they are
// restricted to other interfaces.
}
}
return serviceResponses; // if we got in this if-body, it was a pointer name and we handled it correctly
} /* else if (loweredQuestionName.endsWith(".in-addr.arpa") || loweredQuestionName.endsWith(".ip6.arpa")) { // reverse address lookup
const address = ipAddressFromReversAddressName(loweredQuestionName);
for (const service of this.announcedServices.values()) {
const record = service.re