A small self-hosted tool that lists every mDNS/Bonjour service and every SSDP/UPnP device on your local network, live, in a dark web UI. Click an entry to see everything the device gives away: IP addresses, hostname, port, TXT records (firmware, model, manufacturer, MAC, serial number, …), TTLs, UPnP services, and the other services running on the same host.
🇩🇪 Deutsche Fassung: README.de.md
| mDNS / Bonjour tab | SSDP / UPnP tab |
|---|---|
![]() |
![]() |
- mDNS / Bonjour: browses
_services._dns-sd._udpfor advertised service types, starts a browser for every type found, plus a seed list of ~40 common types (HomeKit, Matter, Chromecast, AirPlay, Sonos, printers, SSH/SMB, ESPHome, Shelly, Home Assistant, GL.iNet, …). - SSDP / UPnP: the devices Windows shows under Network → Media devices / Other devices
(TVs, Sonos, NAS, AV receivers, set-top boxes, routers). Sends
M-SEARCH, listens forNOTIFY, fetches and parses each device description XML. - Detail view: highlighted fields (firmware, model, manufacturer, MAC, serial), network
table, all TXT records, UPnP service list with control URLs, embedded devices,
vendor-specific fields (e.g. Sonos
roomName,softwareVersion), raw JSON, one-click copy. - Live updates via Server-Sent Events, search across all fields, type filter, sorting, offline entries stay visible greyed out, keyboard navigation.
- Zero dependencies besides Python and
zeroconf; the UI is a single HTML file, no CDN. - Docker/Compose ready (host networking), runs as an unprivileged user with a read-only filesystem.
git clone https://github.com/macpit/mdns-viewer.git
cd mdns-viewer
docker compose up -d --buildOpen http://<docker-host-ip>:8765/.
Update with git pull && docker compose up -d --build, logs with docker compose logs -f.
Why network_mode: host: mDNS (224.0.0.251:5353) and SSDP (239.255.255.250:1900) are
multicast. Inside the default Docker bridge network none of it arrives, so the container
shares the host's network stack instead. That is also why compose.yaml has no ports:
mapping; the port is opened directly on the host.
Linux hosts only. Docker Desktop on macOS or Windows runs containers in a Linux VM behind
NAT; LAN multicast never reaches the VM, not even with network_mode: host. On a Mac, run the
script natively (see below). Proxmox LXC/VM, Raspberry Pi, NAS with Docker and plain Linux
servers all work.
| Environment variable | Meaning |
|---|---|
MDNS_VIEWER_HOST |
Bind address, 0.0.0.0 in the image (native default 127.0.0.1) |
MDNS_VIEWER_PORT |
HTTP port, default 8765 |
MDNS_VIEWER_NO_SSDP |
1 disables the SSDP/UPnP tab |
TZ |
Time zone for log timestamps |
Exposure note: with 0.0.0.0 the UI is reachable on your LAN without a login. It only
shows information the devices already broadcast, and the only action is "Rescan". Put a
reverse proxy with authentication in front of it or firewall the port if that matters to you.
Requirements: Python 3.13 (macOS: brew install python@3.13).
git clone https://github.com/macpit/mdns-viewer.git
cd mdns-viewer
python3.13 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python mdns_viewer.pyThe server starts on http://127.0.0.1:8765/ and opens your default browser. Stop with Ctrl+C.
| Option | Meaning |
|---|---|
--host ADDRESS |
Bind address (default 127.0.0.1; 0.0.0.0 for LAN/container) |
--port N |
HTTP port (default 8765) |
--no-browser |
Don't open the browser automatically |
--json SECONDS |
Headless: scan for N seconds, print JSON to stdout, exit |
--types _a._tcp,_b._udp |
Browse additional mDNS service types |
--backend auto|zeroconf|dns-sd |
Discovery backend (see below), default auto |
--ip-version v4|all |
zeroconf transport, default v4 (see below) |
--no-ssdp |
Disable SSDP/UPnP discovery (second tab) |
-v / --verbose |
Verbose logging |
Example: one-off scan as JSON for further processing
.venv/bin/python mdns_viewer.py --json 8 > scan.json- Tabs in the header (keys
1/2): mDNS and SSDP / UPnP, each with a counter of entries currently online. Search, type filter, sorting and the offline switch apply to the active tab. - List (left): name, type pill, IP:port, hostname and, when available, firmware/model. New entries flash teal. Entries that went away stay visible greyed out ("Show offline").
- Search (key
/): matches name, type, hostname, IPs and all TXT values / device fields. Multiple words are AND-combined. - Detail (right) after clicking an entry (or ↑/↓):
- online/offline status, "Open" for web services (HTTP, Home Assistant, Shelly, ESPHome, GL.iNet, UPnP presentation URL, …), "Copy JSON"
- Highlighted: firmware/version, model, manufacturer, MAC, serial number, device name, board, features, extracted heuristically from TXT records or the UPnP description
- Network: hostname, IPv4/IPv6, port, priority/weight, TTLs, timestamps
- TXT records (mDNS) or device / SSDP / services / embedded devices / vendor fields / SSDP headers (UPnP), values copy to the clipboard on click
- other services on the same host as clickable chips, collapsible raw JSON
- Rescan: recreates all mDNS browsers, re-resolves known entries and sends an SSDP M-SEARCH round.
- Browsing
_services._dns-sd._udp.local.yields the service types devices advertise; a new browser is started for every type found. - A seed list of ~40 common types is browsed directly as well, because not every device answers the type enumeration.
- Every service is resolved (SRV, TXT, A/AAAA). If the IPv4 address is missing (typical for Chromecast) the tool retries once and then asks the system resolver.
- Changes are pushed to the browser immediately via Server-Sent Events.
- Every 30 s (three times in quick succession at start-up) an
M-SEARCHforssdp:allandupnp:rootdevicegoes to239.255.255.250:1900on every IPv4 interface. - Port 1900 is monitored as well:
NOTIFY ssdp:aliveadds devices instantly,ssdp:byebyemarks them offline. Devices that neither answer nor announce for ~100 s go offline too. - For every
LOCATIONURL the UPnP device description (XML) is fetched and parsed (4 s timeout, 1 MB max). One device = one description URL; several UDNs of the same description (embedded devices) are grouped.
- zeroconf (default): pure-Python mDNS implementation. Provides TTLs and priority/weight and is the fastest.
- dns-sd (macOS fallback): uses the macOS
dns-sdtool, which talks to the system mDNSResponder.
Background: since macOS 15 every process needs the "Local Network" privacy permission to
send multicast. Without it all zeroconf sockets fail with No route to host. At start-up the
script therefore sends a multicast probe and falls back to dns-sd automatically. This is
logged and shown in the status pill ("Live · dns-sd").
In dual-stack mode (IPVersion.All) the replies of many embedded devices got lost on macOS:
GL.iNet KVM (_glinet._tcp), a Eufy camera (_hap._tcp), Shelly and SLZB (_http._tcp)
were reproducibly never found, while IPv4-only mode found all of them every time. Apple and
Sonos devices showed up in both modes because they announce themselves periodically. IPv6
addresses of services are still shown, because the AAAA records arrive as additional records
inside the IPv4 answers. To test dual-stack: --ip-version all.
The first time you start it from the Terminal, macOS asks whether the Terminal may find devices on the local network. Allow it. To change it later: System Settings → Privacy & Security → Local Network for the app you launch it from (Terminal, iTerm, VS Code, …).
| Symptom | Cause / fix |
|---|---|
Log: weiche auf dns-sd aus (falling back to dns-sd) |
"Local Network" permission missing for the launching app, see above. Still works, just slower and without TTL/priority. |
| No entries, not even with dns-sd | Active VPN that tunnels everything, wrong Wi-Fi, or a guest network with client isolation. Cross-check: dns-sd -B _services._dns-sd._udp local. |
| Container finds nothing | Not running with network_mode: host, or Docker Desktop on macOS/Windows (see above). |
Port 8765 cannot be bound |
Another process uses the port: use --port 9000 / MDNS_VIEWER_PORT. |
| Device shows IPv6 only | The device doesn't answer A queries immediately. After a few seconds or a rescan the IPv4 address usually appears. |
Entries with suffixes (2), (3) |
mDNS name-conflict resolution done by the device itself (e.g. after reboots), not duplicates created by the viewer. |
| Same TV listed several times in the UPnP tab | The TV publishes one UPnP description per function (MediaRenderer, MediaServer, DIAL). They are cross-linked in the detail view. |
Cross-check with built-in macOS tools:
dns-sd -B _services._dns-sd._udp local. # all service types
dns-sd -L "Name" _hap._tcp local. # resolve one servicemdns_viewer.py backend: discovery (zeroconf / dns-sd / SSDP), HTTP + SSE server, CLI
static/index.html UI (HTML, CSS, JS in one file, no external dependencies)
requirements.txt zeroconf
Dockerfile container image (python:3.13-slim, unprivileged user, healthcheck)
compose.yaml Docker Compose with network_mode: host
docs/ screenshots
LICENSE MIT
| Endpoint | Description |
|---|---|
GET /api/services |
JSON snapshot mDNS: services[] + stats + backend |
GET /api/ssdp |
JSON snapshot UPnP: devices[] + stats |
GET /api/events |
Server-Sent Events added, updated, removed, ssdp_added, ssdp_updated, ssdp_removed, heartbeat |
POST /api/rescan |
Trigger a new scan (mDNS + SSDP) |
An mDNS service entry contains among others name, instance, type, server, port,
addresses_v4, addresses_v6, txt (all TXT keys), highlights, host_ttl, other_ttl,
online, first_seen, last_seen. A UPnP entry adds manufacturer, model_name,
model_number, serial_number, udn, location, presentation_url, server_header,
services[], embedded[], extra{} and headers{}.
In headless mode (--json) the output additionally contains the key ssdp with the same
structure as GET /api/ssdp.
MIT, see LICENSE.

