AirPlay 投屏设备发现

#AirPlay #mDNS #DNS-SD #Android #网络分析

背景:设备发现到底是什么?

AirPlay 设备发现不是"手机去扫局域网里所有 IP",而是通过 mDNS/DNS-SD 完成的。

可以把它理解成局域网里的"小广播查询":

  1. Apple 设备向 224.0.0.251:5353 或 IPv6 的 ff02::fb:5353 发 UDP 组播查询。
  2. 查询内容是"谁提供 _airplay._tcp.local 服务?"以及"谁提供 _raop._tcp.local 服务?"。
  3. Airplay投屏器/APP 收到查询后,也向 224.0.0.251:5353 或查询方单播地址回复。
  4. 回复里包含服务名、端口、主机名、IP 地址和能力字段。
  5. Apple 设备拿到这些信息后,才知道后续 AirPlay/RAOP 连接该连到哪里。

这里有两个关键词。

mDNS 是 multicast DNS,即组播 DNS。它不依赖路由器上的 DNS 服务器,而是在本地链路里用 224.0.0.251:5353ff02::fb:5353 直接问答。

DNS-SD 是 DNS Service Discovery,即用 DNS 记录描述"这里有什么服务",AirPlay 设备发现用的就是 DNS-SD。

一、为什么要重做

原来的做法依赖外部 DNS-SD 实现,常见路径是:

  • Linux 发行版上通过 Avahi 的 avahi-compat-libdns_sd
  • macOS 上通过系统 Bonjour
  • Windows 上通过 Bonjour SDK 或 dnssd.dll

这个方案在桌面系统上可用,但移植到 Android 或轻量环境时会遇到几个实际问题。

第一,avahi-daemon 对这个场景偏重。它是系统级 daemon,功能完整,但 Airplay投屏接收端只需要回答两个服务类型的查询:_airplay._tcp.local_raop._tcp.local

第二,Android 环境不适合假设存在 Avahi。Android 应用或 Native 服务更常见的部署方式是自己创建 socket,自己处理网络协议,而不是要求系统额外安装并运行一个 mDNS daemon。

第三,跨平台依赖变复杂。Linux、macOS、Windows、Android 的 DNS-SD 库和头文件都不一样。项目里为了处理这些差异,需要保留很多动态加载、条件编译和错误分支。

第四,排查成本高。一旦发现失败,要同时考虑libdns_sd、Avahi daemon、系统服务状态、动态库路径、权限、端口占用、防火墙和组播转发。问题边界不清楚。

所以这次重做的目标很明确:

  • 去掉 Avahi/Bonjour SDK/libdns_sd 运行时依赖。
  • 内置一个只覆盖 Airplay投屏接收器/APP 所需能力的 mDNS responder。
  • 代码保持简单,不做通用 mDNS 框架。
  • 对外行为尽量保持和抓包里的 AirPlay 设备期望一致。

二、抓包环境

抓包现场有两个关键地址:

  • Apple 设备:192.168.1.102,IPv6 link-local 地址为 fe80::893:1c69:90d7:c4b7
  • Airplay投屏器主机:192.168.1.103,IPv6 link-local 地址为 fe80::65f3:e0b8:8891:bb61

其它敏感字段在本文里会脱敏,比如 MAC 地址、公钥等只展示结构,不展示完整值。

这份抓包可以直接确认三点。

第一,mDNS 发现同时走了 IPv4 和 IPv6。 在 15:00:05.516 附近同时向 IPv4 组播 224.0.0.251:5353 和 IPv6 组播 ff02::fb:5353 公告 _airplay._tcp.local_raop._tcp.local,对应抓包帧号是 1215。响应里同时包含 A 192.168.1.103AAAA fe80::65f3:e0b8:8891:bb61

第二,Apple 设备也同时用 IPv4 和 IPv6 查询。比如 15:00:45.519 可以看到 192.168.1.102 -> 224.0.0.251:5353fe80::893:1c69:90d7:c4b7 -> ff02::fb:5353 两份查询,内容都是 _raop._tcp.local_airplay._tcp.local

