From 734b1e03e86c81481aff09cc0515a18c0377e3a4 Mon Sep 17 00:00:00 2001 From: Eli Young Date: Sun, 30 Oct 2022 22:33:39 -0700 Subject: [PATCH] Add systemd-resolved mDNS advertiser support (#965) Co-authored-by: Andi --- src/lib/Accessory.ts | 16 +++- src/lib/Advertiser.ts | 212 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 198 insertions(+), 30 deletions(-) diff --git a/src/lib/Accessory.ts b/src/lib/Accessory.ts index cce90d22c..f42629f6c 100644 --- a/src/lib/Accessory.ts +++ b/src/lib/Accessory.ts @@ -29,7 +29,7 @@ import { VoidCallback, WithUUID, } from "../types"; -import { Advertiser, AdvertiserEvent, BonjourHAPAdvertiser, CiaoAdvertiser, AvahiAdvertiser } from "./Advertiser"; +import { Advertiser, AdvertiserEvent, BonjourHAPAdvertiser, CiaoAdvertiser, AvahiAdvertiser, ResolvedAdvertiser } from "./Advertiser"; // noinspection JSDeprecatedSymbols import { LegacyCameraSource, LegacyCameraSourceAdapter, StreamController } from "./camera"; import { @@ -281,6 +281,10 @@ export const enum MDNSAdvertiser { * Use Avahi/D-Bus as advertiser. */ AVAHI = "avahi", + /** + * Use systemd-resolved/D-Bus as advertiser. + */ + RESOLVED = "resolved", } export type AccessoryCharacteristicChange = ServiceCharacteristicChange & { @@ -1221,9 +1225,12 @@ export class Accessory extends EventEmitter { const parsed = Accessory.parseBindOption(info); let selectedAdvertiser = info.advertiser ?? MDNSAdvertiser.BONJOUR; - if (info.advertiser === MDNSAdvertiser.AVAHI && !await AvahiAdvertiser.isAvailable()) { + if ( + (info.advertiser === MDNSAdvertiser.AVAHI && !await AvahiAdvertiser.isAvailable()) || + (info.advertiser === MDNSAdvertiser.RESOLVED && !await ResolvedAdvertiser.isAvailable()) + ) { console.error( - `[${this.displayName}] The selected advertiser, "${MDNSAdvertiser.AVAHI}", isn't available on this platform. ` + + `[${this.displayName}] The selected advertiser, "${info.advertiser}", isn't available on this platform. ` + `Reverting to "${MDNSAdvertiser.BONJOUR}"`, ); selectedAdvertiser = MDNSAdvertiser.BONJOUR; @@ -1248,6 +1255,9 @@ export class Accessory extends EventEmitter { case MDNSAdvertiser.AVAHI: this._advertiser = new AvahiAdvertiser(this._accessoryInfo); break; + case MDNSAdvertiser.RESOLVED: + this._advertiser = new ResolvedAdvertiser(this._accessoryInfo); + break; default: throw new Error("Unsupported advertiser setting: '" + info.advertiser + "'"); } diff --git a/src/lib/Advertiser.ts b/src/lib/Advertiser.ts index 388595702..1ca50cb2a 100644 --- a/src/lib/Advertiser.ts +++ b/src/lib/Advertiser.ts @@ -264,6 +264,41 @@ export class BonjourHAPAdvertiser extends EventEmitter implements Advertiser { } +function messageBusConnectionResult(bus: MessageBus): Promise { + return new Promise((resolve, reject) => { + const errorHandler = (error: Error) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + bus.connection.removeListener("connect", connectHandler); + reject(error); + }; + const connectHandler = () => { + bus.connection.removeListener("error", errorHandler); + resolve(); + }; + + bus.connection.once("connect", connectHandler); + bus.connection.once("error", errorHandler); + }); +} + + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function dbusInvoke( bus: MessageBus, destination: string, path: string, dbusInterface: string, member: string, others?: any): Promise { + return new Promise((resolve, reject) => { + const command = { destination, path, interface: dbusInterface, member, ...(others || {}) }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bus.invoke(command, (err: any, result: any) => { + if (err) { + reject(new Error(`dbusInvoke error: ${JSON.stringify(err)}`)); + } else { + resolve(result); + } + }); + + }); +} + /** * Advertiser based on the Avahi D-Bus library. * For (very crappy) docs on the interface, see the XML files at: https://github.com/lathiat/avahi/tree/master/avahi-daemon. @@ -370,7 +405,7 @@ export class AvahiAdvertiser extends EventEmitter implements Advertiser { try { try { - await this.messageBusConnectionResult(bus); + await messageBusConnectionResult(bus); } catch (error) { debug("Avahi/DBus classified unavailable due to missing dbus interface!"); return false; @@ -390,35 +425,158 @@ export class AvahiAdvertiser extends EventEmitter implements Advertiser { } } - private static messageBusConnectionResult(bus: MessageBus): Promise { - return new Promise((resolve, reject) => { - const errorHandler = (error: Error) => { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - bus.connection.removeListener("connect", connectHandler); - reject(error); - }; - const connectHandler = () => { - bus.connection.removeListener("error", errorHandler); - resolve(); - }; - - bus.connection.once("connect", connectHandler); - bus.connection.once("error", errorHandler); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private static avahiInvoke(bus: MessageBus, path: string, dbusInterface: string, member: string, others?: any): Promise { + return dbusInvoke( + bus, + "org.freedesktop.Avahi", + path, + `org.freedesktop.Avahi.${dbusInterface}`, + member, + others, + ); + } +} + +type ResolvedServiceTxt = Array>; + +/** + * Advertiser based on the systemd-resolved D-Bus library. + * For docs on the interface, see: https://www.freedesktop.org/software/systemd/man/org.freedesktop.resolve1.html + */ +export class ResolvedAdvertiser extends EventEmitter implements Advertiser { + private readonly accessoryInfo: AccessoryInfo; + private readonly setupHash: string; + + private port?: number; + + private bus?: MessageBus; + private path?: string; + + constructor(accessoryInfo: AccessoryInfo) { + super(); + this.accessoryInfo = accessoryInfo; + this.setupHash = CiaoAdvertiser.computeSetupHash(accessoryInfo); + + this.bus = dbus.systemBus(); + + debug(`Preparing Advertiser for '${this.accessoryInfo.displayName}' using systemd-resolved backend!`); + } + + private createTxt(): ResolvedServiceTxt { + return Object + .entries(CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash)) + .map((el: Array) => [el[0].toString(), Buffer.from(el[1].toString())]); + } + + public initPort(port: number): void { + this.port = port; + } + + public async startAdvertising(): Promise { + if (this.port == null) { + throw new Error("Tried starting systemd-resolved advertisement without initializing port!"); + } + if (!this.bus) { + throw new Error("Tried to start systemd-resolved advertisement on a destroyed advertiser!"); + } + + debug(`Starting to advertise '${this.accessoryInfo.displayName}' using systemd-resolved backend!`); + + this.path = await ResolvedAdvertiser.resolvedInvoke(this.bus, "RegisterService", { + body: [ + this.accessoryInfo.displayName, // name + this.accessoryInfo.displayName, // name_template + "_hap._tcp", // type + this.port, // service_port + 0, // service_priority + 0, // service_weight + [this.createTxt()], // txt_datas + ], + signature: "sssqqqaa{say}", }); } + public async updateAdvertisement(silent?: boolean): Promise { + if (!this.bus) { + throw new Error("Tried to update systemd-resolved advertisement on a destroyed advertiser!"); + } + + debug("Updating txt record (txt: %o, silent: %d)", CiaoAdvertiser.createTxt(this.accessoryInfo, this.setupHash), silent); + + // Currently, systemd-resolved has no way to update an existing record. + await this.stopAdvertising(); + await this.startAdvertising(); + } + + private async stopAdvertising(): Promise { + if (!this.bus) { + throw new Error("Tried to destroy systemd-resolved advertisement on a destroyed advertiser!"); + } + + if (this.path) { + try { + await ResolvedAdvertiser.resolvedInvoke(this.bus, "UnregisterService", { + body: [this.path], + signature: "o", + }); + } catch (error) { + // Typically, this fails if e.g. systemd-resolved service was stopped in the meantime. + debug("Destroying systemd-resolved advertisement failed: " + error); + } + this.path = undefined; + } + } + + public async destroy(): Promise { + if (!this.bus) { + throw new Error("Tried to destroy systemd-resolved advertisement on a destroyed advertiser!"); + } + + await this.stopAdvertising(); + + this.bus.connection.stream.destroy(); + this.bus = undefined; + } + + public static async isAvailable(): Promise { + const bus = dbus.systemBus(); + + try { + try { + await messageBusConnectionResult(bus); + } catch (error) { + debug("systemd-resolved/DBus classified unavailable due to missing dbus interface!"); + return false; + } + + try { + // Ensure that systemd-resolved is accessible. + await this.resolvedInvoke(bus, "ResolveHostname", { + body: [0, "127.0.0.1", 0, 0], + signature: "isit", + }); + debug("Detected systemd-resolved over DBus interface running version."); + } catch (error) { + debug("systemd-resolved/DBus classified unavailable due to missing systemd-resolved interface!"); + return false; + } + + return true; + } finally { + bus.connection.stream.destroy(); + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any - private static avahiInvoke(bus: MessageBus, path: string, dbusInterface: string, member: string, others?: any): Promise { - return new Promise((resolve, reject) => { - const command = { destination: "org.freedesktop.Avahi", path, interface: "org.freedesktop.Avahi." + dbusInterface, member, ...(others || {}) }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - bus.invoke(command, (err: any, result: any) => { - if (err) { - reject(new Error(`avahiInvoke error: ${JSON.stringify(err)}`)); - } else { - resolve(result); - } - }); - }); + private static resolvedInvoke(bus: MessageBus, member: string, others?: any): Promise { + return dbusInvoke( + bus, + "org.freedesktop.resolve1", + "/org/freedesktop/resolve1", + "org.freedesktop.resolve1.Manager", + member, + others, + ); } }