jimyag's Blog

用 mDNS 和 DNS-SD 发现局域网服务

Last modified:

打开打印机列表,电脑就能列出附近的打印机;设备换了 IP,列表往往仍能找到它。应用通过服务发现取得打印机当前的地址,用户无需提前填写固定 IP。

mDNS 和 DNS-SD 配合完成服务发现:mDNS 让同一链路上的设备直接交换 DNS 查询和响应,DNS-SD 规定怎样用 DNS 记录列出服务,并取得主机、端口和属性。本文用一个 Python HTTP 服务演示发布、发现和连接,再与 LocalSend 的自定义多播方案对照。

从多播理解 mDNS

普通 DNS 查询通常发给配置好的 DNS 服务器。mDNS(Multicast DNS,多播 DNS)把查询发到一个约定的多播组,由知道答案的设备响应。

多播(Multicast,也叫组播)表示把一个数据包发给一组接收者。单播指定一个接收者;广播面向同一广播域中的设备;多播则使用组地址,接收程序在对应网卡上加入这个组。组地址不属于某一台机器,也不需要配置成机器自己的 IP。

交换机是否只向特定端口转发多播流量,取决于 IGMP snooping(监听组成员关系)等网络配置。加入多播组不提供加密或访问控制。

mDNS 使用 UDP 5353,IPv4 组地址是 224.0.0.251,IPv6 是 ff02::fb。.local. 是 mDNS 使用的特殊域;这些查询在本地链路上完成。相关规定见 RFC 6762 的介绍与名称解析规则。

假设局域网里有一台设备使用 printer.local.:

1
2
3
电脑 --多播查询 printer.local. 的 A 记录--> mDNS 组
打印机 --回答 printer.local. = 192.168.1.50--> 查询者所在链路
电脑 --连接 192.168.1.50--> 打印机

响应通常也是多播,查询者也可以请求单播响应。RFC 6762 查询规则

设备会缓存记录、探测名称冲突,并主动公告记录。正常离开时,可以发送 TTL 为 0 的 goodbye 报文,让其他设备删除缓存。冲突探测、goodbye 报文

DNS 记录的 TTL(Time to Live)表示缓存寿命,与 IP 数据包中用于限制转发的 TTL 是两个字段。设备异常断电时,无法发送 goodbye,客户端仍可能短暂保留旧记录。

DNS-SD 怎样发现服务

DNS-SD 的 SD 是 Service Discovery,即服务发现;完整名称是 DNS-Based Service Discovery,意为基于 DNS 的服务发现。

查询 printer.local. 的 IP,前提是已经知道这台打印机的名字。服务发现处理的是更早一步的问题:客户端只知道自己需要哪类服务,例如打印服务,要找出网络里有哪些实例,再取得每个实例的连接信息。

DNS-SD 约定了服务名称和 DNS 记录的组织方式。服务端按约定发布记录,客户端按同一约定查询,就能从服务类型找到实例,再从实例找到主机、端口和属性。它复用已有 DNS 记录,无需新增报文格式或记录类型。RFC 6763 介绍

mDNS 负责把查询和响应送到本地链路上的设备;DNS-SD 负责规定查询什么名称、怎样从回答中取得服务信息。一个程序即使能用 mDNS 解析 .local 主机名,也需要发布 DNS-SD 服务记录,才能出现在按服务类型查询的列表里。

以本文的 HTTP 示例为例:

记录查询名称回答的含义
PTR_mdnsdemo._tcp.local.有一个实例叫 Demo._mdnsdemo._tcp.local.
SRVDemo._mdnsdemo._tcp.local.服务位于 mdnsdemo-c0a802e2.local.,端口为 8000
TXTDemo._mdnsdemo._tcp.local.属性为 path=/hello、version=1
Amdnsdemo-c0a802e2.local.主机 IPv4 地址为 192.168.2.226

PTR 用于枚举实例,SRV 给出目标主机和端口,TXT 保存服务约定的属性,A/AAAA 解析目标主机地址。DNS-SD 的记录结构见 RFC 6763。