第三,后续 AirPlay 连接实际使用了 IPv6。mDNS 的 SRV 记录给出的服务端口是 39851,随后在帧 461463 出现了 Apple 设备从 [fe80::893:1c69:90d7:c4b7]:49156 到投屏器 [fe80::65f3:e0b8:8891:bb61]:39851 的 TCP 三次握手,并继续传输数据。随后在帧 490492 还有一条从 [fe80::893:1c69:90d7:c4b7]:49157[fe80::65f3:e0b8:8891:bb61]:37759 的 IPv6 TCP 数据流。反向检查 192.168.1.102192.168.1.103 之间的 TCP,没有看到对应的 IPv4 会话。因此这份抓包不是只发布 IPv6 地址,而是客户端确实选择了 IPv6 link-local 地址完成连接。

三、设备发现总流程

先用一张时序图看完整流程。

@startuml
title AirPlay mDNS/DNS-SD discovery

actor "Apple 设备\n192.168.1.102\nfe80::893:..." as Apple
participant "mDNS 组播地址\n224.0.0.251:5353\nff02::fb:5353" as MDNS
participant "UxPlay 主机\n192.168.1.103\nfe80::65f3:..." as UxPlay

Apple -> MDNS: PTR query\n_airplay._tcp.local\n_raop._tcp.local\nclass IN + QU
MDNS -> UxPlay: 局域网组播转发
UxPlay -> MDNS: PTR/SRV/TXT/A/AAAA response\nAirPlay + RAOP
MDNS -> Apple: Apple 设备收到服务描述
Apple -> UxPlay: 后续连接 AirPlay/RAOP 端口

@enduml

关键点是:设备发现阶段只解决"服务在哪里"和"服务能力是什么",并不传输投屏画面,真正的投屏连接发生在设备发现之后。

四、从抓包时间线还原发现过程

只看单个包容易丢掉上下文。设备发现要按时间线看,才能理解 Apple 设备和 Airplay投屏器/APP 各自在做什么。

下面是抓包里和 AirPlay/RAOP 发现相关的一段简化时间线。为了避免泄漏设备标识,服务实例里的 MAC 和公钥都做了脱敏。

相对时间源地址目的地址类型关键信息
123.573s192.168.1.103224.0.0.251IPv4 responseRAOP 公告,包含 AAAAA
133.573sfe80::65f3:e0b8:8891:bb61ff02::fbIPv6 response同一组 RAOP 公告发到 IPv6 mDNS 组播
143.573s192.168.1.103224.0.0.251IPv4 responseAirPlay + RAOP 合并响应,SRV 端口 39851
153.573sfe80::65f3:e0b8:8891:bb61ff02::fbIPv6 response同一组合并响应发到 IPv6 mDNS 组播
30924.289sfe80::893:1c69:90d7:c4b7ff02::fbIPv6 queryApple 设备查询 _airplay_raop 等服务
31324.306sfe80::65f3:e0b8:8891:bb61fe80::893:1c69:90d7:c4b7IPv6 unicast responseUxPlay 按 QU 查询单播回复 Apple 设备
402/40327.615s192.168.1.102 / fe80::893:...224.0.0.251 / ff02::fbdual-stack queryApple 设备同时发 IPv4 和 IPv6 mDNS 查询
435/43628.637s192.168.1.103192.168.1.102IPv4 unicast responseUxPlay 对 IPv4 QU 查询单播回复
461-46331.843sfe80::893:...fe80::65f3:...IPv6 TCPApple 设备连接 UxPlay 的 SRV 端口 39851

这条时间线说明两件事。

第一,服务端不能只在启动时公告一次。局域网里 mDNS 是 UDP 组播,包可能丢,客户端也可能在任意时刻打开投屏面板。启动时多次公告和查询时即时响应都需要支持。

第二,Apple 设备不是只问 _airplay._tcp.local。它会同时问 _raop._tcp.local。如果只发布 AirPlay,不发布 RAOP,客户端可能认为设备能力不完整。

设备发现可以画成更细的状态图:

@startuml
title Discovery states from packet capture

[*] --> ServerStarts
ServerStarts --> ProbeAndAnnounce : UxPlay starts
ProbeAndAnnounce --> MulticastVisible : PTR/SRV/TXT/A/AAAA announced
MulticastVisible --> ClientQueries : Apple opens AirPlay device list
ClientQueries --> AnswerMatched : _airplay/_raop PTR matched
AnswerMatched --> ClientCaches : client caches TXT/SRV/A
ClientCaches --> ConnectProtocol : client connects AirPlay/RAOP port
ConnectProtocol --> [*]

ClientQueries --> ClientQueries : repeated mDNS query
ProbeAndAnnounce --> ProbeAndAnnounce : repeated announcement

