A container that relays mDNS and SSDP between VLANs, so Chromecasts, Sonos speakers, printers and other discovery-based devices on one network can be found from another.
Originally built for the UniFi Dream Machine, but it works on any multi-homed Linux host.
This packages alsmith/multicast-relay — all the relaying logic is theirs. This repository provides the container, the hardening and the build pipeline.
docker run -d --name ssdp-relay --restart=always \
--network=host \
-e INTERFACES="br0 br50" \
ghcr.io/scyto/multicast-relay--network=host is mandatory — the relay has to see the host's bridges. Replace br0 br50 with your own interfaces; see finding your interfaces below.
UniFi OS users: current UniFi OS ships Docker, so these commands work as written. Older UniFi OS releases shipped
podmaninstead — on those, substitutepodmanfordockerthroughout.
Out of the box this relays:
| Protocol | Address |
|---|---|
| mDNS | 224.0.0.251:5353 |
| SSDP | 239.255.255.250:1900 |
| Sonos discovery | 255.255.255.255:1900 (broadcast) |
| Sonos setup | 255.255.255.255:6969 (broadcast) |
Published to GitHub Container Registry (primary) and mirrored to Docker Hub. Both get every build from the same multi-arch manifest, so the two are byte-identical — if you already pull from Docker Hub, nothing changes for you.
docker pull ghcr.io/scyto/multicast-relay:latest # preferred
docker pull scyto/multicast-relay:latest # still fully supportedPlatforms: linux/amd64, linux/arm64, linux/arm/v7, linux/arm/v6.
Tags fall into two groups, and the difference matters if you care about reproducibility.
Immutable — published once and never repointed. CI fails the build rather than overwrite one:
| Tag | Meaning |
|---|---|
1.2.3 |
One exact release. Always the same image. |
master-<sha> |
One exact build of one commit on master. |
Moving — pointers meant to be reassigned:
| Tag | Meaning |
|---|---|
latest |
The newest release. Only a v* git tag moves it; merging to master does not. |
1.2 / 1 |
Newest patch in that series, so you pick up fixes automatically. |
edge |
Newest build from master. Untested-in-the-wild code — expect it to change under you. |
For a working relay, use latest. To know exactly what you are running, pin 1.2.3, or pin the digest, which survives even tag deletion:
docker pull ghcr.io/scyto/multicast-relay@sha256:<digest>Every release records its digest in the release notes.
Everything is configured through environment variables.
| Variable | Default | Meaning |
|---|---|---|
INTERFACES |
br0 br50 |
Set this. Space-separated interfaces to relay between, minimum two. The default assumes LAN on br0 and an IoT VLAN 50 on br50, which is unlikely to match your network. |
OPTS |
(empty) | Space-separated extra options passed to the relay. See below. |
TZ |
America/Los_Angeles |
Timezone for log timestamps. |
K8SPORT |
(empty) | If you pass --k8sport <port> in OPTS, set this to the same port and the healthcheck probes that HTTP endpoint instead of just checking the process is alive. |
On UniFi hardware interfaces are named brN, where N is the VLAN ID — br0 is the default LAN (no VLAN / VLAN 1), br50 is VLAN 50, and so on.
The image can list them for you:
docker run --rm --network=host ghcr.io/scyto/multicast-relay ls /sys/class/netIf you name an interface that does not exist, the container exits immediately and prints the ones it can see, so a typo is self-diagnosing.
Set these via OPTS, e.g. -e OPTS="--verbose --noMDNS".
Commonly used:
| Option | Effect |
|---|---|
--verbose |
Log every relayed packet. The first thing to turn on when something is not working. |
--noMDNS |
Do not relay mDNS. Use this if your router already has its own mDNS reflector enabled, to avoid duplicate relaying. |
--noSSDP |
Do not relay SSDP. |
--noSonosDiscovery |
Do not relay the broadcast Sonos discovery packets (udp/1900 and udp/6969). |
--ttl N |
Set the TTL on outbound packets. Occasionally needed for devices that drop low-TTL traffic. |
--relay ADDR:PORT |
Relay an additional multicast address, for protocols not handled by default. |
--noTransmitInterfaces IF |
Listen on these interfaces but never transmit to them — useful for a one-way relay. |
--ifFilter FILE.json |
Restrict which interfaces a given source IP may relay to. Requires mounting the JSON file into the container. |
--k8sport N |
Run an HTTP liveness endpoint on this port. Pair with the K8SPORT variable above. |
Also available: --oneInterface, --mdnsForceUnicast, --ssdpUnicastAddr, --masquerade, --wait, --allowNonEther, --homebrewNetifaces, --ifNameStructLen, and the remote-relay set (--listen, --remote, --remotePort, --remoteRetry, --noRemoteRelay, --aes) for linking relays across sites.
For the authoritative list:
docker run --rm ghcr.io/scyto/multicast-relay \
python3 /multicast-relay/multicast-relay.py --helpTwo notes:
--foregroundis applied automatically by the entrypoint. You do not need to add it.--logfilewrites to disk, which fails under the recommended--read-onlyflag unless you mount a writable volume for it. Container logs (docker logs) are usually what you want instead.
Several VLANs, SSDP only — relay between LAN, VLAN 50 and VLAN 60, letting the router handle mDNS itself:
docker run -d --name ssdp-relay --restart=always --network=host \
-e INTERFACES="br0 br50 br60" \
-e OPTS="--verbose --noMDNS" \
ghcr.io/scyto/multicast-relayLAN as a management VLAN — where every network is a numbered VLAN:
docker run -d --name ssdp-relay --restart=always --network=host \
-e INTERFACES="br10 br75 br90" \
ghcr.io/scyto/multicast-relayWatch it work — run in the foreground with verbose logging, and stop with Ctrl-C:
docker run --rm -it --network=host \
-e INTERFACES="br0 br50" -e OPTS="--verbose" \
ghcr.io/scyto/multicast-relayThe relay needs --network=host and exactly one Linux capability, CAP_NET_RAW, to open the raw sockets it relays with. It needs nothing else, so take everything else away:
docker run -d --name ssdp-relay --restart=always \
--network=host \
--cap-drop=ALL --cap-add=NET_RAW \
--security-opt no-new-privileges \
--read-only --tmpfs /tmp \
-e INTERFACES="br0 br50" \
ghcr.io/scyto/multicast-relayThis exact configuration is verified in CI on every build, so it is supported rather than merely suggested. --cap-drop=ALL --cap-add=NET_RAW takes the container from the ~14 capabilities Docker grants by default down to one.
services:
multicast-relay:
image: ghcr.io/scyto/multicast-relay:latest
container_name: ssdp-relay
restart: always
network_mode: host
cap_drop: [ALL]
cap_add: [NET_RAW]
security_opt: [no-new-privileges:true]
read_only: true
tmpfs: [/tmp]
environment:
INTERFACES: "br0 br50"
OPTS: ""
TZ: "Europe/London"docker pull ghcr.io/scyto/multicast-relay:latest
docker stop ssdp-relay && docker rm ssdp-relay
# then re-run your original docker run commandWith Compose: docker compose pull && docker compose up -d.
There is no state to preserve — the relay keeps nothing on disk.
ERROR: interface(s) not visible to this container — the container prints the interfaces it can see. This nearly always means --network=host was omitted, or an interface name is wrong.
Devices still do not appear. Work through it in this order:
- Run with
-e OPTS="--verbose"and watchdocker logs -f ssdp-relay. If you see packets being relayed, the relay is doing its job and the problem is elsewhere on the network. - Check firewall rules between the VLANs — see below.
- If your router has its own mDNS reflector enabled, run with
--noMDNSso the two are not both relaying. - Confirm the device actually uses mDNS or SSDP. Some discovery protocols use neither, and
--relaymay be needed for those.
Is it healthy? docker ps shows a health column for this image, or query it directly:
docker inspect -f '{{.State.Health.Status}}' ssdp-relayIt will not stop cleanly. It should stop in about a second. If docker stop takes 10 seconds you are running an old image — pull again.
Discovery is only half the problem. Once devices have found each other they open ordinary unicast connections, and those must be permitted too. With Sonos, for example, the speakers connect back to the phone running the controller app, so traffic from the Sonos VLAN to the client VLAN has to be allowed.
On a Linux host that filters multicast on input, you may need:
sudo iptables -I INPUT -m pkttype --pkt-type multicast -j ACCEPTRemember to persist iptables rules once everything works, or they vanish on reboot.
- Pinned, verified upstream. The relay source is pinned to an exact upstream commit and every file is SHA256-verified at build time. The image previously ran
git cloneagainst upstreammasterduring the build, so each rebuild shipped whatever HEAD happened to be, with no way to reproduce an earlier image. A daily workflow proposes pin bumps as reviewable pull requests, so the pin stays current without the build being unpredictable. - Digest-pinned base image. Alpine is pinned by digest, not just tag.
- Minimal runtime. A multi-stage build keeps
git,curlandca-certificatesout of the shipped image; onlypython3,py3-netifaces,tzdataandtiniremain. CI fails the build if a build tool reappears in the runtime image. - No setuid binaries. Every setuid/setgid bit is stripped and CI asserts none come back — which matters because this container runs in the host network namespace.
- Runs on one capability. Verified in CI with
--cap-drop=ALL --cap-add=NET_RAW,no-new-privilegesand a read-only root filesystem. - Immutable release tags. Neither registry enforces tag immutability on these plans, so CI does: a build that would overwrite an existing
X.Y.Zormaster-<sha>tag fails before pushing. What1.2.3means cannot change after the fact. - Vulnerability scanned. Every build is scanned with Trivy and fails on fixable HIGH/CRITICAL findings.
- Protected default branch.
masterrequires a pull request, blocks force-pushes and deletion, and requires CI to pass, so nothing reaches a published image unchecked. - Dependency updates. Dependabot raises weekly PRs for GitHub Actions and the pinned Alpine base, so digest pinning cannot quietly become staying unpatched.
- Signed provenance and SBOM. Images ship an SBOM and a SLSA build provenance attestation:
gh attestation verify oci://ghcr.io/scyto/multicast-relay:latest --repo scyto/multicast-relay
- Correct signal handling.
tiniruns as PID 1 and forwards SIGTERM to the relay, sodocker stopis immediate rather than waiting out the 10s timeout and SIGKILLing. The kernel discards default-disposition signals aimed at PID 1 andmulticast-relay.pyinstalls no SIGTERM handler, so without an init process the relay simply ignores SIGTERM. CI asserts the container exits 143, not 137.
| Workflow | Trigger | Does |
|---|---|---|
build.yml |
push to master, v*.*.* tags, PRs, manual |
Builds, smoke-tests, scans, then publishes the multi-arch image to GHCR and Docker Hub. PRs build and test but never push. |
lint.yml |
push / PR touching build files | hadolint, shellcheck, actionlint, and a check that tracked-versions.json agrees with the Dockerfile. |
check-upstream.yml |
daily 06:00 UTC, manual | Polls upstream for new commits; re-pins, recomputes checksums and opens a PR with the bump for review. Merging it moves :edge only, never :latest. |
The smoke test does more than start the container. It asserts all four relays come up, that effective capabilities are exactly CAP_NET_RAW, that tini is PID 1 with the relay as its child, that the healthcheck passes, that no setuid binaries or build tools survive in the image, and that the container exits 143 (SIGTERM honoured) rather than 137 (SIGKILL after timeout).
master builds only ever move :edge. To publish a release and move :latest:
git tag v1.2.3 && git push origin v1.2.3That publishes :1.2.3, :1.2, :1 and :latest, then creates a GitHub release recording the image digest. Re-tagging an already published version is refused by the immutability check — cut a new patch version instead.
.github/tracked-versions.json is the single source of truth for the upstream commit, its file checksums and the Alpine base digest. The Dockerfile's ARG defaults mirror it, so a plain local docker build with no arguments produces the same image CI does; lint.yml fails if the two drift.
docker build -t multicast-relay:local .To check a change still builds for every published platform, without pushing:
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
--output type=cacheonly .Publishing is CI's job — a hand-pushed :latest would bypass the smoke test and vulnerability scan and overwrite a verified image with an unverified one.
Docker Hub mirroring requires two repository secrets:
| Secret | Value |
|---|---|
DOCKERHUB_USERNAME |
Docker Hub username |
DOCKERHUB_TOKEN |
Docker Hub access token with Read & Write scope |
Without them the build still succeeds and publishes to GHCR, logging a warning that Docker Hub was skipped. GHCR needs no secrets — the built-in GITHUB_TOKEN covers it.
The relay itself is alsmith/multicast-relay by Al Smith. This repository packages it as a container; the heavy lifting is upstream's.
Two licences apply, because the image combines two works:
| Part | Licence |
|---|---|
| This repository — Dockerfile, entrypoint, healthcheck, CI, docs | MIT |
multicast-relay.py and ssdpDiscover.py, fetched at build time |
GPL-3.0 |
The published image therefore contains GPL-3.0 code and is labelled MIT AND GPL-3.0-only. If you redistribute the image, the GPL's terms apply to the relay it contains.