_mdnsdemo._tcp.local. 中,_mdnsdemo 是服务名称,_tcp 表示业务连接使用 TCP,local. 是发现域。发现报文本身仍通过 mDNS 的 UDP 5353 发送。实例名称 Demo 用于区分同类服务,和 SRV 中的目标主机名是两个概念。

本文的 mdnsdemo 名称只用于实验。公开协议应按 RFC 6763 的服务名称规则选择已有名称或申请登记,避免不同应用碰巧使用相同类型。

以客户端寻找本文的 HTTP 示例为例,发现过程按下面的关系展开:

  1. 客户端预先知道服务类型 _mdnsdemo._tcp,查询 _mdnsdemo._tcp.local. 的 PTR 记录,取得实例列表,例如 Demo._mdnsdemo._tcp.local.。
  2. 客户端查询选中实例的 SRV 和 TXT 记录,得到目标主机 mdnsdemo-c0a802e2.local.、端口 8000 和路径 /hello。
  3. 客户端查询目标主机的 A 记录,取得 192.168.2.226,再通过 HTTP 请求 http://192.168.2.226:8000/hello。

客户端与服务端需要使用相同的服务类型,但客户端无需预先知道实例名称、IP 或端口。上面列的是记录之间的依赖关系;响应可以同时携带多种记录,减少后续查询。发现完成后,HTTP 请求直接发给服务端。

DNS-SD 也能运行在普通单播 DNS 上,由 DNS 服务器保存服务记录。跨网段的服务目录可以使用这种方式。RFC 6763 介绍

客户端仍需要知道查询什么

服务发现需要一个预先约定的查询入口。应用通常知道自己支持的服务类型:打印客户端查找 _ipp._tcp,本文示例查找 _mdnsdemo._tcp。这个类型可以写在程序中,或由配置提供;具体设备的名称、IP 和端口则从发现结果中取得。

前面的 printer.local. 示例演示的是已知主机名时的名称解析。发现打印机时,客户端先查询 _ipp._tcp.local. 的 PTR 记录,得到打印服务实例列表。用户选中某个实例后,客户端查询它的 SRV 记录,才知道目标主机是 printer.local.、端口是 631,随后再解析主机地址。printer.local. 无需写死在客户端中。

DNS-SD 还提供了枚举服务类型的公共入口。客户端查询 _services._dns-sd._udp.local. 的 PTR 记录,可以取得该域内公告的服务类型,例如 _ipp._tcp.local. 或 _http._tcp.local.,再分别查询这些类型的实例。此时预先约定的是枚举入口,无需先列出每一种具体服务类型。RFC 6763 服务类型枚举

枚举类型适合网络浏览和诊断工具。发现一种类型,只能告诉客户端这种服务存在;要使用它,客户端仍需理解对应的业务协议和属性含义。例如找到 IPP 打印服务后,还需要支持 IPP 的客户端发送打印请求。DNS-SD 解决的是服务位置的发现,业务协议由应用实现。

一个能发布和发现服务的程序

示例用 Python 标准库启动 HTTP 服务,用 python-zeroconf处理 mDNS 报文和 DNS-SD 记录。依赖固定为本次验证的 zeroconf==0.151.5,DNS 编码、缓存和冲突探测由库处理。

下载这两个文件,放进同一个目录:

  • discovery.py:发布或发现服务。
  • check.py:启动两个独立进程,发现服务后请求 /hello 并检查响应。

需要 Python 3.10+ 和 uv。下面的命令在文件所在目录执行。uv run --with 会准备依赖环境,无需修改系统 Python。

先确认网卡地址

服务端和客户端都要传入自己连接局域网的 IPv4 地址。程序用这个地址选择 mDNS 网卡,服务端还用它绑定 HTTP 监听地址、发布 A 记录。

macOS 可以先查看硬件端口和接口,再查对应接口的地址:

1
2
networksetup -listallhardwareports
ipconfig getifaddr en0