@enduml

五、抓包分析:Apple 设备怎么问

抓包里一个典型查询包来自 192.168.1.102,发往 224.0.0.251:5353

简化后的字段如下:

Multicast Domain Name System (query)
Transaction ID: 0x0000
Flags: 0x0000 Standard query
Questions: 2
Answer RRs: 2
Additional RRs: 1

Queries:
  _airplay._tcp.local: type PTR, class IN, QU question
  _raop._tcp.local:    type PTR, class IN, QU question

Known answers:
  _airplay._tcp.local -> UxPlay@debian._airplay._tcp.local
  _raop._tcp.local    -> <mac>@UxPlay@debian._raop._tcp.local

这几个字段很重要。

Transaction ID0x0000。mDNS 通常使用 0,因为它不是传统 DNS 那种客户端发一个随机 ID、服务器原样带回的请求响应模型。

Questions: 2 表示一个包里同时问了两个服务类型:

  • _airplay._tcp.local
  • _raop._tcp.local

type PTR 表示它不是在问"某个域名的 IP 是什么",而是在问"这个服务类型下面有哪些实例"。这就是 DNS-SD 的入口。

QU question 表示查询方希望收到 unicast response。也就是说,除了普通组播回复以外,服务端最好也支持直接回给查询方。

Answer RRs: 2 在 query 包里不是普通响应,而是 known-answer。Apple 设备告诉局域网里的 responder:"我已经知道这些答案了,如果没有变化,可以按 mDNS 规则减少重复回复"。实现上可以先不做复杂抑制,但必须能正确解析这种包,不能因为 query 包里带了 answers 就误判成 response。

这里的 known-answer 对排查很有价值。如果抓包里看到 Apple 设备一直带着旧服务实例名重复查询,但 UxPlay 侧没有新的响应,通常要检查:

  • responder 是否真的绑定到了 UDP 5353。
  • 是否加入了 224.0.0.251 组播。
  • query 包里带 answers 时,解析逻辑是否提前返回。
  • QU 位是否被正确识别。
  • 防火墙或 Wi-Fi AP 是否丢弃了 mDNS 组播。

从协议角度看,query 包里带 known-answer 是正常行为,不能把它当成异常包。

六、抓包分析:服务端应该怎么答

一个完整的 AirPlay/RAOP DNS-SD 服务描述不是单条记录,而是一组记录。

@startuml
title DNS-SD records used by AirPlay discovery

rectangle "_services._dns-sd._udp.local" as Services
rectangle "_airplay._tcp.local" as AirPlayType
rectangle "_raop._tcp.local" as RaopType
rectangle "UxPlay@hostname._airplay._tcp.local" as AirPlayInst
rectangle "<mac>@UxPlay@hostname._raop._tcp.local" as RaopInst
rectangle "hostname.local" as Host
rectangle "192.168.x.x" as IPv4
rectangle "fe80::...%iface" as IPv6

Services --> AirPlayType : PTR
Services --> RaopType : PTR
AirPlayType --> AirPlayInst : PTR
RaopType --> RaopInst : PTR
AirPlayInst --> Host : SRV + port
RaopInst --> Host : SRV + port
AirPlayInst --> AirPlayInst : TXT
RaopInst --> RaopInst : TXT
Host --> IPv4 : A
Host --> IPv6 : AAAA

@enduml

这里每种记录有不同作用。

PTR 记录把服务类型映射到服务实例。例如 _airplay._tcp.local 指向 UxPlay@debian._airplay._tcp.local

SRV 记录告诉客户端服务运行在哪台主机、哪个端口。例如实例指向 debian.local:39851

TXT 记录描述能力。AirPlay/RAOP 很多能力不是靠后续连接猜出来的,而是在 TXT 里先告诉客户端。

A 记录把 debian.local 解析成 IPv4 地址 192.168.1.103

AAAA 记录把 debian.local 解析成 IPv6 地址。mDNS 的 IPv6 发现发生在本地链路上,所以常见地址是 fe80::/64 link-local 地址,发送到 ff02::fb:5353 时必须带接口 scope。

6.1 RAOP 响应包

本次抓包里 RAOP 响应包大约 576 字节,包含 6 条 answer。

简化后如下:

Flags: 0x8400 Standard query response, authoritative, no error
Answer RRs: 6

