Skip to content

mDNS host discovery and remote multihost pairing - #3429

Open
GermanBluefox wants to merge 14 commits into
masterfrom
mdns
Open

mDNS host discovery and remote multihost pairing#3429
GermanBluefox wants to merge 14 commits into
masterfrom
mdns

Conversation

@GermanBluefox

@GermanBluefox GermanBluefox commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

[feature]: mDNS host discovery and remote multihost pairing

Link the feature issue which is closed by this PR

Implementation details

Today a new host can only be attached to an existing multihost system from that host:
iobroker multihost connect needs a shell on the machine. On a ready-made image, in a container or
on a headless device that is exactly what the user does not have.

This PR makes a host announce itself on the network, lets a master list the hosts that belong to no
system yet, and lets the user attach or reject one of them from the Admin — without ever logging
into the new host.

The flow this enables:

  1. A freshly installed controller announces itself on the local network.
  2. A master listens and offers the unknown host in the Admin.
  3. Yes → the master sends a command, the new host takes over the database configuration and
    restarts. Nothing has to be confirmed on the new host.
  4. No → the master remembers the host's UUID and stops offering it.

1. Host discovery — new module packages/controller/src/lib/hostDiscovery.ts

Announces this host via mDNS/DNS-SD and collects the announcements of the others.

  • Service type _iobroker._tcp on port 50005 — the port the multihost service already listens
    on, so whoever sees the announcement can talk to that host straight away.
  • TXT record: uuid (installation id), host, unclaimed, master, v (controller version),
    proto (TXT layout version).
  • The service is re-published only when something actually changed. mDNS clients read a
    re-publish as "gone and back again", so doing it on a timer would make every host flap in every
    master's list. The periodic part sits on the browsing side instead: the browser re-queries every
    60 seconds and every host answers. The same tick asks the controller whether its own announcement
    is still accurate — a host stops being unclaimed the moment another host joins it.
  • Own announcements are filtered out; entries that were not seen for 5 minutes are dropped, because
    a host that is switched off does not always manage to send a goodbye.

The mDNS library is an optional dependency. bonjour-service binds UDP 5353, which does not
work on every system — notably not on some Windows setups. It is declared in optionalDependencies
and loaded with a dynamic import(). When it is missing or the responder cannot be started, the
controller writes one info line and carries on: hosts are then only found over the multihost UDP
protocol, and discoveredHosts stays empty. So that the module also compiles without the package,
the used surface is described by local interfaces rather than a type import.

The discovered hosts are published as JSON in the new state
system.host.<hostname>.discoveredHosts, so the Admin can subscribe instead of polling.

2. MHClient moved to @iobroker/js-controller-common

packages/cli/src/lib/setup/multihostClient.tspackages/common/src/lib/common/multihostClient.ts,
exported together with the ReceivedMessage / BrowseResultEntry types. The controller needs the
same client the CLI setup uses, and the controller must not depend on the CLI package.

  • New sendCommand(ip, cmd, payload, timeout) — a unicast command to a single host.
  • Bugfix: browse() returned the server's answer unchanged for result: 'ok', and that answer
    carries no address — only the not authenticated branch added ip. Unclaimed hosts always answer
    ok, so the whole pairing flow had no IP to send anything to. The address now comes from the
    packet itself.
  • uuid added to ReceivedMessage; info re-typed, it is an object and was declared as string.

3. New UDP commands in MHServer

browse was read-only: a client asks, the host answers. These three go the other way — a master
tells a host what to do. That host has no states database connection, so this socket is the only way
to reach it.

Command Effect
join The host fetches the master's database configuration, stores it and restarts. Refused with already claimed or declined.
decline The host stores the master's UUID in declined-masters.json and ignores its future joins. revoke: true undoes it.
identify The host writes its own name into its log — helps to tell two freshly flashed devices apart.

4. Pairing mode

When the multihost service is off but the host still uses local databases, MHServer starts in
pairingOnly mode: browse answers with hostname, static info, unclaimed and uuidnever
with the database configuration. UDP is kept alongside mDNS on purpose, because mDNS does not
survive a Docker bridge or a subnet border.