en0 只是例子,Wi-Fi 或有线接口不一定叫这个名字。Linux 可以使用 ip -4 addr,Windows 可以使用 ipconfig。选择局域网网卡的地址,避免误用 VPN 或 Docker 网桥地址。

发布 HTTP 服务

在设备 A 上运行,替换为 A 的实际地址:

1
2
uv run --with zeroconf==0.151.5 python discovery.py \
  serve --ip 192.168.2.226 --port 8000 --name Demo

终端出现 Published ... 后,服务保持运行。HTTP 仅监听指定 IP 的 8000 端口,GET /hello 返回固定文本,其他路径返回 404。

发布记录的核心代码是:

1
2
3
4
5
6
7
8
9
info = ServiceInfo(
    SERVICE_TYPE,
    f"{name}.{SERVICE_TYPE}",
    parsed_addresses=[ip],
    port=server.server_port,
    properties={"path": "/hello", "version": "1"},
    server=f"mdnsdemo-{ipaddress.IPv4Address(ip).packed.hex()}.local.",
)
zc.register_service(info, allow_name_change=True)

SERVICE_TYPE 是 _mdnsdemo._tcp.local.。库根据 ServiceInfo 中的数据生成 PTR、SRV、TXT 和 A 记录,再通过 register_service 注册并公告服务。

allow_name_change=True 允许库在实例重名时调整名称,终端打印的是实际注册后的名称。

按 Ctrl+C 停止服务时,程序调用 unregister_service 撤销公告,再关闭 HTTP 和 mDNS socket。

发现服务并连接

在同一局域网的设备 B 上运行,--ip 填 B 自己的地址:

1
2
uv run --with zeroconf==0.151.5 python discovery.py \
  discover --ip 192.168.2.227 --timeout 10

程序浏览 _mdnsdemo._tcp.local.,发现首个能够解析的实例后输出 JSON 并退出。它不会自动访问收到的地址。没有发现服务时,超时退出并返回非零状态。

核心调用是:

1
2
browser = ServiceBrowser(zc, SERVICE_TYPE, listener=Listener(names))
info = zc.get_service_info(SERVICE_TYPE, name, timeout=1000)

ServiceBrowser 监听服务变化,回调把实例名称放进队列。主线程再调用 get_service_info,取得实例的主机、地址、端口和属性。完整的超时等待与清理代码在 discovery.py 中。

确认输出对应自己的设备后,使用发现结果中的地址和端口连接:

1
curl --noproxy '*' http://192.168.2.226:8000/hello

预期输出:

1
Hello from DNS-SD demo!

这里直接连接解析出的 IP,因此不依赖操作系统是否支持 .local 名称解析。mDNS 的发现端口是 UDP 5353,HTTP 的业务端口是 TCP 8000,两者可以不同。

运行结果

同机检查程序

2026-10-07,在一台 macOS 主机上使用局域网网卡地址 192.168.2.226,运行:

1
uv run --with zeroconf==0.151.5 python check.py --ip 192.168.2.226

检查程序让服务端使用 --port 0,由操作系统分配空闲 HTTP 端口。发现进程等到服务记录后,再请求该端口的 /hello。实际输出如下,实例后缀和端口每次可能不同:

1
2
{"name": "Check-86776ee3._mdnsdemo._tcp.local.", "server": "mdnsdemo-c0a802e2.local.", "addresses": ["192.168.2.226"], "port": 55745, "properties": {"path": "/hello", "version": "1"}}
PASS: discovered service and fetched /hello

检查通过表明,发现进程取得了服务端发布的地址、端口和 TXT 属性,并成功请求 /hello。服务进程在检查结束后已停止。另外检查了无服务时的超时退出,以及多播地址、非正超时参数的拒绝行为。

同机检查用于验证程序的发布、解析和连接行为。设备间的多播传播还需要双机验证。

macOS 与 m6 双机验证