TXT <mac>@UxPlay@debian._raop._tcp.local
PTR _raop._tcp.local -> <mac>@UxPlay@debian._raop._tcp.local
SRV <mac>@UxPlay@debian._raop._tcp.local -> debian.local:39851
AAAA debian.local -> <ipv6 address>
A debian.local -> 192.168.1.103
PTR _services._dns-sd._udp.local -> _raop._tcp.local

其中 TXT 字段包括:

ch=2
cn=0,1,2,3
da=true
et=0,3,5
vv=2
ft=0x527FFEE6,0x0
am=AppleTV3,2
md=0,1,2
rhd=5.6.0.0
pw=false
sr=44100
ss=16
sv=false
tp=UDP
txtvers=1
sf=0x4
vs=220.68
vn=65537
pk=<redacted>

这些字段的含义可以粗略理解为:

字段含义
ch声道数
cn支持的音频编码能力集合
et支持的加密方式
ftfeature bits,设备能力位
am设备型号
mdmetadata 能力
pw是否需要密码
sr采样率
sssample size
tp传输协议
sfservice flags
vsserver version
pk公钥,本文脱敏

RAOP 主要对应音频能力。即使 AirPlay 镜像是主目标,RAOP 记录也会影响客户端对设备能力的判断。

6.2 AirPlay 响应包

本次抓包里 AirPlay 响应包大约 532 字节,包含 6 条 answer。

简化后如下:

Flags: 0x8400 Standard query response, authoritative, no error
Answer RRs: 6

TXT UxPlay@debian._airplay._tcp.local
PTR _airplay._tcp.local -> UxPlay@debian._airplay._tcp.local
SRV UxPlay@debian._airplay._tcp.local -> debian.local:39851
A debian.local -> 192.168.1.103
AAAA debian.local -> <ipv6 address>
PTR _services._dns-sd._udp.local -> _airplay._tcp.local

TXT 字段包括:

deviceid=<mac-redacted>
features=0x527FFEE6,0x0
pw=false
flags=0x4
model=AppleTV3,2
pk=<redacted>
pi=2e388006-13ba-4041-9a67-25dd4a43d536
srcvers=220.68
vv=2

AirPlay 记录更偏向视频、镜像、配对和设备身份描述。

6.3 TTL 和 cache flush

抓包里能看到两个常见 TTL:

  • 服务和 TXT 的 TTL:4500
  • 主机 A/SRV 相关记录 TTL:120

这符合 mDNS/DNS-SD 的常见做法:服务描述可以缓存更久,主机地址和具体连接目标缓存短一点。

还有一个容易忽略的字段是 cache flush

TXTSRVA 这类唯一记录,响应里会带 cache flush。它的意思是:客户端如果缓存了同名旧记录,应该用这条新记录替换。

PTR 这类"一个服务类型可以对应多个实例"的记录,不应该带 cache flush。否则可能把局域网里其他同类型服务错误清掉。

6.4 合并响应还是拆包响应

抓包里能看到 UxPlay 主机会有几种响应:

包类型大小answer 数
RAOP 单独响应576 字节6
AirPlay 单独响应532 字节6
AirPlay + RAOP 合并响应1028 字节10

原始抓包里的合并响应不算太大,一个重要原因是 DNS 名称可以压缩。自己实现一个简单 responder 时,如果一开始不做 DNS name compression,合并包会明显变大。

这里有两种工程选择。

第一种是拆包响应:

  • AirPlay 查询命中时,发 AirPlay 记录包。
  • RAOP 查询命中时,发 RAOP 记录包。
  • 两者都命中时,分别发两个包。

拆包的优点是每个包都小,不做 DNS name compression 也几乎没有 MTU 风险。缺点是重复 DNS header、重复 host A 记录,也会多一次 sendto()

第二种是合并响应:

  • 一个 response 同时携带 AirPlay 和 RAOP 的 PTR/SRV/TXT。
  • host A 记录只带一次。
  • 客户端一次就能拿到完整服务描述。

合并响应的体验更接近抓包里的 Bonjour 行为,也减少组播包数量。风险是单个 UDP payload 变大。如果包接近 MTU,局域网里的 UDP multicast 分片会更脆弱。

当前实现采用折中方案:优先合并 AirPlay + RAOP 响应,但不做 DNS name compression;合并包超过 1200 字节时,自动回退到拆包响应。

1200 不是 DNS 协议硬限制,而是一个保守工程阈值。普通 Ethernet IPv4 下,避免分片的 UDP payload 上限大约是:

