diff --git a/CHANGES.md b/CHANGES.md index 2f8e24550..ca5f53019 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # HAP-Java 2.0.10 * Add optional Mute characteristic to Doorbell service +* Add pluggable `HomekitAdvertiser` with native DNS-SD / Bonjour implementation (`DnssdHomekitAdvertiser`) so macOS can advertise `_hap._tcp` without binding UDP 5353 [#212](https://github.com/hap-java/HAP-Java/issues/212) (see [docs/NATIVE_DNSSD.md](docs/NATIVE_DNSSD.md)) +* Extract shared HAP TXT helpers (`HapDnsTxt`) with stable key order; JmDNS and native advertisers publish the same property set +* JNA (`net.java.dev.jna:jna`) is **optional** for `nativeDnssd()`; JmDNS-only consumers do not need it. Applications should choose `jmdns(...)` or `nativeDnssd(InetAddress)` explicitly (`auto(...)` is deprecated) +* `nativeDnssd(InetAddress)` scopes DNS-SD by interface index only and keeps SRV `host` null (system hostname). Passing an IP literal as host broke pairing (SRV target with no A record) # HAP-Java 2.0.9 * Fix Set Duration and Remaining Duration characteristics to advertise format `uint32` instead of `int` [#202](https://github.com/hap-java/HAP-Java/issues/202) diff --git a/README.md b/README.md index 6d1f88458..dc6300676 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,24 @@ Include HAP-Java in your project using maven: After that, check out the [Sample](https://github.com/hap-java/HAP-Java/tree/sample) and read the [Javadoc](https://hap-java.github.io/HAP-Java/apidocs/index.html) +Native DNS-SD / Bonjour (macOS) +========= + +JmDNS binds UDP **5353**. On macOS that port is owned by `mDNSResponder`, so dedicated JmDNS advertising can fail with “Address already in use” and Home never sees `_hap._tcp`. + +This build adds a native advertiser that registers through Bonjour without binding 5353. Choose the advertiser explicitly in your application (e.g. openHAB); declare optional JNA when using the native path: + +```java +import io.github.hapjava.server.HomekitAdvertisers; +import io.github.hapjava.server.impl.HomekitServer; + +// Scope native registration to the HAP bind address, or use jmdns(sharedJmDNS) to force JmDNS: +HomekitServer server = + new HomekitServer(bindAddress, 9124, HomekitAdvertisers.nativeDnssd(bindAddress)); +``` + +Full details: [docs/NATIVE_DNSSD.md](docs/NATIVE_DNSSD.md). + Supported HomeKit Accessories ========= diff --git a/docs/NATIVE_DNSSD.md b/docs/NATIVE_DNSSD.md new file mode 100644 index 000000000..95162b05c --- /dev/null +++ b/docs/NATIVE_DNSSD.md @@ -0,0 +1,139 @@ +# Native DNS-SD / Bonjour advertiser + +## Problem + +HAP-Java historically advertises `_hap._tcp` with **JmDNS**, which opens a multicast UDP socket on **port 5353**. + +On **macOS**, Apple’s `mDNSResponder` already owns `*:5353`. A second process (or a JVM) that tries to bind 5353 gets `Address already in use`, and Home / Controllers never see the accessory — even when the HAP TCP port (e.g. 9124) is listening. + +Typical symptoms on macOS: empty `dns-sd -B _hap._tcp` results while `lsof -iUDP:5353` only lists `mDNSResponder`. + +## Solution + +This library adds a pluggable **`HomekitAdvertiser`** and a native implementation **`DnssdHomekitAdvertiser`** that calls Apple’s DNS-SD API (`DNSServiceRegister`) via JNA: + +| Implementation | Class | Binds UDP 5353? | Typical platform | +| --- | --- | --- | --- | +| JmDNS (default / classic) | `JmdnsHomekitAdvertiser` | Yes (unless sharing an existing `JmDNS`) | Linux containers, Windows | +| Native DNS-SD | `DnssdHomekitAdvertiser` | **No** — registers through the system daemon | **macOS** (mDNSResponder), Linux with Avahi `libdns_sd` | + +TXT records (`sf`, `id`, `md`, `sh`, `c#`, `s#`, `ff`, `ci`, `pv`) are shared via `HapDnsTxt` so both advertisers publish the same HAP payload. + +**Policy belongs in the application** (e.g. openHAB): choose `jmdns(...)` or `nativeDnssd(...)` explicitly. Do not rely on HAP-Java to auto-pick. + +## Quick start + +Declare the optional JNA dependency if you use the native advertiser (JmDNS-only consumers can omit it): + +```xml + + net.java.dev.jna + jna + 5.14.0 + +``` + +```java +import io.github.hapjava.server.HomekitAdvertisers; +import io.github.hapjava.server.impl.HomekitServer; +import java.net.InetAddress; + +InetAddress bind = InetAddress.getByName("192.168.0.6"); + +// Prefer scoping native registration to the HAP bind interface (SRV host stays system default): +HomekitServer server = + new HomekitServer(bind, 9124, HomekitAdvertisers.nativeDnssd(bind)); + +// Or share an existing JmDNS (e.g. openHAB's): +// HomekitServer server = +// new HomekitServer(bind, 9124, HomekitAdvertisers.jmdns(sharedJmDNS)); +``` + +Check whether the native library **loads** (does not prove registration will succeed on the LAN): + +```java +if (HomekitAdvertisers.isNativeDnssdSupported()) { + // safe to construct nativeDnssd(...) +} +``` + +Existing constructors that take only `InetAddress` / `JmDNS` keep the previous JmDNS behaviour (backward compatible). + +## Verifying on macOS (manual) + +There is no automated live IT in CI (ubuntu runners lack `libdns_sd` / Bonjour). After starting a server with `HomekitAdvertisers.nativeDnssd(bind)`: + +```bash +dns-sd -B _hap._tcp local. +dns-sd -L "" _hap._tcp local. +# or +dns-sd -Z _hap._tcp local. +``` + +You should see the bridge/accessory instance and HAP TXT keys. No Java process should be listening on UDP 5353 (`lsof -iUDP:5353` should only show `mDNSResponder`). + +## API surface + +### `io.github.hapjava.server.HomekitAdvertiser` + +```text +advertise(label, category, mac, port, configurationIndex, setupId) +stop() +setDiscoverable(boolean) +setMac(String) +setConfigurationIndex(int) +``` + +### `io.github.hapjava.server.HomekitAdvertisers` + +| Factory | Behaviour | +| --- | --- | +| `jmdns(JmDNS)` | Wrap an existing JmDNS (force JmDNS / share openHAB’s instance) | +| `jmdns(InetAddress)` | Create dedicated JmDNS on that address | +| `nativeDnssd()` | Native Bonjour on **all** interfaces (`interfaceIndex = 0`, default host) | +| `nativeDnssd(InetAddress)` | Native Bonjour on that address’s interface index; SRV host = system default (`null`) | +| `isNativeDnssdSupported()` | Whether JNA + dns_sd **load** (not a live register probe) | +| `auto(InetAddress)` | **Deprecated** — prefer an explicit `jmdns` / `nativeDnssd` choice | + +### `HomekitServer` constructors + +```java +new HomekitServer(InetAddress localAddress, int port, HomekitAdvertiser advertiser) +new HomekitServer(InetAddress localAddress, int port, int nThreads, HomekitAdvertiser advertiser) +``` + +When an advertiser is supplied, `createBridge` / `createStandaloneAccessory` use it and do **not** open a private JmDNS socket. + +## Implementation notes + +* **JNA** is an **optional** dependency. It loads `System` on macOS/Darwin and `dns_sd` elsewhere (Avahi Bonjour compatibility). Missing JNA surfaces as `LinkageError` and `isNativeDnssdSupported()` returns `false`. +* `DNSServiceRegister` receives the TCP port in **network byte order**. +* Default `nativeDnssd()` uses `interfaceIndex = 0` (all interfaces) and `host = null` (system hostname). Prefer `nativeDnssd(InetAddress)` to limit the interface index when the HAP TCP listener is bound to one LAN address — still with `host = null`. Never pass a raw IP as DNS-SD `host` (no A record → Home hangs on “Setting up”). +* TXT is RFC 6763 length-prefixed `key=value` bytes (`HapDnsTxt.encodeTxt`). Key insertion order is stable (`LinkedHashMap`); RFC 6763 does not require a particular pair order. +* A daemon thread runs `DNSServiceProcessResult` until `DNSServiceRefDeallocate` on `stop()` / re-register. +* Re-registration (discoverability, MAC, `c#`) deallocates and registers again — same pattern as the JmDNS advertiser. + +## Tests + +| Test | Purpose | +| --- | --- | +| `HapDnsTxtTest` | Full TXT key set, stable order, wire encoding | +| `DnssdHomekitAdvertiserTest` | Mocked JNA register / interface+host / re-register / stop / port endianness | +| `JmdnsHomekitAdvertiserTest` | JmDNS register including full HAP property set | + +```bash +mvn test +``` + +## Troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| `Address already in use` / “no suitable mDNS” | Still using dedicated JmDNS against mDNSResponder — switch to `nativeDnssd(bind)` or share an existing JmDNS | +| `UnsupportedOperationException: Native DNS-SD` | No JNA on classpath, or no `libSystem`/`libdns_sd` on this JVM/OS | +| `DNSServiceRegister failed with error code …` | Bad name/type, permissions, or daemon unavailable — check Console / `mDNSResponder` | +| Browse empty after register / too many interfaces | Prefer `nativeDnssd(localAddress)` instead of all-interfaces `nativeDnssd()` | + +## License + +Same MIT license as HAP-Java. Native symbols are provided by the host OS; this library only calls them through JNA. diff --git a/pom.xml b/pom.xml index a1a952779..c1d6d5816 100644 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,16 @@ 3.5.6 + + + net.java.dev.jna + jna + 5.14.0 + true + + junit junit diff --git a/src/main/java/io/github/hapjava/server/HomekitAdvertiser.java b/src/main/java/io/github/hapjava/server/HomekitAdvertiser.java new file mode 100644 index 000000000..e04f8cec7 --- /dev/null +++ b/src/main/java/io/github/hapjava/server/HomekitAdvertiser.java @@ -0,0 +1,45 @@ +package io.github.hapjava.server; + +import java.io.IOException; + +/** + * Publishes a HomeKit accessory / bridge via DNS-SD ({@code _hap._tcp}). + * + *

