Skip to content

Commit

Permalink
Add systemd-resolved mDNS advertiser support (#965)
Browse files Browse the repository at this point in the history
Co-authored-by: Andi <mail@anderl-bauer.de>
  • Loading branch information
elyscape and Supereg committed Nov 18, 2022
1 parent 35ad372 commit ced6042
Show file tree
Hide file tree
Showing 2 changed files with 198 additions and 30 deletions.
16 changes: 13 additions & 3 deletions src/lib/Accessory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 & {
Expand Down Expand Up @@ -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;
Expand All @@ -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 + "'");
}
Expand Down
212 changes: 185 additions & 27 deletions src/lib/Advertiser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,41 @@ export class BonjourHAPAdvertiser extends EventEmitter implements Advertiser {

}

function messageBusConnectionResult(bus: MessageBus): Promise<void> {
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<any> {
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.
Expand Down Expand Up @@ -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;
Expand All @@ -390,35 +425,158 @@ export class AvahiAdvertiser extends EventEmitter implements Advertiser {
}
}

private static messageBusConnectionResult(bus: MessageBus): Promise<void> {
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<any> {
return dbusInvoke(
bus,
"org.freedesktop.Avahi",
path,
`org.freedesktop.Avahi.${dbusInterface}`,
member,
others,
);
}
}

type ResolvedServiceTxt = Array<Array<string | Buffer>>;

/**
* 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<string>) => [el[0].toString(), Buffer.from(el[1].toString())]);
}

public initPort(port: number): void {
this.port = port;
}

public async startAdvertising(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<any> {
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<any> {
return dbusInvoke(
bus,
"org.freedesktop.resolve1",
"/org/freedesktop/resolve1",
"org.freedesktop.resolve1.Manager",
member,
others,
);
}
}

0 comments on commit ced6042

Please sign in to comment.