1500 - 20(IP header) - 8(UDP header) = 1472 bytes

选择 1200 可以给 IPv6、VLAN、隧道、未来 additional records 留出余量。对当前 AirPlay + RAOP + A/AAAA 记录的组合来说,典型合并包大约在 1KB 左右,不需要马上引入 DNS 压缩。

七、设计方案

代码上把职责拆成两层。

@startuml
title UxPlay mDNS implementation split

package "UxPlay main" {
  [uxplay.cpp] as UxPlay
}

package "DNS-SD service layer" {
  [lib/dnssd.c] as Dnssd
  [lib/dnssd.h] as DnssdH
}

package "mDNS protocol layer" {
  [lib/mdnsd.c] as Mdnsd
  [lib/mdnsd.h] as MdnsdH
}

UxPlay --> Dnssd : dnssd_init/register/unregister
Dnssd --> Mdnsd : mdnsd_set_services\nmdnsd_start\nmdnsd_announce
Mdnsd --> "UDP 5353\nIPv4 + IPv6" : socket\nmulticast\nsendto/recvfrom

DnssdH ..> Dnssd
MdnsdH ..> Mdnsd

@enduml

dnssd.c 保留业务含义:

  • 设备名
  • MAC 地址
  • AirPlay/RAOP 实例名
  • TXT 字段
  • feature bits
  • PIN/密码策略

mdnsd.c 只处理协议:

  • DNS name 编码和解析
  • PTR/SRV/TXT/A/AAAA 记录编码
  • UDP 5353 socket
  • 加入 224.0.0.251ff02::fb 组播
  • mDNS 查询解析
  • 组播响应和 QU 单播响应
  • goodbye 包

这样拆完后,dnssd.c 不需要知道 DNS 包怎么拼,mdnsd.c 也不需要理解 AirPlay feature bit 的业务含义。

旧方案和新方案的边界差异如下。

@startuml
title Old external DNS-SD dependency vs built-in responder

rectangle "Old design" {
  [UxPlay] as OldUx
  [libdns_sd API] as Libdns
  [Avahi daemon / Bonjour] as Daemon
  [UDP 5353] as OldUdp
  OldUx --> Libdns
  Libdns --> Daemon
  Daemon --> OldUdp
}

rectangle "New design" {
  [UxPlay] as NewUx
  [dnssd.c\nservice data] as NewDnssd
  [mdnsd.c\npacket + socket] as NewMdnsd
  [UDP 5353\nIPv4 + IPv6] as NewUdp
  NewUx --> NewDnssd
  NewDnssd --> NewMdnsd
  NewMdnsd --> NewUdp
}

@enduml

旧方案把发现能力交给系统服务,新方案把发现能力收回到 UxPlay 进程内。这样做不是为了重复造一个完整 Avahi,而是因为 UxPlay 只需要一个很小的协议子集。

这个子集包括:

  • _services._dns-sd._udp.local 的 PTR 枚举。
  • _airplay._tcp.local 的 PTR 枚举。
  • _raop._tcp.local 的 PTR 枚举。
  • AirPlay/RAOP 实例的 SRV/TXT。
  • hostname 的 A 记录。
  • hostname 的 AAAA 记录。
  • AirPlay + RAOP 的合并响应,以及超过阈值时的拆包回退。
  • goodbye TTL=0。

不包括:

  • 通用 DNS resolver。
  • 跨网段 DNS-SD。
  • 完整 service browser。
  • 完整 DNS cache。
  • 全量 name compression。

八、实现流程

运行时流程可以画成这样:

@startuml
title Runtime flow

start
:uxplay.cpp 初始化设备名和硬件地址;
:dnssd_init();
:生成 hostname.local;
:生成 AirPlay/RAOP 实例名;

if (注册 RAOP?) then (yes)
  :构造 RAOP TXT;
  :mdnsd_set_services();
  :mdnsd_start();
  :发送 RAOP announce;
endif

if (注册 AirPlay?) then (yes)
  :构造 AirPlay TXT;
  :mdnsd_set_services();
  :mdnsd_start();
  :发送 AirPlay announce;
endif

