UNPKG

24.2 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3exports.ResolvedAdvertiser = exports.AvahiAdvertiser = exports.DBusInvokeError = exports.BonjourHAPAdvertiser = exports.CiaoAdvertiser = exports.AdvertiserEvent = exports.PairingFeatureFlag = exports.StatusFlag = void 0;
4const tslib_1 = require("tslib");
5// eslint-disable-next-line @typescript-eslint/triple-slash-reference
6/// <reference path="../../@types/bonjour-hap.d.ts" />
7const ciao_1 = tslib_1.__importDefault(require("@homebridge/ciao"));
8const dbus_native_1 = tslib_1.__importDefault(require("@homebridge/dbus-native"));
9const assert_1 = tslib_1.__importDefault(require("assert"));
10const bonjour_hap_1 = tslib_1.__importDefault(require("bonjour-hap"));
11const crypto_1 = tslib_1.__importDefault(require("crypto"));
12const debug_1 = tslib_1.__importDefault(require("debug"));
13const events_1 = require("events");
14const promise_utils_1 = require("./util/promise-utils");
15const debug = (0, debug_1.default)("HAP-NodeJS:Advertiser");
16/**
17 * This enum lists all bitmasks for all known status flags.
18 * When the bit for the given bitmask is set, it represents the state described by the name.
19 *
20 * @group Advertiser
21 */
22var StatusFlag;
23(function (StatusFlag) {
24 StatusFlag[StatusFlag["NOT_PAIRED"] = 1] = "NOT_PAIRED";
25 StatusFlag[StatusFlag["NOT_JOINED_WIFI"] = 2] = "NOT_JOINED_WIFI";
26 StatusFlag[StatusFlag["PROBLEM_DETECTED"] = 4] = "PROBLEM_DETECTED";
27})(StatusFlag || (exports.StatusFlag = StatusFlag = {}));
28/**
29 * This enum lists all bitmasks for all known pairing feature flags.
30 * When the bit for the given bitmask is set, it represents the state described by the name.
31 *
32 * @group Advertiser
33 */
34var PairingFeatureFlag;
35(function (PairingFeatureFlag) {
36 PairingFeatureFlag[PairingFeatureFlag["SUPPORTS_HARDWARE_AUTHENTICATION"] = 1] = "SUPPORTS_HARDWARE_AUTHENTICATION";
37 PairingFeatureFlag[PairingFeatureFlag["SUPPORTS_SOFTWARE_AUTHENTICATION"] = 2] = "SUPPORTS_SOFTWARE_AUTHENTICATION";
38})(PairingFeatureFlag || (exports.PairingFeatureFlag = PairingFeatureFlag = {}));
39/**
40 * @group Advertiser
41 */
42var AdvertiserEvent;
43(function (AdvertiserEvent) {
44 /**
45 * Emitted if the underlying mDNS advertisers signals, that the service name
46 * was automatically changed due to some naming conflicts on the network.
47 */
48 AdvertiserEvent["UPDATED_NAME"] = "updated-name";
49})(AdvertiserEvent || (exports.AdvertiserEvent = AdvertiserEvent = {}));
50/**
51 * Advertiser uses mdns to broadcast the presence of an Accessory to the local network.
52 *
53 * Note that as of iOS 9, an accessory can only pair with a single client. Instead of pairing your
54 * accessories with multiple iOS devices in your home, Apple intends for you to use Home Sharing.
55 * To support this requirement, we provide the ability to be "discoverable" or not (via a "service flag" on the
56 * mdns payload).
57 *
58 * @group Advertiser
59 */
60class CiaoAdvertiser extends events_1.EventEmitter {
61 static protocolVersion = "1.1";
62 static protocolVersionService = "1.1.0";
63 accessoryInfo;
64 setupHash;
65 responder;
66 advertisedService;
67 constructor(accessoryInfo, responderOptions, serviceOptions) {
68 super();
69 this.accessoryInfo = accessoryInfo;
70 this.setupHash = CiaoAdvertiser.computeSetupHash(accessoryInfo);
71 this.responder = ciao_1.default.getResponder({
72 ...responderOptions,
73 });
74 this.advertisedService = this.responder.createService({
75 name: this.accessoryInfo.displayName,
76 type: "hap" /* ServiceType.HAP */,
77 txt: CiaoAdvertiser.createTxt(accessoryInfo, this.setupHash),
78 // host will default now to <displayName>.local, spaces replaced with dashes
79 ...serviceOptions,
80 });
81 this.advertisedService.on("name-change" /* ServiceEvent.NAME_CHANGED */, this.emit.bind(this, "updated-name" /* AdvertiserEvent.UPDATED_NAME */));
82 debug(`Preparing Advertiser for '${this.accessoryInfo.displayName}' using ciao backend!`);
83 }
84 initPort(port) {
85 this.advertisedService.updatePort(port);
86 }
87 startAdvertising() {
88 debug(`Starting to advertise '${this.accessoryInfo.displayName}' using ciao backend!`);
89 return this.advertisedService.advertise();
90 }
91 updateAdvertisement(silent) {
92 const txt = CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash);
93 debug("Updating txt record (txt: %o, silent: %d)", txt, silent);
94 this.advertisedService.updateTxt(txt, silent);
95 }
96 async destroy() {
97 // advertisedService.destroy(); is called implicitly via the shutdown call
98 await this.responder.shutdown();
99 this.removeAllListeners();
100 }
101 static createTxt(accessoryInfo, setupHash) {
102 const statusFlags = [];
103 if (!accessoryInfo.paired()) {
104 statusFlags.push(1 /* StatusFlag.NOT_PAIRED */);
105 }
106 return {
107 "c#": accessoryInfo.getConfigVersion(), // current configuration number
108 ff: CiaoAdvertiser.ff(), // pairing feature flags
109 id: accessoryInfo.username, // device id
110 md: accessoryInfo.model, // model name
111 pv: CiaoAdvertiser.protocolVersion, // protocol version
112 "s#": 1, // current state number (must be 1)
113 sf: CiaoAdvertiser.sf(...statusFlags), // status flags
114 ci: accessoryInfo.category,
115 sh: setupHash,
116 };
117 }
118 static computeSetupHash(accessoryInfo) {
119 const hash = crypto_1.default.createHash("sha512");
120 hash.update(accessoryInfo.setupID + accessoryInfo.username.toUpperCase());
121 return hash.digest().slice(0, 4).toString("base64");
122 }
123 static ff(...flags) {
124 let value = 0;
125 flags.forEach(flag => value |= flag);
126 return value;
127 }
128 static sf(...flags) {
129 let value = 0;
130 flags.forEach(flag => value |= flag);
131 return value;
132 }
133}
134exports.CiaoAdvertiser = CiaoAdvertiser;
135/**
136 * Advertiser base on the legacy "bonjour-hap" library.
137 *
138 * @group Advertiser
139 */
140class BonjourHAPAdvertiser extends events_1.EventEmitter {
141 accessoryInfo;
142 setupHash;
143 serviceOptions;
144 bonjour;
145 advertisement;
146 port;
147 destroyed = false;
148 constructor(accessoryInfo, serviceOptions) {
149 super();
150 this.accessoryInfo = accessoryInfo;
151 this.setupHash = CiaoAdvertiser.computeSetupHash(accessoryInfo);
152 this.serviceOptions = serviceOptions;
153 this.bonjour = (0, bonjour_hap_1.default)();
154 debug(`Preparing Advertiser for '${this.accessoryInfo.displayName}' using bonjour-hap backend!`);
155 }
156 initPort(port) {
157 this.port = port;
158 }
159 startAdvertising() {
160 (0, assert_1.default)(!this.destroyed, "Can't advertise on a destroyed bonjour instance!");
161 if (this.port == null) {
162 throw new Error("Tried starting bonjour-hap advertisement without initializing port!");
163 }
164 debug(`Starting to advertise '${this.accessoryInfo.displayName}' using bonjour-hap backend!`);
165 if (this.advertisement) {
166 this.destroy();
167 }
168 const hostname = this.accessoryInfo.username.replace(/:/ig, "_") + ".local";
169 this.advertisement = this.bonjour.publish({
170 name: this.accessoryInfo.displayName,
171 type: "hap",
172 port: this.port,
173 txt: CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash),
174 host: hostname,
175 addUnsafeServiceEnumerationRecord: true,
176 ...this.serviceOptions,
177 });
178 return (0, promise_utils_1.PromiseTimeout)(1);
179 }
180 updateAdvertisement(silent) {
181 const txt = CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash);
182 debug("Updating txt record (txt: %o, silent: %d)", txt, silent);
183 if (this.advertisement) {
184 this.advertisement.updateTxt(txt, silent);
185 }
186 }
187 destroy() {
188 if (this.advertisement) {
189 this.advertisement.stop(() => {
190 this.advertisement.destroy();
191 this.advertisement = undefined;
192 this.bonjour.destroy();
193 });
194 }
195 else {
196 this.bonjour.destroy();
197 }
198 }
199}
200exports.BonjourHAPAdvertiser = BonjourHAPAdvertiser;
201function messageBusConnectionResult(bus) {
202 return new Promise((resolve, reject) => {
203 const errorHandler = (error) => {
204 // eslint-disable-next-line @typescript-eslint/no-use-before-define
205 bus.connection.removeListener("connect", connectHandler);
206 reject(error);
207 };
208 const connectHandler = () => {
209 bus.connection.removeListener("error", errorHandler);
210 resolve();
211 };
212 bus.connection.once("connect", connectHandler);
213 bus.connection.once("error", errorHandler);
214 });
215}
216/**
217 * @group Advertiser
218 */
219class DBusInvokeError extends Error {
220 errorName;
221 // eslint-disable-next-line @typescript-eslint/no-explicit-any
222 constructor(errorObject) {
223 super();
224 Object.setPrototypeOf(this, DBusInvokeError.prototype);
225 this.name = "DBusInvokeError";
226 this.errorName = errorObject.name;
227 if (Array.isArray(errorObject.message) && errorObject.message.length === 1) {
228 this.message = errorObject.message[0];
229 }
230 else {
231 this.message = errorObject.message.toString();
232 }
233 }
234}
235exports.DBusInvokeError = DBusInvokeError;
236// eslint-disable-next-line @typescript-eslint/no-explicit-any
237function dbusInvoke(bus, destination, path, dbusInterface, member, others) {
238 return new Promise((resolve, reject) => {
239 const command = {
240 destination,
241 path,
242 interface: dbusInterface,
243 member,
244 ...(others || {}),
245 };
246 bus.invoke(command, (err, result) => {
247 if (err) {
248 reject(new DBusInvokeError(err));
249 }
250 else {
251 resolve(result);
252 }
253 });
254 });
255}
256/**
257 * AvahiServerState.
258 *
259 * Refer to https://github.com/lathiat/avahi/blob/fd482a74625b8db8547b8cfca3ee3d3c6c721423/avahi-common/defs.h#L220-L227.
260 *
261 * @group Advertiser
262 */
263var AvahiServerState;
264(function (AvahiServerState) {
265 // noinspection JSUnusedGlobalSymbols
266 AvahiServerState[AvahiServerState["INVALID"] = 0] = "INVALID";
267 AvahiServerState[AvahiServerState["REGISTERING"] = 1] = "REGISTERING";
268 AvahiServerState[AvahiServerState["RUNNING"] = 2] = "RUNNING";
269 AvahiServerState[AvahiServerState["COLLISION"] = 3] = "COLLISION";
270 AvahiServerState[AvahiServerState["FAILURE"] = 4] = "FAILURE";
271})(AvahiServerState || (AvahiServerState = {}));
272/**
273 * Advertiser based on the Avahi D-Bus library.
274 * For (very crappy) docs on the interface, see the XML files at: https://github.com/lathiat/avahi/tree/master/avahi-daemon.
275 *
276 * Refer to https://github.com/lathiat/avahi/blob/fd482a74625b8db8547b8cfca3ee3d3c6c721423/avahi-common/defs.h#L120-L155 for a
277 * rough API usage guide of Avahi.
278 *
279 * @group Advertiser
280 */
281class AvahiAdvertiser extends events_1.EventEmitter {
282 accessoryInfo;
283 setupHash;
284 port;
285 bus;
286 avahiServerInterface;
287 path;
288 stateChangeHandler;
289 constructor(accessoryInfo) {
290 super();
291 this.accessoryInfo = accessoryInfo;
292 this.setupHash = CiaoAdvertiser.computeSetupHash(accessoryInfo);
293 debug(`Preparing Advertiser for '${this.accessoryInfo.displayName}' using Avahi backend!`);
294 this.bus = dbus_native_1.default.systemBus();
295 this.stateChangeHandler = this.handleStateChangedEvent.bind(this);
296 }
297 createTxt() {
298 return Object
299 .entries(CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash))
300 .map((el) => Buffer.from(el[0] + "=" + el[1]));
301 }
302 initPort(port) {
303 this.port = port;
304 }
305 async startAdvertising() {
306 if (this.port == null) {
307 throw new Error("Tried starting Avahi advertisement without initializing port!");
308 }
309 if (!this.bus) {
310 throw new Error("Tried to start Avahi advertisement on a destroyed advertiser!");
311 }
312 debug(`Starting to advertise '${this.accessoryInfo.displayName}' using Avahi backend!`);
313 this.path = await AvahiAdvertiser.avahiInvoke(this.bus, "/", "Server", "EntryGroupNew");
314 await AvahiAdvertiser.avahiInvoke(this.bus, this.path, "EntryGroup", "AddService", {
315 body: [
316 -1, // interface
317 -1, // protocol
318 0, // flags
319 this.accessoryInfo.displayName, // name
320 "_hap._tcp", // type
321 "", // domain
322 "", // host
323 this.port, // port
324 this.createTxt(), // txt
325 ],
326 signature: "iiussssqaay",
327 });
328 await AvahiAdvertiser.avahiInvoke(this.bus, this.path, "EntryGroup", "Commit");
329 try {
330 if (!this.avahiServerInterface) {
331 this.avahiServerInterface = await AvahiAdvertiser.avahiInterface(this.bus, "Server");
332 this.avahiServerInterface.on("StateChanged", this.stateChangeHandler);
333 }
334 }
335 catch (error) {
336 // We have some problem on Synology https://github.com/homebridge/HAP-NodeJS/issues/993
337 console.warn("Failed to create listener for avahi-daemon server state. The system will not be notified about restarts of avahi-daemon " +
338 "and will therefore stay undiscoverable in those instances. Error message: " + error);
339 if (error.stack) {
340 debug("Detailed error: " + error.stack);
341 }
342 }
343 }
344 /**
345 * Event handler for the `StateChanged` event of the `org.freedesktop.Avahi.Server` DBus interface.
346 *
347 * This is called once the state of the running avahi-daemon changes its running state.
348 * @param state - The state the server changed into {@see AvahiServerState}.
349 */
350 handleStateChangedEvent(state) {
351 if (state === 2 /* AvahiServerState.RUNNING */ && this.path) {
352 debug("Found Avahi daemon to have restarted!");
353 this.startAdvertising()
354 .catch(reason => console.error("Could not (re-)create mDNS advertisement. The HAP-Server won't be discoverable: " + reason));
355 }
356 }
357 async updateAdvertisement(silent) {
358 if (!this.bus) {
359 throw new Error("Tried to update Avahi advertisement on a destroyed advertiser!");
360 }
361 if (!this.path) {
362 debug("Tried to update advertisement without a valid `path`!");
363 return;
364 }
365 debug("Updating txt record (txt: %o, silent: %d)", CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash), silent);
366 try {
367 await AvahiAdvertiser.avahiInvoke(this.bus, this.path, "EntryGroup", "UpdateServiceTxt", {
368 body: [-1, -1, 0, this.accessoryInfo.displayName, "_hap._tcp", "", this.createTxt()],
369 signature: "iiusssaay",
370 });
371 }
372 catch (error) {
373 console.error("Failed to update avahi advertisement: " + error);
374 }
375 }
376 async destroy() {
377 if (!this.bus) {
378 throw new Error("Tried to destroy Avahi advertisement on a destroyed advertiser!");
379 }
380 if (this.path) {
381 try {
382 await AvahiAdvertiser.avahiInvoke(this.bus, this.path, "EntryGroup", "Free");
383 }
384 catch (error) {
385 // Typically, this fails if e.g. avahi service was stopped in the meantime.
386 debug("Destroying Avahi advertisement failed: " + error);
387 }
388 this.path = undefined;
389 }
390 if (this.avahiServerInterface) {
391 this.avahiServerInterface.removeListener("StateChanged", this.stateChangeHandler);
392 this.avahiServerInterface = undefined;
393 }
394 this.bus.connection.stream.destroy();
395 this.bus = undefined;
396 }
397 static async isAvailable() {
398 const bus = dbus_native_1.default.systemBus();
399 try {
400 try {
401 await messageBusConnectionResult(bus);
402 }
403 catch (error) {
404 debug("Avahi/DBus classified unavailable due to missing dbus interface!");
405 return false;
406 }
407 try {
408 const version = await this.avahiInvoke(bus, "/", "Server", "GetVersionString");
409 debug("Detected Avahi over DBus interface running version '%s'.", version);
410 }
411 catch (error) {
412 debug("Avahi/DBus classified unavailable due to missing avahi interface!");
413 return false;
414 }
415 return true;
416 }
417 finally {
418 bus.connection.stream.destroy();
419 }
420 }
421 // eslint-disable-next-line @typescript-eslint/no-explicit-any
422 static avahiInvoke(bus, path, dbusInterface, member, others) {
423 return dbusInvoke(bus, "org.freedesktop.Avahi", path, `org.freedesktop.Avahi.${dbusInterface}`, member, others);
424 }
425 static avahiInterface(bus, dbusInterface) {
426 return new Promise((resolve, reject) => {
427 bus
428 .getService("org.freedesktop.Avahi")
429 .getInterface("/", "org.freedesktop.Avahi." + dbusInterface, (error, iface) => {
430 if (error || !iface) {
431 reject(error ?? new Error("Interface not present!"));
432 }
433 else {
434 resolve(iface);
435 }
436 });
437 });
438 }
439}
440exports.AvahiAdvertiser = AvahiAdvertiser;
441const RESOLVED_PERMISSIONS_ERRORS = [
442 "org.freedesktop.DBus.Error.AccessDenied",
443 "org.freedesktop.DBus.Error.AuthFailed",
444 "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
445];
446/**
447 * Advertiser based on the systemd-resolved D-Bus library.
448 * For docs on the interface, see: https://www.freedesktop.org/software/systemd/man/org.freedesktop.resolve1.html
449 *
450 * @group Advertiser
451 */
452class ResolvedAdvertiser extends events_1.EventEmitter {
453 accessoryInfo;
454 setupHash;
455 port;
456 bus;
457 path;
458 constructor(accessoryInfo) {
459 super();
460 this.accessoryInfo = accessoryInfo;
461 this.setupHash = CiaoAdvertiser.computeSetupHash(accessoryInfo);
462 this.bus = dbus_native_1.default.systemBus();
463 debug(`Preparing Advertiser for '${this.accessoryInfo.displayName}' using systemd-resolved backend!`);
464 }
465 createTxt() {
466 return Object
467 .entries(CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash))
468 .map((el) => [el[0].toString(), Buffer.from(el[1].toString())]);
469 }
470 initPort(port) {
471 this.port = port;
472 }
473 async startAdvertising() {
474 if (this.port == null) {
475 throw new Error("Tried starting systemd-resolved advertisement without initializing port!");
476 }
477 if (!this.bus) {
478 throw new Error("Tried to start systemd-resolved advertisement on a destroyed advertiser!");
479 }
480 debug(`Starting to advertise '${this.accessoryInfo.displayName}' using systemd-resolved backend!`);
481 try {
482 this.path = await ResolvedAdvertiser.managerInvoke(this.bus, "RegisterService", {
483 body: [
484 this.accessoryInfo.displayName, // name
485 this.accessoryInfo.displayName, // name_template
486 "_hap._tcp", // type
487 this.port, // service_port
488 0, // service_priority
489 0, // service_weight
490 [this.createTxt()], // txt_datas
491 ],
492 signature: "sssqqqaa{say}",
493 });
494 }
495 catch (error) {
496 if (error instanceof DBusInvokeError) {
497 if (RESOLVED_PERMISSIONS_ERRORS.includes(error.errorName)) {
498 error.message = `Permissions issue. See https://homebridge.io/w/mDNS-Options for more info. ${error.message}`;
499 }
500 }
501 throw error;
502 }
503 }
504 async updateAdvertisement(silent) {
505 if (!this.bus) {
506 throw new Error("Tried to update systemd-resolved advertisement on a destroyed advertiser!");
507 }
508 debug("Updating txt record (txt: %o, silent: %d)", CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash), silent);
509 // Currently, systemd-resolved has no way to update an existing record.
510 await this.stopAdvertising();
511 await this.startAdvertising();
512 }
513 async stopAdvertising() {
514 if (!this.bus) {
515 throw new Error("Tried to destroy systemd-resolved advertisement on a destroyed advertiser!");
516 }
517 if (this.path) {
518 try {
519 await ResolvedAdvertiser.managerInvoke(this.bus, "UnregisterService", {
520 body: [this.path],
521 signature: "o",
522 });
523 }
524 catch (error) {
525 // Typically, this fails if e.g. systemd-resolved service was stopped in the meantime.
526 debug("Destroying systemd-resolved advertisement failed: " + error);
527 }
528 this.path = undefined;
529 }
530 }
531 async destroy() {
532 if (!this.bus) {
533 throw new Error("Tried to destroy systemd-resolved advertisement on a destroyed advertiser!");
534 }
535 await this.stopAdvertising();
536 this.bus.connection.stream.destroy();
537 this.bus = undefined;
538 }
539 static async isAvailable() {
540 const bus = dbus_native_1.default.systemBus();
541 try {
542 try {
543 await messageBusConnectionResult(bus);
544 }
545 catch (error) {
546 debug("systemd-resolved/DBus classified unavailable due to missing dbus interface!");
547 return false;
548 }
549 try {
550 // Ensure that systemd-resolved is accessible.
551 await this.managerInvoke(bus, "ResolveHostname", {
552 body: [0, "127.0.0.1", 0, 0],
553 signature: "isit",
554 });
555 debug("Detected systemd-resolved over DBus interface running version.");
556 }
557 catch (error) {
558 debug("systemd-resolved/DBus classified unavailable due to missing systemd-resolved interface!");
559 return false;
560 }
561 try {
562 const mdnsStatus = await this.resolvedInvoke(bus, "org.freedesktop.DBus.Properties", "Get", {
563 body: ["org.freedesktop.resolve1.Manager", "MulticastDNS"],
564 signature: "ss",
565 });
566 if (mdnsStatus[0][0].type !== "s") {
567 throw new Error("Invalid type for MulticastDNS");
568 }
569 if (mdnsStatus[1][0] !== "yes") {
570 debug("systemd-resolved/DBus classified unavailable because MulticastDNS is not enabled!");
571 return false;
572 }
573 }
574 catch (error) {
575 debug("systemd-resolved/DBus classified unavailable due to failure checking system status: " + error);
576 return false;
577 }
578 return true;
579 }
580 finally {
581 bus.connection.stream.destroy();
582 }
583 }
584 // eslint-disable-next-line @typescript-eslint/no-explicit-any
585 static resolvedInvoke(bus, dbusInterface, member, others) {
586 return dbusInvoke(bus, "org.freedesktop.resolve1", "/org/freedesktop/resolve1", dbusInterface, member, others);
587 }
588 // eslint-disable-next-line @typescript-eslint/no-explicit-any
589 static managerInvoke(bus, member, others) {
590 return this.resolvedInvoke(bus, "org.freedesktop.resolve1.Manager", member, others);
591 }
592}
593exports.ResolvedAdvertiser = ResolvedAdvertiser;
594//# sourceMappingURL=Advertiser.js.map
\No newline at end of file