@homebridge/ciao
Version:
ciao is a RFC 6763 compliant dns-sd library, advertising on multicast dns (RFC 6762) implemented in plain Typescript/JavaScript
259 lines • 13.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Prober = void 0;
const tslib_1 = require("tslib");
const assert_1 = tslib_1.__importDefault(require("assert"));
const debug_1 = tslib_1.__importDefault(require("debug"));
const Question_1 = require("../coder/Question");
const MDNSServer_1 = require("../MDNSServer");
const tiebreaking = tslib_1.__importStar(require("../util/tiebreaking"));
const tiebreaking_1 = require("../util/tiebreaking");
const PROBE_INTERVAL = 250; // 250ms as defined in RFC 6762 8.1.
const LIMITED_PROBE_INTERVAL = 1000;
const debug = (0, debug_1.default)("ciao:Prober");
/**
* This class is used to execute the probing process for a given service as defined
* in RFC 6762 8.1.
* This ensures that we advertise the service under a unique name.
* It also provides a conflict resolution algorithm if multiple clients probing
* for the same name are detected.
*/
class Prober {
constructor(responder, server, service) {
this.records = [];
this.currentInterval = PROBE_INTERVAL;
this.serviceEncounteredNameChange = false;
this.sentFirstProbeQuery = false; // we MUST ignore responses received BEFORE the first probe is sent
this.sentQueriesForCurrentTry = 0;
this.sentQueries = 0;
(0, assert_1.default)(responder, "responder must be defined");
(0, assert_1.default)(server, "server must be defined");
(0, assert_1.default)(service, "service must be defined");
this.responder = responder;
this.server = server;
this.service = service;
}
getService() {
return this.service;
}
/**
* This method is called to start the actual probing process.
* Once the service is considered unique on the network and can be announced the promise returns.
* While probing multiple name changes can happen
*
* @returns a promise which returns when the service is considered unique on the network
*/
probe() {
/*
* Probing is basically the following process: We send three "probe" queries to check
* if the desired service name is already on the network.
* The request are sent with a delay of 250ms between them and the first
* request starting with a random delay.
* If we don't receive any response to our requests we consider the probing to be successful
* and continue with announcing our service.
*/
debug("Starting to probe for '%s'...", this.service.getFQDN());
return new Promise((resolve, reject) => {
this.promiseResolve = resolve;
this.promiseReject = reject;
this.timer = setTimeout(this.sendProbeRequest.bind(this), Math.random() * PROBE_INTERVAL);
this.timer.unref();
});
}
cancel() {
this.clear();
this.promiseReject(Prober.CANCEL_REASON);
}
clear() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
// reset all values to default (so the Prober can be reused if it wasn't successful)
this.sentFirstProbeQuery = false;
this.sentQueriesForCurrentTry = 0;
}
/**
* End the current ongoing probing requests. If
* @param success
*/
endProbing(success) {
// reset all values to default (so the Prober can be reused if it wasn't successful)
this.clear();
if (success) {
debug("Probing for '%s' finished successfully", this.service.getFQDN());
this.promiseResolve();
if (this.serviceEncounteredNameChange) {
this.service.informAboutNameUpdates();
}
}
}
sendProbeRequest() {
if (this.sentQueriesForCurrentTry === 0) { // this is the first query sent, init some stuff
// RFC 6762 8.2. When a host is probing for a group of related records with the same
// name (e.g., the SRV and TXT record describing a DNS-SD service), only
// a single question need be placed in the Question Section, since query
// type "ANY" (255) is used, which will elicit answers for all records
// with that name. However, for tiebreaking to work correctly in all
// cases, the Authority Section must contain *all* the records and
// proposed rdata being probed for uniqueness.
// it states *all* records, though we include ALL A/AAAA records as well, even
// though it may not be relevant data if the probe query is published on different interfaces.
// Having the same "format" probed on all interfaces, the simultaneous probe tiebreaking
// algorithm can work correctly. Otherwise, we would conflict with ourselves in a situation were
// a device is connected to the same network via Wi-Fi and Ethernet.
this.records = [
this.service.srvRecord(), this.service.txtRecord(),
this.service.ptrRecord(), ...this.service.subtypePtrRecords(),
...this.service.allAddressRecords(), //...this.service.allReverseAddressMappings(),
].sort(tiebreaking_1.rrComparator); // we sort them for the tiebreaking algorithm
this.records.forEach(record => record.flushFlag = false);
}
if (this.sentQueriesForCurrentTry >= 3) {
// we sent three requests, and it seems like we weren't canceled, so we have a success right here
this.endProbing(true);
return;
}
if (this.sentQueries >= 15) {
this.currentInterval = LIMITED_PROBE_INTERVAL;
}
debug("Sending prober query number %d for '%s'...", this.sentQueriesForCurrentTry + 1, this.service.getFQDN());
(0, assert_1.default)(this.records.length > 0, "Tried sending probing request for zero record length!");
const questions = [
// probes SHOULD be sent with unicast response flag as of the RFC
// MDNServer might overwrite the QU flag to false, as we can't use unicast if there is another responder on the machine
new Question_1.Question(this.service.getFQDN(), 255 /* QType.ANY */, true),
new Question_1.Question(this.service.getHostname(), 255 /* QType.ANY */, true),
];
this.server.sendQueryBroadcast({
questions: questions,
// TODO certified homekit accessories only include the main service PTR record
authorities: this.records, // include records we want to announce in authorities to support Simultaneous Probe Tiebreaking (RFC 6762 8.2.)
}, this.service).then(results => {
const failRatio = (0, MDNSServer_1.SendResultFailedRatio)(results);
if (failRatio === 1) {
console.error((0, MDNSServer_1.SendResultFormatError)(results, `Failed to send probe queries for '${this.service.getFQDN()}'`), true);
this.endProbing(false);
this.promiseReject(new Error("Probing failed as of socket errors!"));
return; // all failed => thus probing 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 probe queries for '${this.service.getFQDN()}' encountered an error`));
// SEE no return here
}
if (this.service.serviceState !== "probing" /* ServiceState.PROBING */) {
debug("Service '%s' is no longer in probing state. Stopping.", this.service.getFQDN());
return;
}
this.sentFirstProbeQuery = true;
this.sentQueriesForCurrentTry++;
this.sentQueries++;
this.timer = setTimeout(this.sendProbeRequest.bind(this), this.currentInterval);
this.timer.unref();
this.checkLocalConflicts();
});
}
checkLocalConflicts() {
let containsAnswer = false;
for (const service of this.responder.getAnnouncedServices()) {
if (service.getLowerCasedFQDN() === this.service.getLowerCasedFQDN() || service.getLowerCasedHostname() === this.service.getLowerCasedHostname()) {
containsAnswer = true;
break;
}
}
if (containsAnswer) {
debug("Probing for '%s' failed as of local service. Doing a name change", this.service.getFQDN());
this.handleNameChange();
}
}
handleResponse(packet, endpoint) {
if (!this.sentFirstProbeQuery || !this.service.advertisesOnInterface(endpoint.interface)) {
return;
}
let containsAnswer = false;
// search answers and additionals for answers to our probe queries
for (const record of packet.answers.values()) {
if (record.getLowerCasedName() === this.service.getLowerCasedFQDN() || record.getLowerCasedName() === this.service.getLowerCasedHostname()) {
containsAnswer = true;
break;
}
}
for (const record of packet.additionals.values()) {
if (record.getLowerCasedName() === this.service.getLowerCasedFQDN() || record.getLowerCasedName() === this.service.getLowerCasedHostname()) {
containsAnswer = true;
break;
}
}
if (containsAnswer) { // abort and cancel probes
debug("Probing for '%s' failed. Doing a name change", this.service.getFQDN());
this.handleNameChange();
}
}
handleNameChange() {
this.endProbing(false); // reset the prober
this.service.serviceState = "unannounced" /* ServiceState.UNANNOUNCED */;
this.service.incrementName();
this.service.serviceState = "probing" /* ServiceState.PROBING */;
this.serviceEncounteredNameChange = true;
this.timer = setTimeout(this.sendProbeRequest.bind(this), 1000);
this.timer.unref();
}
handleQuery(packet, endpoint) {
if (!this.sentFirstProbeQuery || !this.service.advertisesOnInterface(endpoint.interface)) {
return;
}
// if we are currently probing and receiving a query which is also a probing query
// which matches the desired name we run the tiebreaking algorithm to decide on the winner
let needsTiebreaking = false;
for (const question of packet.questions.values()) {
if (question.getLowerCasedName() === this.service.getLowerCasedFQDN() || question.getLowerCasedName() === this.service.getLowerCasedHostname()) {
needsTiebreaking = true;
break;
}
}
if (needsTiebreaking) {
this.doTiebreaking(packet);
}
}
doTiebreaking(packet) {
if (!this.sentFirstProbeQuery) { // ignore queries if we are not sending
return;
}
// first of all check if the contents of authorities answers our query
let conflict = packet.authorities.size === 0;
for (const record of packet.authorities.values()) {
if (record.getLowerCasedName() === this.service.getLowerCasedFQDN() || record.getLowerCasedName() === this.service.getLowerCasedHostname()) {
conflict = true;
break;
}
}
if (!conflict) {
return;
}
// now run the actual tiebreaking algorithm to decide the winner
// tiebreaking is actually run pretty often, as we always receive our own packets
// first of all build our own records
const answers = this.records; // already sorted
const opponent = Array.from(packet.authorities.values()).sort(tiebreaking.rrComparator);
const result = tiebreaking.runTiebreaking(answers, opponent);
if (result === 1 /* TiebreakingResult.HOST */) {
debug("'%s' won the tiebreak. We gonna ignore the other probing request!", this.service.getFQDN());
}
else if (result === -1 /* TiebreakingResult.OPPONENT */) {
debug("'%s' lost the tiebreak. We are waiting a second and try to probe again...", this.service.getFQDN());
this.endProbing(false); // cancel the current probing
// wait 1 second and probe again (this is to guard against stale probe packets)
// If it wasn't a stale probe packet, the other host will correctly respond to our probe queries by then
this.timer = setTimeout(this.sendProbeRequest.bind(this), 1000);
this.timer.unref();
}
else {
//debug("Tiebreaking for '%s' detected exact same records on the network. There is actually no conflict!", this.service.getFQDN());
}
}
}
exports.Prober = Prober;
Prober.CANCEL_REASON = "CIAO PROBING CANCELLED";
//# sourceMappingURL=Prober.js.map