2026-10-08,继续使用两台设备验证:macOS 的局域网地址为 192.168.2.226,Ubuntu 主机 m6 的地址为 192.168.2.100。两台机器都使用 zeroconf==0.151.5,HTTP 端口由 --port 0 自动分配。

先在 m6 发布 M6-Demo,再在 macOS 执行发现命令。本机成功输出:

1
{"name": "M6-Demo._mdnsdemo._tcp.local.", "server": "mdnsdemo-c0a80264.local.", "addresses": ["192.168.2.100"], "port": 35941, "properties": {"path": "/hello", "version": "1"}}

随后在 macOS 请求解析出的地址:

1
curl --noproxy '*' http://192.168.2.100:35941/hello

实际返回 Hello from DNS-SD demo!。

反向验证时,先在 m6 运行发现命令,再启动 macOS 服务。m6 收到服务启动时的公告,成功输出:

1
{"name": "Mac-Demo._mdnsdemo._tcp.local.", "server": "mdnsdemo-c0a802e2.local.", "addresses": ["192.168.2.226"], "port": 56179, "properties": {"path": "/hello", "version": "1"}}

m6 随后请求 http://192.168.2.226:56179/hello,也返回了预期文本。

双机实验验证了服务信息在设备之间的传播,以及根据发现结果建立 HTTP 连接的过程。结束后已停止示例进程,并删除 m6 临时目录中的脚本。

LocalSend 使用的是哪种方案

LocalSend 同样利用多播发现设备,但使用自己的 JSON 消息格式。官方 v2.2 协议定义的默认组地址是 224.0.0.167,UDP 端口是 53317。

启动时,设备多播公告名称、类型、服务端口等信息。其他设备优先通过 HTTP/HTTPS 的 /api/localsend/v2/register 接口回复,也可以用多播回复作为备用方式。文件传输随后使用 HTTP/HTTPS。

项目本文示例LocalSend v2.2
发现消息DNS 报文中的服务记录自定义 JSON
默认多播地址224.0.0.251224.0.0.167
默认发现端口UDP 5353UDP 53317
服务连接信息SRV、TXT、A 记录JSON 字段
发现后的业务通信HTTPHTTP/HTTPS

LocalSend 官方解释,选用 224.0.0.0/24 是为了兼容部分拒绝其他多播组的 Android 设备。这是具体产品的兼容性选择,不能据此随意占用该地址段。LocalSend 默认配置

自己设计多播协议时,应检查地址分配。IPv4 的 239.0.0.0/8 是管理域多播空间,239.255.0.0/16 定义为本地范围;仍要避开已有用途并在网络内协调地址。地址的传播范围需要通过网络配置限制,仅选用管理域地址不会自动阻止跨网段转发。RFC 2365、IANA 多播地址表

如果应用只需要发布名称、端口和少量属性,mDNS + DNS-SD 已经提供了对应机制。自定义多播协议还要自己约定消息格式、重试、去重和设备离线处理。

发现不到服务时查什么

先在服务端直接请求 /hello。如果 HTTP 本身不可用,检查发布进程、绑定 IP 和业务端口;本机 HTTP 可用但另一台机器连接失败,再检查 TCP 端口、防火墙和设备间隔离。

HTTP 可以直连,但服务发现失败时,重点检查 UDP 5353、选用的网卡以及网络对多播的处理。两台设备连着同一个 Wi-Fi 名称,也可能因为访客网络或客户端隔离而不能互通。

mDNS 默认局限于本地链路。跨 VLAN 发现需要专门的 mDNS 网关或反射器;这类设备只解决发现报文的传播,发现后的业务连接仍需要路由和防火墙放行。也可以使用单播 DNS-SD,让服务目录由 DNS 服务器提供。

发现结果不能证明服务身份。示例的发现命令只展示信息,HTTP 也没有认证或加密;实际应用仍需在连接阶段验证服务身份、控制权限。TXT 记录适合放路径、版本等公开属性,不应放密码或访问令牌。RFC 6763 安全考虑

#DNS #MDNS #DNS-SD #网络 #Python