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

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

<!--more-->

## 从多播理解 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 的介绍与名称解析规则](https://www.rfc-editor.org/rfc/rfc6762.html#section-3)。

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

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

响应通常也是多播，查询者也可以请求单播响应。[RFC 6762 查询规则](https://www.rfc-editor.org/rfc/rfc6762.html#section-5)

设备会缓存记录、探测名称冲突，并主动公告记录。正常离开时，可以发送 TTL 为 0 的 goodbye 报文，让其他设备删除缓存。[冲突探测](https://www.rfc-editor.org/rfc/rfc6762.html#section-8)、[goodbye 报文](https://www.rfc-editor.org/rfc/rfc6762.html#section-10.1)

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 介绍](https://www.rfc-editor.org/rfc/rfc6763.html#section-1)

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

以本文的 HTTP 示例为例：

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

PTR 用于枚举实例，SRV 给出目标主机和端口，TXT 保存服务约定的属性，A/AAAA 解析目标主机地址。DNS-SD 的记录结构见 [RFC 6763](https://www.rfc-editor.org/rfc/rfc6763.html#section-4)。

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

本文的 `mdnsdemo` 名称只用于实验。公开协议应按 [RFC 6763 的服务名称规则](https://www.rfc-editor.org/rfc/rfc6763.html#section-7)选择已有名称或申请登记，避免不同应用碰巧使用相同类型。

以客户端寻找本文的 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 介绍](https://www.rfc-editor.org/rfc/rfc6763.html#section-1)

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

服务发现需要一个预先约定的查询入口。应用通常知道自己支持的服务类型：打印客户端查找 `_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 服务类型枚举](https://www.rfc-editor.org/rfc/rfc6763.html#section-9)

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

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

示例用 Python 标准库启动 HTTP 服务，用 [python-zeroconf](https://python-zeroconf.readthedocs.io/en/latest/api.html)处理 mDNS 报文和 DNS-SD 记录。依赖固定为本次验证的 `zeroconf==0.151.5`，DNS 编码、缓存和冲突探测由库处理。

下载这两个文件，放进同一个目录：

- [discovery.py](https://jimyag.com/posts/mdns-dns-sd-demo/files/discovery.py)：发布或发现服务。
- [check.py](https://jimyag.com/posts/mdns-dns-sd-demo/files/check.py)：启动两个独立进程，发现服务后请求 `/hello` 并检查响应。

需要 Python 3.10+ 和 [uv](https://docs.astral.sh/uv/getting-started/installation/)。下面的命令在文件所在目录执行。`uv run --with` 会准备依赖环境，无需修改系统 Python。

### 先确认网卡地址

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

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

```bash
networksetup -listallhardwareports
ipconfig getifaddr en0
```

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

### 发布 HTTP 服务

在设备 A 上运行，替换为 A 的实际地址：

```bash
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。

发布记录的核心代码是：

```python
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 自己的地址：

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

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

核心调用是：

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

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

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

```bash
curl --noproxy '*' http://192.168.2.226:8000/hello
```

预期输出：

```text
Hello from DNS-SD demo!
```

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

## 运行结果

### 同机检查程序

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

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

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

```text
{"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 执行发现命令。本机成功输出：

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

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

```bash
curl --noproxy '*' http://192.168.2.100:35941/hello
```

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

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

```text
{"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 协议](https://github.com/localsend/protocol#3-discovery)定义的默认组地址是 `224.0.0.167`，UDP 端口是 `53317`。

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

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

LocalSend 官方解释，选用 `224.0.0.0/24` 是为了兼容部分拒绝其他多播组的 Android 设备。这是具体产品的兼容性选择，不能据此随意占用该地址段。[LocalSend 默认配置](https://github.com/localsend/protocol#1-defaults)

自己设计多播协议时，应检查地址分配。IPv4 的 `239.0.0.0/8` 是管理域多播空间，`239.255.0.0/16` 定义为本地范围；仍要避开已有用途并在网络内协调地址。地址的传播范围需要通过网络配置限制，仅选用管理域地址不会自动阻止跨网段转发。[RFC 2365](https://www.rfc-editor.org/rfc/rfc2365.html)、[IANA 多播地址表](https://www.iana.org/assignments/multicast-addresses)

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

## 发现不到服务时查什么

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

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

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

发现结果不能证明服务身份。示例的发现命令只展示信息，HTTP 也没有认证或加密；实际应用仍需在连接阶段验证服务身份、控制权限。TXT 记录适合放路径、版本等公开属性，不应放密码或访问令牌。[RFC 6763 安全考虑](https://www.rfc-editor.org/rfc/rfc6763.html#section-15)