repeat
  :select IPv4/IPv6 UDP 5353;
  :recvfrom 命中的 socket;
  :解析 DNS header 和 questions;
  if (命中 _airplay 或 _raop?) then (yes)
    if (同时命中 AirPlay 和 RAOP?) then (yes)
      :尝试构造合并响应包;
      if (包长 <= 1200 bytes?) then (yes)
        :发送合并响应;
      else (no)
        :回退为 AirPlay/RAOP 拆包响应;
      endif
    else (no)
      :按服务构造响应包;
    endif
    :发组播响应;
    if (QU 或源端口不是 5353?) then (yes)
      :发单播响应给查询方;
    endif
  endif
repeat while (UxPlay running)

:unregister 时发送 goodbye TTL=0;
stop

@enduml

实现里没有做成通用 mDNS 框架,原因是 UxPlay 不需要完整框架。它只需要服务发现所需的最小闭环。

8.1 关键实现点

第一,打开 UDP socket。

mDNS 使用固定端口 5353,IPv4 组播地址是 224.0.0.251,IPv6 组播地址是 ff02::fb。responder 启动时需要:

  • 创建 UDP socket
  • 设置 SO_REUSEADDR
  • 设置 multicast TTL
  • 设置 multicast loop
  • bind 到 0.0.0.0:5353
  • 加入 224.0.0.251
  • bind 到 [::]:5353
  • 选择一个 IPv6 link-local 接口
  • 设置 IPV6_MULTICAST_IF
  • 加入 ff02::fb

第二,选择出站地址。

本机可能有多张网卡,代码用 UDP connect()224.0.0.251:5353 的方式让内核选择默认出站接口,再通过 getsockname() 得到本机 IPv4 地址。这个地址用于 A 记录。

IPv6 不能只照搬这个办法。ff02::fb 是 link-local multicast,发送时需要明确接口 scope。实现里通过 getifaddrs() 枚举 AF_INET6 地址,跳过 loopback,优先选择支持 multicast 的 link-local 地址,并记录 sin6_scope_id。这个地址用于 AAAA 记录,这个 scope 用于 IPV6_MULTICAST_IF 和向 ff02::fb 发送组播。

第三,解析 DNS name。

DNS name 是 label 格式,例如:

_airplay._tcp.local

在线上包里会编码成:

8 "_airplay"
4 "_tcp"
5 "local"
0

解析时还要处理 DNS compression pointer。即使当前实现生成响应时为了简单不做压缩,也必须能解析别人发来的压缩名称。

第四,识别查询类型。

主要命中条件是:

  • _services._dns-sd._udp.local 的 PTR 查询
  • _airplay._tcp.local 的 PTR 查询
  • _raop._tcp.local 的 PTR 查询
  • 服务实例名的 TXT/SRV 查询
  • hostname 的 A 查询
  • hostname 的 AAAA 查询

第五,构造响应记录。

响应包 header 使用:

Transaction ID: 0x0000
Flags: 0x8400
Questions: 0
Answer RRs: N

0x8400 表示这是 response,并且是 authoritative answer。

代码里不要直接写 0x8400 这种 magic number,更清楚的写法是拆成两个 flags:

#define DNS_FLAG_RESPONSE 0x8000
#define DNS_FLAG_AUTHORITATIVE 0x0400

构造 header 时使用:

DNS_FLAG_RESPONSE | DNS_FLAG_AUTHORITATIVE

这样以后读代码时可以直接看到语义,而不是反查 DNS header bit。

第六,控制合并包大小。

AirPlay 和 RAOP 同时命中时,responder 会先尝试构造一个合并包。构造完成后检查 payload 长度,如果超过 1200 字节,就丢弃这个临时包,回到原来的拆包发送路径。

这个逻辑有两个好处:

  • 正常情况下减少一半左右的响应包数量。
  • 极端设备名、未来新增记录或 TXT 增长时,不会让 multicast UDP 包逼近 MTU。

第七,处理 QU。

Apple 设备的查询带 QU。实现上如果发现 query class 最高位是 0x8000,就除了组播响应外,再向查询来源地址发一份单播响应。

第八,处理 goodbye。

服务取消注册时发送 TTL 为 0 的记录,告诉客户端清掉缓存。否则客户端可能在一段时间内继续显示已经退出的设备。

第九,双栈发送。

IPv4 和 IPv6 共享同一组 AirPlay/RAOP 服务数据,但不能共享同一个 socket。实现上 mdnsd 内部维护两个 fd:一个加入 224.0.0.251,一个加入 ff02::fb。线程里用 select() 同时等待两个 fd,收到 IPv4 查询就回 IPv4 响应,收到 IPv6 查询就回 IPv6 响应。公告和 goodbye 也分别向两个组播地址发送。