Switchable with the new "multihostService": { "pairing": false } in iobroker.json (default
true; added to iobroker-dist.json, to IoBJson in packages/types-dev/config.d.ts, and
schemas/iobroker.json regenerated).

5. What counts as "unclaimed"

A remote database means the host already belongs to a system. A local database alone is not enough
to call it free — a master runs on a local database too. What disqualifies it is a second host in
the same system
: then somebody already joined it, and taking it over would cut that host off from
its data.

Installed adapter instances are deliberately not part of the check. A ready-made image usually
ships with admin and a backup adapter already set up, and that is still a host nobody has claimed.

The check needs the objects database, which MHServer has no access to, so the controller injects it
via the new MHServerOptions.isUnclaimed. The old configuration-only check remains as the fallback.

6. Ignore list on the master

decline used to be stored only on the other host, keyed by the master's UUID. That loses the
decision whenever the other host is switched off or the packet is lost.

The master now keeps its own list in system.meta.discovery (native.declined), keyed by the
UUID the remote host announces. multihostPair with cmd: 'decline' stores locally first and
notifies the other host afterwards — if it cannot be reached, it stays hidden anyway. revoke: true
takes it back. Both sources of the discovery list are filtered against it.

7. Host messages

Message Side Purpose
multihostBrowse master Lists the hosts on the network. Merges the mDNS cache with an active UDP browse (deduplicated by IP, each entry tagged source: 'mdns' | 'udp'), filtered against the ignore list.
multihostPair master Sends join / decline / identify. Passes the master's system.meta.uuid so a decline can be assigned.
multihostConnect slave Attaches this host to a given master, for the case where the user sits in the Admin of the new host.

multihostPair with join runs two preflight checks before sending anything, because a join
makes the other host fetch the configuration from us: if this host is not a multihost master, or
its databases only listen locally, there is nothing to fetch. Without the checks the other side
failed seconds later with an unhelpful "invalid configuration" — in a log the master cannot read,
because that host is not part of the system. It now returns a message the Admin can act on.

8. joinMaster() — shared by both directions

Runs the existing handshake (browseauthbrowse), stores the received objects / states
sections and restarts the controller. Safeguards:

  • the previous iobroker.json is kept as iobroker.json.bak — without a screen and without SSH, a
    host that cannot reach its master after the restart would otherwise be unrecoverable;
  • refused if the remote configuration points back to a local database;
  • a listen-all address (0.0.0.0 / ::) delivered by the master is replaced with its actual IP.

9. Startup ordering fix

setMeta() creates system.meta.uuid inside a database callback which it does not await, so on a
fresh installation the UUID does not exist yet when the discovery starts. The host would have
announced itself without one for a full refresh interval — and without it a master can neither tell
two hosts apart nor remember a rejected one. waitForUuid() picks it up every 2 s for up to 30 s and
corrects the announcement.

10. Removed again

@iobroker/plugin-mdns and its common.plugins.mdns entry, both added in an earlier commit on this
branch, are gone. That package is adapter-oriented: it has no discovery side at all, its dependency
@homebridge/ciao is advertise-only, it aborts without a configured port, and its namespace parsing
does not match a controller. Discovery is inseparable from MHServer/MHClient, so it lives in the
controller now.


Security considerations

  • An unclaimed host discloses only its name, static info and UUID — the database configuration is
    never handed out before it is claimed.
  • join verifies against the actual state of this host whether it is free, it does not trust the
    sender.
  • The password handshake for a secured master is unchanged.
  • Declines are stored on the master and additionally on the remote host, per master, so a host
    declined by one master stays reachable for another — a host invisible to everybody could never be
    brought back without reflashing it. A declined host keeps announcing for the same reason.
  • Everything can be switched off with multihostService.pairing: false.

