docker-mdns-bridge
v0.1.0
Published
Relay a Docker container's Bonjour/mDNS advertisements onto your real LAN — no host networking, no macvlan.
Maintainers
Readme
docker-mdns-bridge
Relay a Docker container's Bonjour/mDNS advertisements onto your real LAN — without host networking, macvlan, or any Docker networking mode changes.
Background
This exists for situations like fault-finding a smart speaker's poor Bonjour/AirPlay discoverability on the LAN, or developing a Homebridge plugin against a real running instance — cases where you want to test against a container without installing anything bare-metal on the host just to debug a networking quirk. Docker is the obvious way to keep that disposable, but Docker Desktop on Mac is exactly where mDNS discovery breaks.
The problem
Docker Desktop (Mac and Windows) runs containers inside a hidden VM whose network is NAT'd to the host. Published ports (-p 8080:8080) work fine — but IP multicast, which mDNS/Bonjour/Avahi depends on for service discovery, never crosses that NAT boundary. A service advertised inside a container (Homebridge, a printer daemon, a Chromecast-style receiver, anything using Bonjour/Avahi for discovery) is invisible to other devices on your LAN, even though its TCP port is reachable fine once you know the address.
macvlan/--network host are either unsupported or unreliable on Docker Desktop for Mac — there's no host-side network interface for them to attach to.
The fix
This tool periodically asks the container (via docker exec ... avahi-browse) what it's advertising, and re-publishes an equivalent mDNS record from the host's own network stack — dns-sd — pointed at the container's already-published port. Other devices on the LAN discover the host's advertisement and connect over the normal published TCP port; the container never needs multicast access at all.
It keeps re-syncing on an interval, so:
- TXT record changes (pairing state, config versions, anything that mutates at runtime) are picked up automatically.
- Services appearing or disappearing inside the container are reflected on the host.
- A stale record is deregistered cleanly on exit (Ctrl+C / SIGTERM).
Installation
npm install -g docker-mdns-bridgeOr run it directly with npx docker-mdns-bridge ....
Usage
docker-mdns-bridge --container <name> [options]| Option | Description |
|---|---|
| -c, --container <name> | Docker container to read advertisements from (required) |
| -f, --filter <pattern> | Only bridge services whose name or type matches (repeatable; */? wildcards; default: bridge everything) |
| -x, --exclude <pattern> | Never bridge services whose name or type matches (repeatable; */? wildcards) |
| -i, --interval <secs> | Re-sync interval in seconds (default: 5) |
| --once | Sync once and exit, instead of running continuously |
| --dry-run | Print what would be published, without publishing anything |
| -v, --verbose | Log every poll cycle, not just changes |
Example: bridging a Homebridge container
docker-mdns-bridge --container my-homebridge --filter _hap._tcp --verboseYour phone's Home app (or any HAP controller) will then discover the accessory advertised under your Mac's own hostname, on the container's mapped HAP port.
Example: bridge everything except noisy sshd advertisements
docker-mdns-bridge --container my-container --exclude '*ssh*' --verboseExample: bridge everything, once, to see what's there
docker-mdns-bridge --container my-homebridge --once --dry-run --verboseRequirements
- Docker CLI on the host, with the target container running.
avahi-browseinside the container. If missing, this tool tries to install it automatically (avahi-utilsvia apt-get,avahi-toolsvia apk/yum/dnf) — if none of those package managers are present, install it yourself in the image and re-run.dns-sdon the host (built in to macOS, no install needed). macOS is the only supported host platform.- Node.js >= 24 (current LTS), no other dependencies.
[!WARNING] Running this tool against a container that doesn't already have
avahi-browseinstalled will runapt-get/apk/yum/dnfinside your already-running container viadocker exec, installingavahi-utils/avahi-toolsdirectly into its live filesystem. This mutates the running container, not its image: it doesn't touch your Dockerfile, won't survive the container being removed and recreated, and adds to that container's writable layer on disk for as long as it lives. If you'd rather this tool not modify the container at all, bakeavahi-utils/avahi-toolsinto your image yourself ahead of time — nothing is installed onceavahi-browseis already present.
How it works
docker exec <container> avahi-browse -a -r -p -t -kdumps every currently-advertised service the container's Avahi daemon knows about, in parsable (-p) form — always every type;--filter/--excludenarrow the results afterwards, not the avahi-browse invocation itself.-k/--no-db-lookupis mandatory, not cosmetic: without it, avahi-browse's browse-all mode silently substitutes well-known type strings with a human-friendly description from its local database (e.g._ssh._tcpbecomesSSH Remote Terminal) — republishing that string as the actual mDNS type would produce a broken advertisement, so-kis always passed to keep the real type string intact.- Each resolved record is parsed into
{ name, type, domain, port, txt }, unescaping Avahi's\DDDdecimal escapes. --filter/--excludepatterns (if given) are applied to the parsed list, matching against each service's name or type.- For each unique remaining service (by name+type+domain), a signature is computed from its port and TXT record. If it's new or the signature changed since the last poll, the previous publish process (if any) is killed and a new one spawned:
dns-sd -R "<name>" <type> <domain> <port> <k=v> ... - Any previously-published service that no longer appears in the (filtered) results is deregistered.
- On exit, every spawned publish process is killed, removing the host-side records.
A transient failure to reach the container (e.g. it's mid-restart) is treated as "unknown", not "gone" — the last-known-good record stays published rather than flapping on every blip.
Development
Written in TypeScript (^7.0.2). The CLI logic is split into small, independently testable modules under src/: types.ts (shared ParsedService/PublishHandle types), parse.ts (Avahi output parsing/escaping), filter.ts (--filter/--exclude glob matching), docker.ts (docker exec/avahi-browse plumbing), publish.ts (host-side dns-sd backend), cli.ts (argument parsing, via commander), and sync.ts (the diff/publish engine, with browse/publish injected so it can be tested without touching Docker or a real mDNS stack). src/main.ts wires the real implementations together and is the package's bin entry: package.json points straight at its compiled output, dist/main.js — npm link/npm install make it executable automatically.
npm install
npm run build # tsc -> dist/
npm test # unit tests (fast, no Docker required)
npm run test:integration # integration tests (needs Docker; pulls real images)
npm run docs # generates HTML API docs into docs/ via TypeDocTests live under src/__tests__/{unit,integration}/ and run via Node's native stripTypeScriptTypes (see jest.strip-types-transformer.cjs) rather than ts-jest — TypeScript 7 ("tsgo") is a from-scratch Go rewrite that doesn't expose the in-process JS Compiler API ts-jest relies on. tsconfig.json enforces erasableSyntaxOnly, so nothing here needs real type-directed codegen (no enums, no parameter properties); type-checking itself happens via npm run build, not the test run. The docs script pins a [email protected]/typedoc pair via npx for the same reason — TypeDoc also needs that Compiler API.
Integration tests (src/__tests__/integration/) use Testcontainers to spin up real, well-known Bonjour-advertising software — not a synthetic fixture — and exercise this tool's browse/parse path against their actual output:
homebridge/homebridge— advertises_hap._tcp; also exercises the apt-get auto-install path foravahi-browse.mikebrady/shairport-sync— advertises_raop._tcp/_airplay._tcp(AirPlay audio); exercises the apk auto-install path. Its always-running sshd (_ssh._tcp/_sftp-ssh._tcp) also verifies the-kfriendly-name substitution described above, and that--excludecan silence that kind of noise.
Limitations
- This only solves discovery. The container's port must already be published normally (
docker run -p <port>:<port>) for the host's advertisement to actually be reachable. - TXT records containing binary/non-UTF8 data are not supported (Avahi's parsable output is text).
- One mDNS domain is supported per run:
local(the standard LAN discovery domain).
License
MIT