8.2 为什么不直接实现完整 DNS 压缩

DNS name compression 可以显著减少包大小,但它会引入更多状态:

  • 需要记录每个 name suffix 在包里的 offset
  • 要避免生成非法 pointer
  • 要处理压缩指针嵌套和循环风险
  • 要保证不同 RR 的 name 和 rdata 都能正确引用

对 UxPlay 当前需求来说,这个复杂度可以暂时不引入。合并响应已经通过 1200 字节阈值保护住 MTU 风险;一旦超过阈值,代码会回退拆包。

这是一个有意的取舍:先用简单方案解决真实问题,而不是先做一个完整 DNS 库。后续如果要加入 NSEC、多地址 additional records,或者 TXT 继续膨胀,再实现发送端 DNS name compression 会更合适。

8.3 Android 平台考虑

Android 上最重要的约束是部署形态不同。

在传统 Linux 发行版里,可以说"请安装 Avahi 并启动服务"。但 Android 上通常不能这么要求。即使设备有 root 或定制系统,把 Avahi daemon 带进去也会引入额外问题:

  • daemon 生命周期谁管理
  • SELinux 策略怎么放行
  • UDP 5353 权限和组播权限怎么处理
  • 多应用或多服务争用 5353 怎么协调
  • 动态库和头文件怎么随系统构建

内建 mDNS responder 后,UxPlay 只需要依赖 socket 能力。对 Android Native 层来说,这比外部 daemon 更可控。

当然,Android 仍然要注意:

  • Wi-Fi 网络必须允许 multicast
  • 进程需要具备加入组播的网络权限
  • 如果系统已有 mDNSResponder,需要确认 UDP 5353 复用策略
  • 某些设备的省电策略可能影响组播收发

这些是网络和系统权限问题,但至少不再额外依赖 Avahi。

九、验证方式

编译验证:

cmake -B build
cmake --build build

运行验证:

timeout 4 ./build/uxplay -n UxPlay -nh -vs 0 -as 0

抓包验证可以用:

tshark -i <iface> -f "udp port 5353"

如果要专门看 IPv6 mDNS,可以过滤:

tshark -i <iface> -f "udp port 5353 and ip6"

也可以用字段过滤看 mDNS 记录:

tshark -r airplay_mdns.pcapng \
  -Y "mdns && ip.src == 192.168.1.103" \
  -T fields \
  -e frame.number \
  -e frame.len \
  -e dns.count.answers \
  -e dns.resp.name \
  -e dns.ptr.domain_name \
  -e dns.srv.target \
  -e dns.srv.port \
  -e dns.a \
  -e dns.aaaa \
  -e dns.txt

验证重点不是"有没有包",而是以下几项都要成立:

  • Apple 设备会查询 _airplay._tcp.local_raop._tcp.local
  • UxPlay 会响应 PTR、SRV、TXT、A、AAAA 记录。
  • IPv4 查询走 224.0.0.251:5353,IPv6 查询走 ff02::fb:5353
  • 后续 AirPlay TCP 连接是否选择了 SRV 记录对应的 IPv4 或 IPv6 地址;本次抓包中客户端选择了 IPv6 link-local 地址连接 39851
  • 同时查询 AirPlay 和 RAOP 时,优先看到合并响应;如果包长超过阈值,会回退为拆包响应。
  • SRV 端口和 UxPlay 实际监听端口一致。
  • TXT 字段里 feature、model、version、password 标志符合预期。
  • QU 查询能收到响应。
  • unregister 或退出时能发送 TTL 为 0 的 goodbye。

十、代码下载

UxPlay

总结

这次重做的核心不是"自己写一个 mDNS 库",而是把 UxPlay 需要的设备发现闭环收进项目内部。

从抓包看,AirPlay 发现流程并不神秘:Apple 设备问 _airplay._tcp.local_raop._tcp.local,服务端回复 PTR/SRV/TXT/A/AAAA。只要把这些记录按 mDNS/DNS-SD 规则说清楚,客户端就能发现设备。

从工程角度看,内建 responder 带来的收益更明显:

  • Linux 上少一个外部 daemon 依赖。
  • Android 和嵌入式平台更容易部署。
  • 发现问题时边界更清楚。
  • 代码结构更直接,dnssd.c 负责业务,mdnsd.c 负责协议。

这也是这次选择重做 mDNS 发现方案的根本原因。

评论