Known and deliberately out of scope for this PR: join is not authenticated on the receiving
side. There is no secret check in the join path — password is only forwarded outwards to the
master. A single UDP packet to port 50005 is enough to make a host with a local database re-point its
databases and restart. The unclaimed check narrows this to hosts without a second host in their
system, but does not close it. Since confirming on the new host is explicitly not wanted, a different
anchor is needed — a pairing window after installation, or a pairing code the master has to send.

Tests

  • I have added tests to test this feature
  • It is not possible to test this feature

If no tests added, please specify why it was not possible

Pairing runs over mDNS and UDP multicast between two hosts and ends in a controller restart, which
the existing Mocha integration setup does not cover.

Manually verified:

Check Result
tsc --project packages/controller/tsconfig.check.json clean
npm run lint clean (only two pre-existing warnings in db-objects-redis)
npm run build ESM + CJS pass
Two instances against the built ESM output master finds the fresh host with uuid / ip / port 50005 / unclaimed: true, own announcement filtered out, flipping to unclaimed: false propagates, clean shutdown
Same with bonjour-service removed one info line per instance, no crash, empty list, clean shutdown
Built CJS output loads and announces — a different interop path than ESM, since the package is CommonJS

Documentation

  • I have documented the new feature

New Host discovery section under Feature Overview, plus multihostBrowse, multihostPair and
multihostConnect under Feature Overview/js-controller Host Messages.

Open points

  • The Admin UI counterpart is not part of this PR: subscribing to discoveredHosts, the yes/no
    dialog, and enabling the multihost service on the master (write iobroker.json, then
    updateMultihost; rebinding the databases to 0.0.0.0 still needs a controller restart).
  • Hostname collisions are not handled. Two devices flashed from the same image are both called
    iobroker; after the join their system.host.<name> objects collide. mDNS resolves the conflict
    for the announced instance name only, not for the host object.
  • The master flag in the announcement comes from the in-memory config and stays stale after
    updateMultihost until the next restart. Cosmetic — only unclaimed drives the pairing logic.

@GermanBluefox
GermanBluefox marked this pull request as ready for review August 6, 2026 08:59
GermanBluefox and others added 3 commits August 6, 2026 13:08
The line came in with master but was dropped again while resolving the
conflict in 4d48e57, because both sides had touched the same block.
Without it every object view comes back empty on macOS and the standard
tests end at 377 passing / 101 failing.

Becomes unnecessary once the view scripts compare bytes instead of
relying on the server collation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcdCSrTTHNY2SRMUo8aoPc

@Apollon77 Apollon77 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with a focus on the network-facing side. tsc --project packages/controller/tsconfig.check.json is clean on this branch (my first run failed only because my local packages/common/build was stale — rebuilding @iobroker/js-controller-common clears it, so the claim in the description holds).

The engineering is careful in the places that are easy to get wrong: re-publishing only on real change instead of on a timer, the optional dependency behind a dynamic import() with local interfaces so it still compiles, isUnclaimed() evaluated against this host's actual state rather than trusting the sender, keeping UDP alongside mDNS for bridged networks. The write-up is also unusually honest about what is missing.

My concern is that the unauthenticated join is filed as "known and deliberately out of scope", and I think the description understates it on both axes:

Reach. pairing defaults to true when absent, and the new pairingOnly listener starts whenever the objects database is local and the multihost service is off. That is the standard single-host installation. So after an upgrade, hosts that previously had nothing listening on UDP 50005 start listening, and "narrows this to hosts without a second host in their system" describes the majority of installations rather than an edge case. The user is never asked.

Impact. "Re-point its databases and restart" undersells the consequence. The objects database is where adapter configuration and installedFrom live, so a host pointed at an attacker's database fetches instance objects from the attacker and the controller then starts and installs adapters accordingly — code execution as the iobroker user, from one unauthenticated UDP packet on the LAN.

There is also a second, independent path to the same outcome that is not in the description: MHClient.connect() accepts the browse answer from any source address and correlates only on a message id that starts at 1, and joinMaster builds a fresh client for every attempt so the first request is always id 2. Detail inline.