The default implementation uses JmDNS and binds UDP port 5353. On platforms where a system + * mDNS responder already owns that port (notably macOS {@code mDNSResponder}), use {@link + * io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser} instead, which registers through the + * native Bonjour / DNS-SD API without binding 5353. + * + * @see io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser + * @see io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser + * @see HomekitAdvertisers + * @author Karel Goderis + */ +public interface HomekitAdvertiser { + + /** + * Start advertising the accessory. + * + * @param label instance name shown in Home / Discovery + * @param category HomeKit accessory category ({@code ci} TXT key) + * @param mac accessory id ({@code id} TXT key), typically a MAC-like string + * @param port HAP TCP listen port + * @param configurationIndex configuration number ({@code c#} TXT key) + * @param setupId setup id used for the {@code sh} TXT hash + */ + void advertise( + String label, int category, String mac, int port, int configurationIndex, String setupId) + throws Exception; + + /** Stop advertising and release native resources. */ + void stop(); + + /** Update discoverability ({@code sf} TXT). Re-registers when already advertising. */ + void setDiscoverable(boolean discoverable) throws IOException; + + /** Update accessory id ({@code id} TXT). Re-registers when already advertising. */ + void setMac(String mac) throws IOException; + + /** Update configuration index ({@code c#} TXT). Re-registers when already advertising. */ + void setConfigurationIndex(int revision) throws IOException; +} diff --git a/src/main/java/io/github/hapjava/server/HomekitAdvertisers.java b/src/main/java/io/github/hapjava/server/HomekitAdvertisers.java new file mode 100644 index 000000000..28c3792e7 --- /dev/null +++ b/src/main/java/io/github/hapjava/server/HomekitAdvertisers.java @@ -0,0 +1,122 @@ +package io.github.hapjava.server; + +import io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser; +import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser; +import java.io.IOException; +import java.net.InetAddress; +import javax.jmdns.JmDNS; + +/** + * Factories for {@link HomekitAdvertiser} implementations. + * + *

Callers (e.g. openHAB) should choose the advertiser explicitly: use {@link #jmdns(JmDNS)} to + * share an existing JmDNS instance, {@link #jmdns(InetAddress)} for a dedicated JmDNS socket, or + * {@link #nativeDnssd()} / {@link #nativeDnssd(InetAddress)} for Bonjour without binding UDP 5353. + * Do not rely on HAP-Java to pick a policy automatically. + * + *

{@code
+ * // Force JmDNS (e.g. share openHAB's instance):
+ * HomekitAdvertiser adv = HomekitAdvertisers.jmdns(sharedJmDNS);
+ *
+ * // Native DNS-SD on the interface that owns the HAP bind address (SRV host = system default):
+ * HomekitAdvertiser adv = HomekitAdvertisers.nativeDnssd(InetAddress.getByName("192.168.0.6"));
+ * }
+ * + * @author Karel Goderis + */ +public final class HomekitAdvertisers { + + private HomekitAdvertisers() {} + + /** + * Classic JmDNS advertiser (binds UDP 5353 unless sharing an existing {@link JmDNS}). + * + * @param jmdns existing JmDNS instance to register with + * @return a JmDNS-backed advertiser + */ + public static HomekitAdvertiser jmdns(JmDNS jmdns) { + return new JmdnsHomekitAdvertiser(jmdns); + } + + /** + * Classic JmDNS advertiser that creates its own multicast socket on {@code localAddress}. + * + * @param localAddress address used to create the JmDNS instance + * @return a JmDNS-backed advertiser + * @throws IOException if JmDNS cannot be created on {@code localAddress} + */ + public static HomekitAdvertiser jmdns(InetAddress localAddress) throws IOException { + return new JmdnsHomekitAdvertiser(localAddress); + } + + /** + * Native DNS-SD / Bonjour advertiser on all interfaces ({@code interfaceIndex = + * 0}, default host). Prefer {@link #nativeDnssd(InetAddress)} when the HAP TCP listener is bound + * to a single LAN address. + * + *

Requires the optional JNA dependency on the classpath. + * + * @return a native DNS-SD advertiser + * @throws UnsupportedOperationException if JNA or the native library cannot be loaded + */ + public static HomekitAdvertiser nativeDnssd() { + if (!DnssdHomekitAdvertiser.isSupported()) { + throw new UnsupportedOperationException( + "Native DNS-SD (dns_sd / Bonjour) is not available on this platform/JVM"); + } + return new DnssdHomekitAdvertiser(); + } + + /** + * Native DNS-SD / Bonjour advertiser scoped to the interface that owns {@code localAddress}. + * Registration uses that interface index when resolvable; SRV host stays {@code null} (system + * hostname). Passing an IP literal as DNS-SD host is incorrect — it yields an SRV target with no + * A record and breaks Home pairing. + * + * @param localAddress LAN address the HAP TCP server listens on + * @return a native DNS-SD advertiser + * @throws UnsupportedOperationException if JNA or the native library cannot be loaded + */ + public static HomekitAdvertiser nativeDnssd(InetAddress localAddress) { + if (!DnssdHomekitAdvertiser.isSupported()) { + throw new UnsupportedOperationException( + "Native DNS-SD (dns_sd / Bonjour) is not available on this platform/JVM"); + } + return new DnssdHomekitAdvertiser(localAddress); + } + + /** + * Convenience: prefer native DNS-SD when the library loads, otherwise JmDNS on {@code + * localAddress}. + * + *

Applications should prefer an explicit {@link #jmdns(JmDNS)} / {@link + * #nativeDnssd(InetAddress)} choice (policy belongs in openHAB / the integrator). This + * helper only probes whether the native library loads; it does not verify that + * registration will succeed on the network. + * + * @param localAddress address used for native scoping or JmDNS fallback + * @return a native DNS-SD advertiser when the library loads, otherwise a JmDNS-backed advertiser + * @throws IOException if native DNS-SD is unavailable and JmDNS cannot be created + * @deprecated Prefer {@link #jmdns(JmDNS)}, {@link #jmdns(InetAddress)}, or {@link + * #nativeDnssd(InetAddress)} so the application chooses the advertiser. + */ + @Deprecated + public static HomekitAdvertiser auto(InetAddress localAddress) throws IOException { + if (DnssdHomekitAdvertiser.isSupported()) { + return new DnssdHomekitAdvertiser(localAddress); + } + return new JmdnsHomekitAdvertiser(localAddress); + } + + /** + * Whether {@link #nativeDnssd()} can be constructed on this JVM/OS. + * + *

Only checks that JNA and the platform DNS-SD library load. It does not register a + * service or prove mDNS is reachable on the LAN. + * + * @return {@code true} if the platform DNS-SD library loaded successfully + */ + public static boolean isNativeDnssdSupported() { + return DnssdHomekitAdvertiser.isSupported(); + } +} diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java b/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java index 36bfeefa2..1721be95f 100644 --- a/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java +++ b/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java @@ -3,6 +3,7 @@ import io.github.hapjava.accessories.Bridge; import io.github.hapjava.accessories.HomekitAccessory; import io.github.hapjava.server.HomekitAccessoryCategories; +import io.github.hapjava.server.HomekitAdvertiser; import io.github.hapjava.server.HomekitAuthInfo; import io.github.hapjava.server.HomekitWebHandler; import io.github.hapjava.server.impl.connections.HomekitClientConnectionFactoryImpl; @@ -27,7 +28,7 @@ public class HomekitRoot { private static final Logger logger = LoggerFactory.getLogger(HomekitRoot.class); private static final int DEFAULT_ACCESSORY_CATEGORY = HomekitAccessoryCategories.OTHER; - private final JmdnsHomekitAdvertiser advertiser; + private final HomekitAdvertiser advertiser; private final HomekitWebHandler webHandler; private final HomekitAuthInfo authInfo; private final String label; @@ -60,7 +61,7 @@ public class HomekitRoot { int category, HomekitWebHandler webHandler, HomekitAuthInfo authInfo, - JmdnsHomekitAdvertiser advertiser) + HomekitAdvertiser advertiser) throws IOException { this.advertiser = advertiser; this.webHandler = webHandler; diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitServer.java b/src/main/java/io/github/hapjava/server/impl/HomekitServer.java index 7728896a2..e16c3518b 100644 --- a/src/main/java/io/github/hapjava/server/impl/HomekitServer.java +++ b/src/main/java/io/github/hapjava/server/impl/HomekitServer.java @@ -1,6 +1,7 @@ package io.github.hapjava.server.impl; import io.github.hapjava.accessories.HomekitAccessory; +import io.github.hapjava.server.HomekitAdvertiser; import io.github.hapjava.server.HomekitAuthInfo; import io.github.hapjava.server.impl.http.impl.HomekitHttpServer; import java.io.IOException; @@ -23,6 +24,9 @@ * same values on each start of your application or HomeKit will fail to recognize your device as * being the same. * + *

On macOS, prefer constructing with an explicit {@link HomekitAdvertiser} from {@link + * io.github.hapjava.server.HomekitAdvertisers#nativeDnssd()} so advertising does not bind UDP 5353. + * * @author Andy Lintner */ public class HomekitServer { @@ -30,12 +34,15 @@ public class HomekitServer { private final HomekitHttpServer http; private final InetAddress localAddress; private final JmDNS jmdns; + private final HomekitAdvertiser advertiser; /** * Constructor. Contains an argument indicating the number of threads to use in the http server. * The other constructors default this to the number of available processors, however you may * increase this in an environment with many users and/or blocking accessory implementations. * + *

Advertising uses a dedicated JmDNS instance (binds UDP 5353). + * * @param localAddress local address to bind to. * @param port local port to bind to. * @param nThreads number of threads to use in the http server @@ -44,6 +51,28 @@ public class HomekitServer { public HomekitServer(InetAddress localAddress, int port, int nThreads) throws IOException { this.localAddress = localAddress; this.jmdns = null; + this.advertiser = null; + http = new HomekitHttpServer(localAddress, port, nThreads); + } + + /** + * Constructor that uses a caller-supplied advertiser (e.g. native DNS-SD) and does not create + * JmDNS. + * + * @param localAddress local address for the HAP HTTP server + * @param port local TCP port + * @param nThreads HTTP worker threads + * @param advertiser mDNS / DNS-SD advertiser (must not be null) + */ + public HomekitServer( + InetAddress localAddress, int port, int nThreads, HomekitAdvertiser advertiser) + throws IOException { + if (advertiser == null) { + throw new IllegalArgumentException("advertiser must not be null"); + } + this.localAddress = localAddress; + this.jmdns = null; + this.advertiser = advertiser; http = new HomekitHttpServer(localAddress, port, nThreads); } @@ -58,6 +87,7 @@ public HomekitServer(InetAddress localAddress, int port, int nThreads) throws IO public HomekitServer(JmDNS jmdns, int port, int nThreads) throws IOException { this.jmdns = jmdns; this.localAddress = null; + this.advertiser = null; http = new HomekitHttpServer(jmdns.getInetAddress(), port, nThreads); } @@ -72,6 +102,15 @@ public HomekitServer(InetAddress localAddress, int port) throws IOException { this(localAddress, port, Runtime.getRuntime().availableProcessors()); } + /** + * HAP HTTP on {@code localAddress}:{@code port} with an explicit advertiser (native DNSSD or + * shared JmDNS wrapper). + */ + public HomekitServer(InetAddress localAddress, int port, HomekitAdvertiser advertiser) + throws IOException { + this(localAddress, port, Runtime.getRuntime().availableProcessors(), advertiser); + } + /** * Constructor * @@ -111,7 +150,10 @@ public void stop() { public HomekitStandaloneAccessoryServer createStandaloneAccessory( HomekitAuthInfo authInfo, HomekitAccessory accessory) throws IOException, ExecutionException, InterruptedException { - if (jmdns != null) { + if (advertiser != null) { + return new HomekitStandaloneAccessoryServer( + accessory, http, localAddress, authInfo, advertiser); + } else if (jmdns != null) { return new HomekitStandaloneAccessoryServer(accessory, http, jmdns, authInfo); } else { return new HomekitStandaloneAccessoryServer(accessory, http, localAddress, authInfo); @@ -121,7 +163,10 @@ public HomekitStandaloneAccessoryServer createStandaloneAccessory( public HomekitStandaloneAccessoryServer createStandaloneAccessory( HomekitAuthInfo authInfo, HomekitAccessory accessory, int category) throws IOException, ExecutionException, InterruptedException { - if (jmdns != null) { + if (advertiser != null) { + return new HomekitStandaloneAccessoryServer( + accessory, http, localAddress, authInfo, category, advertiser); + } else if (jmdns != null) { return new HomekitStandaloneAccessoryServer(accessory, http, jmdns, authInfo, category); } else { return new HomekitStandaloneAccessoryServer( @@ -158,7 +203,9 @@ public HomekitRoot createBridge( String hardwareRevision) throws IOException { HomekitRoot root; - if (jmdns != null) { + if (advertiser != null) { + root = new HomekitRoot(label, category, http, authInfo, advertiser); + } else if (jmdns != null) { root = new HomekitRoot(label, category, http, jmdns, authInfo); } else { root = new HomekitRoot(label, category, http, localAddress, authInfo); diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java b/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java index 4a5dbabee..c0fc23b95 100644 --- a/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java +++ b/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java @@ -1,6 +1,8 @@ package io.github.hapjava.server.impl; import io.github.hapjava.accessories.HomekitAccessory; +import io.github.hapjava.server.HomekitAccessoryCategories; +import io.github.hapjava.server.HomekitAdvertiser; import io.github.hapjava.server.HomekitAuthInfo; import io.github.hapjava.server.HomekitWebHandler; import java.io.IOException; @@ -63,6 +65,35 @@ public class HomekitStandaloneAccessoryServer { root.addAccessory(accessory); } + HomekitStandaloneAccessoryServer( + HomekitAccessory accessory, + HomekitWebHandler webHandler, + InetAddress localhost, + HomekitAuthInfo authInfo, + HomekitAdvertiser advertiser) + throws UnknownHostException, IOException, ExecutionException, InterruptedException { + root = + new HomekitRoot( + accessory.getName().get(), + HomekitAccessoryCategories.OTHER, + webHandler, + authInfo, + advertiser); + root.addAccessory(accessory); + } + + HomekitStandaloneAccessoryServer( + HomekitAccessory accessory, + HomekitWebHandler webHandler, + InetAddress localhost, + HomekitAuthInfo authInfo, + int category, + HomekitAdvertiser advertiser) + throws UnknownHostException, IOException, ExecutionException, InterruptedException { + root = new HomekitRoot(accessory.getName().get(), category, webHandler, authInfo, advertiser); + root.addAccessory(accessory); + } + /** Begins advertising and handling requests for this accessory. */ public void start() { root.start(); diff --git a/src/main/java/io/github/hapjava/server/impl/advertiser/HapDnsTxt.java b/src/main/java/io/github/hapjava/server/impl/advertiser/HapDnsTxt.java new file mode 100644 index 000000000..632f23498 --- /dev/null +++ b/src/main/java/io/github/hapjava/server/impl/advertiser/HapDnsTxt.java @@ -0,0 +1,81 @@ +package io.github.hapjava.server.impl.advertiser; + +import static io.github.hapjava.server.impl.crypto.HAPSetupCodeUtils.generateSHA512Hash; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Shared HAP DNS-SD TXT record helpers for JmDNS and native DNSSD advertisers. + * + * @author Karel Goderis + */ +public final class HapDnsTxt { + + public static final String SERVICE_TYPE = "_hap._tcp"; + public static final String SERVICE_TYPE_LOCAL = "_hap._tcp.local."; + public static final String DOMAIN_LOCAL = "local."; + + private HapDnsTxt() {} + + /** + * Build the standard HAP TXT key/value map. + * + * @param discoverable whether the accessory is unpaired / discoverable ({@code sf}) + * @param mac accessory id ({@code id}) + * @param label model / display name ({@code md}) + * @param setupId setup id used to compute {@code sh} + * @param configurationIndex configuration number ({@code c#}) + * @param category accessory category ({@code ci}) + * @return ordered TXT properties suitable for JmDNS or {@link #encodeTxt(Map)} + */ + public static Map buildProperties( + boolean discoverable, + String mac, + String label, + String setupId, + int configurationIndex, + int category) { + Map props = new LinkedHashMap<>(); + props.put("sf", discoverable ? "1" : "0"); + props.put("id", mac); + props.put("md", label); + props.put("sh", generateSHA512Hash(setupId + mac)); + props.put("c#", Integer.toString(configurationIndex)); + props.put("s#", "1"); + props.put("ff", "0"); + props.put("ci", Integer.toString(category)); + props.put("pv", "1.1"); + return props; + } + + /** + * Encode TXT as DNS-SD wire format: a sequence of length-prefixed {@code key=value} strings (RFC + * 6763). + * + * @param props HAP TXT key/value pairs + * @return DNS-SD TXT RDATA bytes + * @throws IllegalArgumentException if any encoded pair exceeds 255 bytes + */ + public static byte[] encodeTxt(Map props) { + int size = 0; + for (Map.Entry e : props.entrySet()) { + String pair = e.getKey() + "=" + e.getValue(); + byte[] bytes = pair.getBytes(StandardCharsets.UTF_8); + if (bytes.length > 255) { + throw new IllegalArgumentException("TXT pair too long: " + e.getKey()); + } + size += 1 + bytes.length; + } + byte[] out = new byte[size]; + int offset = 0; + for (Map.Entry e : props.entrySet()) { + byte[] bytes = (e.getKey() + "=" + e.getValue()).getBytes(StandardCharsets.UTF_8); + out[offset++] = (byte) bytes.length; + System.arraycopy(bytes, 0, out, offset, bytes.length); + offset += bytes.length; + } + return out; + } +} diff --git a/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java b/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java index 759506722..f302b5051 100644 --- a/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java +++ b/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java @@ -1,5 +1,6 @@ package io.github.hapjava.server.impl.connections; +import io.github.hapjava.server.HomekitAdvertiser; import io.github.hapjava.server.HomekitAuthInfo; import io.github.hapjava.server.impl.HomekitRegistry; import io.github.hapjava.server.impl.crypto.ChachaDecoder; @@ -7,7 +8,6 @@ import io.github.hapjava.server.impl.http.HomekitClientConnection; import io.github.hapjava.server.impl.http.HttpRequest; import io.github.hapjava.server.impl.http.HttpResponse; -import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser; import io.github.hapjava.server.impl.pairing.UpgradeResponse; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -39,7 +39,7 @@ public ConnectionImpl( HomekitRegistry registry, Consumer outOfBandMessageCallback, SubscriptionManager subscriptions, - JmdnsHomekitAdvertiser advertiser) { + HomekitAdvertiser advertiser) { httpSession = new HttpSession(authInfo, registry, subscriptions, this, advertiser); this.outOfBandMessageCallback = outOfBandMessageCallback; this.subscriptions = subscriptions; diff --git a/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java b/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java index 9c8ad9fc0..f23c79d08 100644 --- a/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java +++ b/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java @@ -1,11 +1,11 @@ package io.github.hapjava.server.impl.connections; +import io.github.hapjava.server.HomekitAdvertiser; import io.github.hapjava.server.HomekitAuthInfo; import io.github.hapjava.server.impl.HomekitRegistry; import io.github.hapjava.server.impl.http.HomekitClientConnection; import io.github.hapjava.server.impl.http.HomekitClientConnectionFactory; import io.github.hapjava.server.impl.http.HttpResponse; -import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser; import java.util.function.Consumer; public class HomekitClientConnectionFactoryImpl implements HomekitClientConnectionFactory { @@ -13,13 +13,13 @@ public class HomekitClientConnectionFactoryImpl implements HomekitClientConnecti private final HomekitAuthInfo authInfo; private final HomekitRegistry registry; private final SubscriptionManager subscriptions; - private final JmdnsHomekitAdvertiser advertiser; + private final HomekitAdvertiser advertiser; public HomekitClientConnectionFactoryImpl( HomekitAuthInfo authInfo, HomekitRegistry registry, SubscriptionManager subscriptions, - JmdnsHomekitAdvertiser advertiser) { + HomekitAdvertiser advertiser) { this.registry = registry; this.authInfo = authInfo; this.subscriptions = subscriptions; diff --git a/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java b/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java index f1c97c62c..66dbc24eb 100644 --- a/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java +++ b/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java @@ -1,12 +1,12 @@ package io.github.hapjava.server.impl.connections; import io.github.hapjava.accessories.HomekitAccessory; +import io.github.hapjava.server.HomekitAdvertiser; import io.github.hapjava.server.HomekitAuthInfo; import io.github.hapjava.server.impl.HomekitRegistry; import io.github.hapjava.server.impl.http.HomekitClientConnection; import io.github.hapjava.server.impl.http.HttpRequest; import io.github.hapjava.server.impl.http.HttpResponse; -import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser; import io.github.hapjava.server.impl.json.AccessoryController; import io.github.hapjava.server.impl.json.CharacteristicsController; import io.github.hapjava.server.impl.pairing.PairSetupManager; @@ -30,7 +30,7 @@ class HttpSession { private final HomekitRegistry registry; private final SubscriptionManager subscriptions; private final HomekitClientConnection connection; - private final JmdnsHomekitAdvertiser advertiser; + private final HomekitAdvertiser advertiser; private static final Logger logger = LoggerFactory.getLogger(HttpSession.class); @@ -39,7 +39,7 @@ public HttpSession( HomekitRegistry registry, SubscriptionManager subscriptions, HomekitClientConnection connection, - JmdnsHomekitAdvertiser advertiser) { + HomekitAdvertiser advertiser) { this.authInfo = authInfo; this.registry = registry; this.subscriptions = subscriptions; diff --git a/src/main/java/io/github/hapjava/server/impl/dnssd/DnsSdLibrary.java b/src/main/java/io/github/hapjava/server/impl/dnssd/DnsSdLibrary.java new file mode 100644 index 000000000..4bb718f56 --- /dev/null +++ b/src/main/java/io/github/hapjava/server/impl/dnssd/DnsSdLibrary.java @@ -0,0 +1,49 @@ +package io.github.hapjava.server.impl.dnssd; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; + +/** + * Minimal JNA mapping of Apple's {@code dns_sd.h} / Avahi compat {@code DNSServiceRegister} API. + * + *

On macOS the symbols live in {@code libSystem}; on Linux with Avahi they typically live in + * {@code libdns_sd}. + * + * @author Karel Goderis + */ +public interface DnsSdLibrary extends Library { + + int kDNSServiceErr_NoError = 0; + int kDNSServiceFlagsNoAutoRename = 0; // default flags = 0 + + int DNSServiceRegister( + PointerByReference sdRef, + int flags, + int interfaceIndex, + String name, + String regtype, + String domain, + String host, + short port, // network byte order + short txtLen, + byte[] txtRecord, + Pointer callBack, // may be null + Pointer context); + + void DNSServiceRefDeallocate(Pointer sdRef); + + int DNSServiceRefSockFD(Pointer sdRef); + + int DNSServiceProcessResult(Pointer sdRef); + + static DnsSdLibrary load() { + String os = System.getProperty("os.name", "").toLowerCase(); + if (os.contains("mac") || os.contains("darwin")) { + return Native.load("System", DnsSdLibrary.class); + } + // Avahi Bonjour compatibility library + return Native.load("dns_sd", DnsSdLibrary.class); + } +} diff --git a/src/main/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiser.java b/src/main/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiser.java new file mode 100644 index 000000000..fddb7d025 --- /dev/null +++ b/src/main/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiser.java @@ -0,0 +1,322 @@ +package io.github.hapjava.server.impl.dnssd; + +import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import io.github.hapjava.server.HomekitAdvertiser; +import io.github.hapjava.server.impl.advertiser.HapDnsTxt; +import java.io.IOException; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * HomeKit advertiser that registers {@code _hap._tcp} through the platform DNS-SD API (macOS + * mDNSResponder / Avahi dns_sd compat) without binding UDP 5353. + * + *

Uses JNA to call {@code DNSServiceRegister} / {@code DNSServiceRefDeallocate}. + * + *

Pass an {@link InetAddress} to scope registration to one LAN interface; SRV host + * stays {@code null} so mDNSResponder advertises the system hostname (with A/AAAA). Passing a raw + * IP as DNS-SD {@code host} breaks pairing — the daemon does not synthesize an A record for that + * name. The no-arg constructor registers on all interfaces ({@code interfaceIndex = 0}). + * + * @author Karel Goderis + */ +public class DnssdHomekitAdvertiser implements HomekitAdvertiser { + + private static final Logger logger = LoggerFactory.getLogger(DnssdHomekitAdvertiser.class); + + /** DNS-SD: register on all interfaces. */ + public static final int INTERFACE_INDEX_ANY = 0; + + private static final boolean SUPPORTED = detectSupport(); + + private final DnsSdLibrary dnsSd; + private final int interfaceIndex; + private final String host; + private final Object lock = new Object(); + + private boolean discoverable = true; + private boolean isAdvertising = false; + + private String label; + private String mac; + private String setupId; + private int port; + private int configurationIndex; + private int category; + + private Pointer serviceRef; + private Thread processThread; + private final AtomicBoolean runProcessLoop = new AtomicBoolean(false); + + /** Register on all interfaces using the system default hostname. */ + public DnssdHomekitAdvertiser() { + this(INTERFACE_INDEX_ANY, null); + } + + /** + * Scope registration to the network interface that owns {@code localAddress}. SRV {@code host} is + * left {@code null} (system default hostname + A/AAAA via mDNSResponder). + * + *

Do not pass {@code localAddress.getHostAddress()} as DNS-SD host: a literal IP becomes an + * SRV target name with no A record, and Home pairing hangs after accepting the QR code. + * + * @param localAddress LAN address the HAP TCP listener uses + */ + public DnssdHomekitAdvertiser(InetAddress localAddress) { + this(resolveInterfaceIndex(localAddress), null); + } + + /** + * Full control over DNS-SD {@code interfaceIndex} and {@code host}. + * + * @param interfaceIndex {@link #INTERFACE_INDEX_ANY} (0) for all interfaces, or a platform + * interface index from {@link NetworkInterface#getIndex()} + * @param host SRV target DNS hostname, or {@code null} for the system default. + * Must not be a raw IP literal — DNS-SD will not register an A/AAAA for it. + */ + public DnssdHomekitAdvertiser(int interfaceIndex, String host) { + if (!SUPPORTED) { + throw new UnsupportedOperationException("Native DNS-SD is not available"); + } + this.dnsSd = DnsSdLibrary.load(); + this.interfaceIndex = interfaceIndex; + this.host = host; + } + + /** Visible for tests that inject a mock library. */ + DnssdHomekitAdvertiser(DnsSdLibrary dnsSd) { + this(dnsSd, INTERFACE_INDEX_ANY, null); + } + + /** Visible for tests that inject a mock library with interface/host. */ + DnssdHomekitAdvertiser(DnsSdLibrary dnsSd, int interfaceIndex, String host) { + this.dnsSd = dnsSd; + this.interfaceIndex = interfaceIndex; + this.host = host; + } + + public static boolean isSupported() { + return SUPPORTED; + } + + /** DNS-SD interface index used for registration (0 = all interfaces). */ + public int getInterfaceIndex() { + return interfaceIndex; + } + + /** SRV host passed to DNS-SD, or {@code null} for the system default. */ + public String getHost() { + return host; + } + + private static boolean detectSupport() { + try { + DnsSdLibrary.load(); + return true; + } catch (LinkageError | Exception e) { + // LinkageError covers NoClassDefFoundError when optional JNA is absent, and + // UnsatisfiedLinkError when libSystem / libdns_sd cannot be loaded. + logger.debug("Native DNS-SD not available: {}", e.toString()); + return false; + } + } + + private static int resolveInterfaceIndex(InetAddress localAddress) { + Objects.requireNonNull(localAddress, "localAddress"); + try { + NetworkInterface nif = NetworkInterface.getByInetAddress(localAddress); + if (nif != null) { + int index = nif.getIndex(); + if (index > 0) { + return index; + } + } + } catch (SocketException e) { + logger.debug("Could not resolve NetworkInterface for {}: {}", localAddress, e.toString()); + } + logger.debug( + "No network interface index for {}; registering on all interfaces (index 0)", localAddress); + return INTERFACE_INDEX_ANY; + } + + @Override + public void advertise( + String label, int category, String mac, int port, int configurationIndex, String setupId) + throws Exception { + synchronized (lock) { + if (isAdvertising) { + throw new IllegalStateException("HomeKit advertiser is already running"); + } + this.label = label; + this.mac = mac; + this.port = port; + this.setupId = setupId; + this.category = category; + this.configurationIndex = configurationIndex; + logger.trace( + "Advertising accessory {} via native DNS-SD (interfaceIndex={}, host={})", + label, + interfaceIndex, + host); + registerService(); + isAdvertising = true; + } + } + + @Override + public void stop() { + synchronized (lock) { + unregisterService(); + isAdvertising = false; + } + } + + @Override + public void setDiscoverable(boolean discoverable) throws IOException { + synchronized (lock) { + if (this.discoverable != discoverable) { + this.discoverable = discoverable; + if (isAdvertising) { + logger.trace("Re-creating DNSSD service due to discoverability={}", discoverable); + unregisterService(); + registerService(); + } + } + } + } + + @Override + public void setMac(String mac) throws IOException { + synchronized (lock) { + if (!Objects.equals(this.mac, mac)) { + this.mac = mac; + if (isAdvertising) { + logger.trace("Re-creating DNSSD service due to mac change"); + unregisterService(); + registerService(); + } + } + } + } + + @Override + public void setConfigurationIndex(int revision) throws IOException { + synchronized (lock) { + if (this.configurationIndex != revision) { + this.configurationIndex = revision; + if (isAdvertising) { + logger.trace("Re-creating DNSSD service due to c#={}", revision); + unregisterService(); + registerService(); + } + } + } + } + + private void registerService() throws IOException { + if (serviceRef != null) { + throw new AssertionError("DNSSD service already registered"); + } + Map props = + HapDnsTxt.buildProperties(discoverable, mac, label, setupId, configurationIndex, category); + byte[] txt = HapDnsTxt.encodeTxt(props); + // DNSServiceRegister requires the port in network byte order (big-endian wire form). + short netPort = toNetworkByteOrderPort(port); + + PointerByReference refOut = new PointerByReference(); + int err = + dnsSd.DNSServiceRegister( + refOut, + 0, + interfaceIndex, + label, + HapDnsTxt.SERVICE_TYPE, + HapDnsTxt.DOMAIN_LOCAL, + host, + netPort, + (short) txt.length, + txt, + Pointer.NULL, + Pointer.NULL); + if (err != DnsSdLibrary.kDNSServiceErr_NoError) { + throw new IOException("DNSServiceRegister failed with error code " + err); + } + serviceRef = refOut.getValue(); + if (serviceRef == null) { + throw new IOException("DNSServiceRegister returned a null service reference"); + } + logger.info( + "Registered {} via native DNS-SD on port {} (interfaceIndex={}, host={}, no UDP 5353 bind)", + HapDnsTxt.SERVICE_TYPE, + port, + interfaceIndex, + host); + startProcessLoop(serviceRef); + } + + private void startProcessLoop(Pointer ref) { + runProcessLoop.set(true); + processThread = + new Thread( + () -> { + try { + // DNSServiceProcessResult blocks until daemon IPC data arrives (or FD closes). + while (runProcessLoop.get()) { + int err = dnsSd.DNSServiceProcessResult(ref); + if (err != DnsSdLibrary.kDNSServiceErr_NoError) { + if (runProcessLoop.get()) { + logger.debug("DNSServiceProcessResult returned {}", err); + } + break; + } + } + } catch (Throwable t) { + if (runProcessLoop.get()) { + logger.warn("DNSSD process loop ended: {}", t.toString()); + } + } + }, + "hap-dnssd-process"); + processThread.setDaemon(true); + processThread.start(); + } + + private void unregisterService() { + runProcessLoop.set(false); + Pointer ref = serviceRef; + serviceRef = null; + // Deallocate first so a blocking DNSServiceProcessResult returns. + if (ref != null) { + try { + dnsSd.DNSServiceRefDeallocate(ref); + } catch (Throwable t) { + logger.debug("DNSServiceRefDeallocate: {}", t.toString()); + } + } + if (processThread != null) { + try { + processThread.join(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + processThread = null; + } + } + + /** Convert a host-order TCP port to the short value DNS-SD expects (network byte order). */ + static short toNetworkByteOrderPort(int port) { + ByteBuffer buf = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN); + buf.putShort((short) port); + buf.order(ByteOrder.nativeOrder()); + return buf.getShort(0); + } +} diff --git a/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java b/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java index 9086acb3e..4f28d9030 100644 --- a/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java +++ b/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java @@ -1,20 +1,18 @@ package io.github.hapjava.server.impl.jmdns; -import static io.github.hapjava.server.impl.crypto.HAPSetupCodeUtils.generateSHA512Hash; - +import io.github.hapjava.server.HomekitAdvertiser; +import io.github.hapjava.server.impl.advertiser.HapDnsTxt; import java.io.IOException; import java.net.InetAddress; import java.net.UnknownHostException; -import java.util.HashMap; import java.util.Map; import javax.jmdns.JmDNS; import javax.jmdns.ServiceInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class JmdnsHomekitAdvertiser { - - private static final String SERVICE_TYPE = "_hap._tcp.local."; +/** HomeKit advertiser backed by JmDNS (binds / uses UDP 5353). */ +public class JmdnsHomekitAdvertiser implements HomekitAdvertiser { private final JmDNS jmdns; private boolean discoverable = true; @@ -37,6 +35,7 @@ public JmdnsHomekitAdvertiser(InetAddress localAddress) throws UnknownHostExcept jmdns = JmDNS.create(localAddress); } + @Override public synchronized void advertise( String label, int category, String mac, int port, int configurationIndex, String setupId) throws Exception { @@ -64,10 +63,12 @@ public synchronized void advertise( isAdvertising = true; } + @Override public synchronized void stop() { unregisterService(); } + @Override public synchronized void setDiscoverable(boolean discoverable) throws IOException { if (this.discoverable != discoverable) { this.discoverable = discoverable; @@ -79,6 +80,7 @@ public synchronized void setDiscoverable(boolean discoverable) throws IOExceptio } } + @Override public synchronized void setMac(String mac) throws IOException { if (this.mac != mac) { this.mac = mac; @@ -90,6 +92,7 @@ public synchronized void setMac(String mac) throws IOException { } } + @Override public synchronized void setConfigurationIndex(int revision) throws IOException { if (this.configurationIndex != revision) { this.configurationIndex = revision; @@ -109,7 +112,7 @@ private void unregisterService() { } private void registerService() throws IOException { - logger.info("Registering " + SERVICE_TYPE + " on port " + port); + logger.info("Registering " + HapDnsTxt.SERVICE_TYPE_LOCAL + " on port " + port); if (this.serviceInfo != null) { throw new AssertionError( "Registering an already registered service without unregistering first is not allowed"); @@ -120,17 +123,8 @@ private void registerService() throws IOException { private ServiceInfo buildServiceInfo() { logger.trace("MAC:" + mac + " Setup Id:" + setupId); - Map props = new HashMap<>(); - props.put("sf", discoverable ? "1" : "0"); - props.put("id", mac); - props.put("md", label); - props.put("sh", generateSHA512Hash(setupId + mac)); - props.put("c#", Integer.toString(configurationIndex)); - props.put("s#", "1"); - props.put("ff", "0"); - props.put("ci", Integer.toString(category)); - props.put("pv", "1.1"); - - return ServiceInfo.create(SERVICE_TYPE, label, port, 1, 1, props); + Map props = + HapDnsTxt.buildProperties(discoverable, mac, label, setupId, configurationIndex, category); + return ServiceInfo.create(HapDnsTxt.SERVICE_TYPE_LOCAL, label, port, 1, 1, props); } } diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java index 11eb4a709..f0ff53c15 100644 --- a/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java +++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java @@ -1,9 +1,9 @@ package io.github.hapjava.server.impl.pairing; +import io.github.hapjava.server.HomekitAdvertiser; import io.github.hapjava.server.HomekitAuthInfo; import io.github.hapjava.server.impl.http.HttpRequest; import io.github.hapjava.server.impl.http.HttpResponse; -import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser; import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -15,11 +15,11 @@ public class PairingsManager { private final HomekitAuthInfo authInfo; - private final JmdnsHomekitAdvertiser advertiser; + private final HomekitAdvertiser advertiser; private static final Logger LOGGER = LoggerFactory.getLogger(PairingsManager.class); - public PairingsManager(HomekitAuthInfo authInfo, JmdnsHomekitAdvertiser advertiser) { + public PairingsManager(HomekitAuthInfo authInfo, HomekitAdvertiser advertiser) { this.authInfo = authInfo; this.advertiser = advertiser; } diff --git a/src/test/java/io/github/hapjava/server/impl/advertiser/HapDnsTxtTest.java b/src/test/java/io/github/hapjava/server/impl/advertiser/HapDnsTxtTest.java new file mode 100644 index 000000000..a3a47a6ce --- /dev/null +++ b/src/test/java/io/github/hapjava/server/impl/advertiser/HapDnsTxtTest.java @@ -0,0 +1,62 @@ +package io.github.hapjava.server.impl.advertiser; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.Test; + +public class HapDnsTxtTest { + + @Test + public void buildPropertiesContainsRequiredHapKeys() { + Map props = + HapDnsTxt.buildProperties(true, "AA:BB:CC:DD:EE:FF", "Bridge", "ABCD", 3, 2); + + assertThat(props) + .containsOnlyKeys("sf", "id", "md", "sh", "c#", "s#", "ff", "ci", "pv") + .containsEntry("sf", "1") + .containsEntry("id", "AA:BB:CC:DD:EE:FF") + .containsEntry("md", "Bridge") + .containsEntry("c#", "3") + .containsEntry("s#", "1") + .containsEntry("ff", "0") + .containsEntry("ci", "2") + .containsEntry("pv", "1.1"); + assertThat(props.get("sh")).isNotBlank(); + } + + @Test + public void buildPropertiesKeepsStableKeyOrderForDeterministicTxt() { + // LinkedHashMap insertion order (deterministic). RFC 6763 ignores TXT pair order; this + // differs from the pre-HapDnsTxt HashMap iteration order some dns-sd -L dumps showed. + assertThat( + HapDnsTxt.buildProperties(true, "AA:BB:CC:DD:EE:FF", "Bridge", "ABCD", 3, 2).keySet()) + .containsExactly("sf", "id", "md", "sh", "c#", "s#", "ff", "ci", "pv"); + } + + @Test + public void buildPropertiesSetsSfZeroWhenNotDiscoverable() { + Map props = + HapDnsTxt.buildProperties(false, "AA:BB:CC:DD:EE:FF", "Bridge", "ABCD", 1, 1); + assertThat(props).containsEntry("sf", "0"); + } + + @Test + public void encodeTxtUsesLengthPrefixedKeyValuePairs() { + Map props = HapDnsTxt.buildProperties(true, "id", "md", "setup", 1, 1); + byte[] txt = HapDnsTxt.encodeTxt(props); + + int offset = 0; + int pairs = 0; + while (offset < txt.length) { + int len = txt[offset] & 0xff; + String pair = new String(txt, offset + 1, len, StandardCharsets.UTF_8); + assertThat(pair).contains("="); + offset += 1 + len; + pairs++; + } + assertThat(pairs).isEqualTo(props.size()); + assertThat(offset).isEqualTo(txt.length); + } +} diff --git a/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiserTest.java b/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiserTest.java new file mode 100644 index 000000000..e6e09cbe0 --- /dev/null +++ b/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiserTest.java @@ -0,0 +1,215 @@ +package io.github.hapjava.server.impl.dnssd; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyShort; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import java.nio.ByteOrder; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; + +public class DnssdHomekitAdvertiserTest { + + private DnsSdLibrary dnsSd; + private DnssdHomekitAdvertiser subject; + private Pointer serviceRef; + + private final Answer successRegister = + invocation -> { + PointerByReference refOut = invocation.getArgument(0); + refOut.setValue(serviceRef); + return DnsSdLibrary.kDNSServiceErr_NoError; + }; + + @Before + public void setup() { + dnsSd = mock(DnsSdLibrary.class); + serviceRef = new Pointer(1L); + subject = new DnssdHomekitAdvertiser(dnsSd); + + when(dnsSd.DNSServiceRegister( + any(PointerByReference.class), + anyInt(), + anyInt(), + anyString(), + anyString(), + anyString(), + nullable(String.class), + anyShort(), + anyShort(), + any(byte[].class), + any(), + any())) + .thenAnswer(successRegister); + + when(dnsSd.DNSServiceProcessResult(any(Pointer.class))) + .thenReturn(DnsSdLibrary.kDNSServiceErr_NoError + 1); + } + + @Test + public void advertiseRegistersHapService() throws Exception { + advertise(); + + ArgumentCaptor ifIndex = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor type = ArgumentCaptor.forClass(String.class); + ArgumentCaptor host = ArgumentCaptor.forClass(String.class); + ArgumentCaptor port = ArgumentCaptor.forClass(Short.class); + ArgumentCaptor txt = ArgumentCaptor.forClass(byte[].class); + + verify(dnsSd) + .DNSServiceRegister( + any(PointerByReference.class), + anyInt(), + ifIndex.capture(), + anyString(), + type.capture(), + anyString(), + host.capture(), + port.capture(), + anyShort(), + txt.capture(), + any(), + any()); + + assertThat(type.getValue()).isEqualTo("_hap._tcp"); + assertThat(ifIndex.getValue()).isEqualTo(DnssdHomekitAdvertiser.INTERFACE_INDEX_ANY); + assertThat(host.getValue()).isNull(); + assertThat(port.getValue()).isEqualTo(DnssdHomekitAdvertiser.toNetworkByteOrderPort(9124)); + assertThat(txt.getValue()).isNotEmpty(); + } + + @Test + public void advertisePassesConfiguredInterfaceIndexAndHost() throws Exception { + // Advanced ctor may pass a real DNS hostname; raw IPs must not be used as DNS-SD host. + subject = new DnssdHomekitAdvertiser(dnsSd, 4, "bridge.local"); + when(dnsSd.DNSServiceRegister( + any(PointerByReference.class), + anyInt(), + anyInt(), + anyString(), + anyString(), + anyString(), + nullable(String.class), + anyShort(), + anyShort(), + any(byte[].class), + any(), + any())) + .thenAnswer(successRegister); + + advertise(); + + ArgumentCaptor ifIndex = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor host = ArgumentCaptor.forClass(String.class); + verify(dnsSd) + .DNSServiceRegister( + any(PointerByReference.class), + anyInt(), + ifIndex.capture(), + anyString(), + anyString(), + anyString(), + host.capture(), + anyShort(), + anyShort(), + any(byte[].class), + any(), + any()); + + assertThat(ifIndex.getValue()).isEqualTo(4); + assertThat(host.getValue()).isEqualTo("bridge.local"); + } + + @Test + public void inetAddressConstructorLeavesHostNull() { + // Package-visible mock ctor mirrors InetAddress behaviour: interface scoped, host=null. + subject = new DnssdHomekitAdvertiser(dnsSd, 7, null); + assertThat(subject.getInterfaceIndex()).isEqualTo(7); + assertThat(subject.getHost()).isNull(); + } + + @Test + public void advertiseTwiceFails() throws Exception { + advertise(); + assertThatThrownBy(this::advertise).isInstanceOf(IllegalStateException.class); + } + + @Test + public void setDiscoverableReRegisters() throws Exception { + advertise(); + subject.setDiscoverable(false); + + verify(dnsSd, atLeastOnce()).DNSServiceRefDeallocate(serviceRef); + verify(dnsSd, times(2)) + .DNSServiceRegister( + any(PointerByReference.class), + anyInt(), + anyInt(), + anyString(), + anyString(), + anyString(), + nullable(String.class), + anyShort(), + anyShort(), + any(byte[].class), + any(), + any()); + } + + @Test + public void stopDeallocatesServiceRef() throws Exception { + advertise(); + subject.stop(); + verify(dnsSd).DNSServiceRefDeallocate(serviceRef); + } + + @Test + public void registerFailureSurfacesAsIoException() { + when(dnsSd.DNSServiceRegister( + any(PointerByReference.class), + anyInt(), + anyInt(), + anyString(), + anyString(), + anyString(), + nullable(String.class), + anyShort(), + anyShort(), + any(byte[].class), + any(), + any())) + .thenReturn(-65537); + + assertThatThrownBy(this::advertise) + .isInstanceOf(java.io.IOException.class) + .hasMessageContaining("DNSServiceRegister failed"); + } + + @Test + public void toNetworkByteOrderPortMatchesDnsSdExpectation() { + short net = DnssdHomekitAdvertiser.toNetworkByteOrderPort(9124); + if (ByteOrder.nativeOrder() == ByteOrder.LITTLE_ENDIAN) { + assertThat(net).isEqualTo(Short.reverseBytes((short) 9124)); + } else { + assertThat(net).isEqualTo((short) 9124); + } + } + + private void advertise() throws Exception { + subject.advertise("test", 2, "00:00:00:00:00:00", 9124, 1, "ABCD"); + Thread.sleep(50); + } +} diff --git a/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java b/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java index 870bd739b..38107041c 100644 --- a/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java +++ b/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java @@ -54,6 +54,28 @@ public void testSetConfigurationIndex() throws Exception { assertThat(getArgumentFromUnregister().getPropertyString("c#")).isEqualTo("1"); } + @Test + public void advertiseRegistersFullHapTxtPropertySet() throws Exception { + advertise(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ServiceInfo.class); + verify(jmdns).registerService(captor.capture()); + ServiceInfo info = captor.getValue(); + + assertThat(info.getType()).isEqualTo("_hap._tcp.local."); + assertThat(info.getName()).isEqualTo("test"); + assertThat(info.getPort()).isEqualTo(1234); + assertThat(info.getPropertyString("sf")).isEqualTo("1"); + assertThat(info.getPropertyString("id")).isEqualTo("00:00:00:00:00:00"); + assertThat(info.getPropertyString("md")).isEqualTo("test"); + assertThat(info.getPropertyString("c#")).isEqualTo("1"); + assertThat(info.getPropertyString("s#")).isEqualTo("1"); + assertThat(info.getPropertyString("ff")).isEqualTo("0"); + assertThat(info.getPropertyString("ci")).isEqualTo("1"); + assertThat(info.getPropertyString("pv")).isEqualTo("1.1"); + assertThat(info.getPropertyString("sh")).isNotBlank(); + } + private ServiceInfo getArgumentFromUnregister() { ArgumentCaptor serviceInfoCaptor = ArgumentCaptor.forClass(ServiceInfo.class); verify(jmdns).unregisterService(serviceInfoCaptor.capture());