My suggestion would be to split this: the announcement, the discovery, identify and the read-only browse are useful on their own and could go in once the spam handling below is addressed. The join write path is the part that needs an anchor first — a post-install pairing window or a pairing code shown on the master — and pairing should default to false until it has one.

Separately, nothing on the UDP or mDNS side is rate limited or bounded, and several handlers do real work per packet: a synchronous read-modify-write of a JSON file, a full getObjectViewAsync('system', 'host'), a log line, a states write. Those are individually cheap to fix and are marked inline.

Everything below is from reading the code on this branch; where I say "no check" I mean I grepped for it and it is not there.

_config.multihostService?.pairing !== false &&
(await isLocalObjectsDbServer(_config.objects.type, _config.objects.host))
) {
_startMultihost(_config, false, { pairingOnly: true });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the line that decides the blast radius, so it deserves the most attention in the PR.

pairing is absent from every existing iobroker.json, so !== false makes it enabled, and the second condition — a local objects database — is true for every standalone installation. The result is that upgrading to this version opens UDP 50005 on hosts that had nothing listening there before, with no prompt and no changelog-visible opt-in. Combined with the unauthenticated join handler, one packet from anywhere that can route to the host is enough to make it fetch its database configuration from the sender and restart.

I would default pairing to false and let the ready-made-image use case turn it on explicitly (the images ship their own iobroker.json anyway, so they can). That keeps the feature available where it is wanted without changing the exposure of existing installations.

Also: the comment above says "browse is refused in this mode, so nothing is disclosed", but MHServer answers browse in pairingOnly mode with hostname, info (os, ostype, cpus, memory, node version) and uuid to any unauthenticated sender. The MHServerOptions.pairingOnly JSDoc describes it correctly — this comment does not, and it is the one a reader checking the security posture will find first.

const id = `${rinfo.address}:${rinfo.port}`;

switch (msg.cmd) {
case 'join': {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The join handler runs before anything has proven who the sender is. msg.password is only forwarded outwards; nothing here verifies it, and masterUuid is only consulted against the local decline list. So the guard is isUnclaimed() alone, which is true for every host that has a local database and no second host — a fresh installation and an ordinary single-host installation.

What happens next is worth spelling out in the description, because "re-point its databases" reads more benign than it is: onJoin pulls objects/states from rinfo.address, writes them to iobroker.json and restarts. From then on the host reads system.adapter.* from a database the attacker controls, which is where installedFrom and every instance's native live. The controller will install and start what it finds there. That is remote code execution as the iobroker user, reachable with a single UDP datagram.

The anchor you sketch in the description — a pairing window after installation, or a code the master must present — is the right shape. Either one also removes the need for isUnclaimed() to carry the whole weight. Until then I would not ship this handler enabled by default.

One more thing on this path: there is no in-flight guard. See the note on joinMaster.

): Promise<ReceivedMessage> {
return new Promise((resolve, reject) => {
let answered = false;
const requestId = ++this.id;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anchoring here because the id counter is the part this PR touches, but the finding is about connect() (line 267 in this file), which joinMaster now feeds straight into iobroker.json.

Two gaps there:

  1. No source check. startServer's message handler receives rinfo but never compares it against the ip the request went to, so a browse answer from any address is accepted and its msg.objects / msg.states are handed to the callback at line 277.
  2. Predictable id. id is initialised to 1 (line 67) and both connect() and sendCommand() send ++this.id. joinMaster constructs a fresh MHClient per attempt, so the first request is always id 2.

So an attacker on the network who lands a datagram on the ephemeral socket during the ~2 s window — {"cmd":"browse","id":2,"result":"ok","objects":{…},"states":{…}} — beats the real master and decides which databases the host joins. The port is the only unknown, and it is a single 16-bit guess against a window the attacker can trigger repeatedly.

This is pre-existing code, but it used to be reachable only from iobroker multihost connect typed by a human on the box. onJoin makes it remotely triggerable, which is what brings it into scope here.

Both fixes are small — in the connect() handler:

(msg, rinfo) => {
    if (rinfo.address !== ip) {
        return false;   // not the host we asked
    }
    

and seeding id from crypto.randomInt instead of 1, which also hardens sendCommand on this line.

return [];
}

private addDeclined(masterUuid: string): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decline is unauthenticated and this is the most abusable handler of the four.

Per packet it does readDeclined() + writeJSONSync() — a synchronous read-modify-write of a file next to iobroker.json, on the event loop, inside the UDP handler. masterUuid is any string of any length, and list has no cap. So a flood of decline packets with random masterUuid values grows the file without bound and stalls the controller on synchronous I/O for as long as the flood lasts. No rate limit, no size limit, no format check.

revoke: true is the same handler and equally unauthenticated, so anyone can also remove a decline the user made — which is the one decision this file exists to remember.

Minimum changes I would want: validate masterUuid against a UUID shape, cap the list length, and move to the async fs API with the write coalesced (or hold the list in memory and flush on a timer). Rate limiting per source address would cover all four commands at once.

* considers every host with a local database free, which is why it is not used when the
* controller provides something better.
*/
private async isUnclaimed(): Promise<boolean> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isUnclaimed() is called from both the browse and the join handler, and the injected implementation (isHostUnclaimed in main.ts) does getObjectViewAsync('system', 'host') every time, with no caching.

So one unauthenticated UDP datagram costs one full objects-database view query. That is a comfortable amplification factor for an attacker and it needs no reply to be delivered — a flood of browse packets is enough to keep the objects database busy.

The answer changes rarely (only when a host joins or leaves the system), so caching it for a few seconds — or recomputing it on the objectChange for system.host.* and answering from a cached boolean — removes the amplification entirely.

await publishDiscoveredHosts([]);
}

if (!hostDiscovery.isAvailable()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return sits before the waitForUuid() kick-off at line 447, so when mDNS is unavailable the UUID is never picked up later.

The sequence on a fresh installation without bonjour-service — the Windows case the description explicitly supports:

  1. updateHostAnnouncement() runs once, system.meta.uuid does not exist yet, ownUuid stays '';
  2. isAvailable() is false, so hostDiscovery = null and the function returns;
  3. waitForUuid() never starts, and updateHostAnnouncement() returns early forever after because hostDiscovery is null;
  4. getUuid: () => ownUuid therefore keeps returning '' for the whole lifetime of the process, so the pairingOnly browse answer has no uuid;
  5. the master cannot key a decline to that host, and the !host.uuid filter shows it regardless.

Since ownUuid is also what the UDP path needs, it should not depend on mDNS being usable at all. Resolving it before the availability check — or moving it out of updateHostAnnouncement into its own small resolver — decouples the two.

Comment thread packages/controller/src/main.ts Outdated
config.states.host = replaceListenAll(config.states.host);

try {
fs.copyFileSync(configFile, `${configFile}.bak`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no in-flight guard on joinMaster, and the join handler can be entered concurrently: it answers ok and then awaits onJoin, so a second datagram arriving in that window starts a second join.

The consequence lands exactly on the safety net this backup is meant to be. Run one: iobroker.json.bak holds the original, iobroker.json holds the master's configuration. Run two overlaps and copies the already rewritten iobroker.json over iobroker.json.bak — so both files now point at the master, and the recovery path the description promises ("without a screen and without SSH a host that cannot reach its master after the restart would otherwise be unrecoverable") is gone.

Two small things fix it: a module-level joinInProgress flag that makes the second join answer error, and refusing to overwrite ${configFile}.bak if one already exists (or writing .bak only when the current config still has a local database).

Comment thread packages/controller/src/main.ts Outdated
if ((entry.uuid && declined.includes(entry.uuid)) || (entry.ip && seen.has(entry.ip))) {
continue;
}
hosts.push({ ...entry, source: 'udp' });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ ...entry } forwards the whole browse answer to the caller of multihostBrowse. For a master that runs with multihostService.secure: false, MHServer answers browse with objects: this.config.objects and states: this.config.states — database type, host and, for Redis, the credentials in those sections.

So this reply can carry another host's database configuration to whoever sent the host message. The Admin needs hostname, ip, port, unclaimed, uuid, version and source; picking those explicitly instead of spreading keeps the rest from travelling. The mDNS branch above already only has the safe fields, so the two branches would also become symmetric.

Separately, msg.message?.timeout at line 3295 is unbounded and caller-controlled. MHClient.browse resolves only when the timeout expires, and this handler awaits it, so a large value parks the handler and holds the socket for that long. Clamping to something like 10 s is enough.

return;
}

if (txt.uuid && this.announced?.uuid === txt.uuid && hostname === this.hostname) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both self-filters can miss, so a host can list itself.

ownFqdn is only assigned after a successful publish(), and this.announced is null until then. startDiscovery() and announce() are independent entry points, so if browsing wins the race the host's own announcement passes both checks and is stored under its own fqdn key.

It then stays: once announce() completes, addHost returns at line 359 on the fqdn match, so the stale self-entry is never refreshed and never corrected — it just sits in the list until ENTRY_TTL expires it five minutes later, and in the meantime the Admin offers the host to itself.

Comparing against this.hostname and the resolved ownUuid independently of this.announced, or simply deleting this.hosts.get(fqdn) when ownFqdn is assigned, closes it.

*
* A host that is switched off does not always manage to send a goodbye.
*/
private expire(): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On tests — the description says the feature cannot be tested because pairing spans two hosts and ends in a restart, and that is true for the end-to-end flow. But the parts where the bugs above actually live are not the end-to-end flow, and most of them are reachable without any network:

  • addHost / expire / getKey / removeHost take a plain MdnsService object. Feeding them a hand-written service (missing proto, no IPv4, duplicate fqdn, attacker-length strings) needs no mDNS at all and would pin the filtering the JSDoc claims.
  • announce()'s change detection — the property that a re-publish only happens on real change, which the description calls out as important — is testable with a stub responder.
  • MHServer's command dispatch can be driven over a loopback UDP socket with isUnclaimed/onJoin stubbed. That covers already claimed, declined, the pairingOnly answer shape and, most usefully, that browse in pairingOnly mode never contains objects/states — a regression there is silent and serious.

Those three groups are cheap and they guard the security-relevant branches, which is where I would want a test to exist even if the full pairing flow stays manual.

GermanBluefox and others added 2 commits August 15, 2026 13:21
Addresses the review findings on the mDNS discovery / remote pairing PR.

Authentication and trust:
- join: demand a well-formed masterUuid, so a master the user declined
  cannot get back in by simply omitting the field
- join: refuse unless this host runs in pairing mode - a host that is a
  multihost master itself is not a pairing target, even while no second
  host has joined it yet
- MHClient.connect/sendCommand: only accept an answer from the address
  that was asked, and seed the message id randomly instead of from 1

Resource use - all four UDP commands are unauthenticated:
- rate limit per sender in front of the command dispatch
- decline: validate the uuid, cap the list, and keep it in memory with an
  async write instead of a sync read-modify-write per datagram
- isUnclaimed: cache the answer, it cost one objects view per datagram
- identify: log at most once per sender and window

mDNS:
- addHost: enforce what the JSDoc already promised - protocol version,
  plausible uuid, bounded string lengths - and cap the number of entries
- debounce the change notification, so the state write rate no longer
  follows whatever the network announces
- drop our own announcement from the list once the fqdn is known

Correctness:
- do not offer a host without a uuid: the decline list is keyed by it, so
  "No" would silently do nothing and the host would keep coming back
- resolve system.meta.uuid independently of mDNS, the UDP browse answer
  needs it just as much
- keep iobroker.json.bak from being overwritten by a second join, and
  guard joinMaster against concurrent runs
- multihostBrowse: pick the fields explicitly, since a browse answer of an
  unsecured master carries its database credentials; clamp the timeout

Pairing stays enabled by default: a freshly installed host has to be
attachable from an existing system without a shell on it.

Tests: 27 unit tests for the command dispatch, the mDNS filtering and the
client, wired into CI on all platforms via cross-env - which also brings
the existing adapter unit tests into CI for the first time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants