协议

mDNS 是用于局域网的 UDP 多播协议,网络包的格式基本跟单播的 DNS 协议一致,用于局域网 IP 自动分配、打印机自动发现、投影仪自动发现等网络零配置场景。DNS 协议使用 DNS server 的 IP 地址的 53 端口,mDNS 使用多播地址 224.0.0.251 和 f02::fb 的 5353 端口(还有一种以太网的多播地址,撇下不表)。

每一个支持 mDNS 协议的设备一般既是 mDNS 的客户端,也是服务端,比如 iPhone 可以请求投屏到 MacBook,而 MacBook 也可以请求投屏到电视上。mDNS 的协议很简单,客户端和服务端都加入多播组监听多播数据包,客户端往多播地址发起一个 mDNS 查询,可以要求响应是单播(响应只给自己)或者多播(响应发到多播地址上),服务端从多播收到请求后,返回 mDNS 查询结果。

基于 mDNS 协议的 DNS-SD 协议用于做服务发现(Service Discovery),以 AirPlay 为例:

  1. 客户端向 mDNS 多播地址发出 UDP 包,查询 _airplay._tcp.local 的 PTR 记录。由于早期 AirPlay 只用于投屏照片和带音频的视频,不支持纯音频投放,所以 Apple 设备还会同时查询 _raop._tcp.local 的 PTR 记录,RAOP 指 Remote Audio Output Protocol 。针对 Apple TV 还有个 _airplay-p2p._tcp.local 以支持不在同一个网段的设备。下面只以 _airplay._tcp 为例,这个足以支持 AirPlay 投屏了。

  2. 服务端根据请求的 QU(query unicast) 还是 QM(query multicast) 标志位,决定是单播还是多播返回结果。虽然只是查询 PTR 记录,但实际 mDNS server 会一股脑返回所有需要的信息:

    1. 返回 PTR 记录,服务指向一个域名,对应一条 SRV 记录:

      _airplay._tcp.local:
        type PTR, class IN, my-mbp._airplay._tcp.local
      
    2. 一并返回 SRV 记录,服务指向一个域名、优先级、权重、端口。AirPlay 默认使用端口 7000,target 指向的域名一般是 mDNS server 所在本机,但有的 mDNS server 支持返回广域网上的域名,此时的 DNS-SD 协议称为 wide area DNS-SD, 或者 DNS-SD over unicast DNS,可以用来发现广域网上的服务。

      my-mbp._airplay._tcp.local:
        type SRV, class IN, priority 0, weight 0, port 7000, target my-mbp.local
      
    3. 一并返回 TXT 记录,标志这个 SRV 记录代表的服务的配置参数。

      my-mbp._airplay._tcp.local:
        type TXT, class IN, acl=0 deviceid=xxx ...
      
    4. 一并返回 A 记录和 AAAA 记录,标志这个 SRV 记录的 target 域名的 IP 地址:

      my-mbp.local:
        type A, class IN, addr 192.168.1.100
        type AAAA, class IN, addr fe80::...
      

一般网关不会在不同网段之间转发多播协议,以避免多播风暴以及网关维护多播组成员的负担,那么要能跨网段发现 AirPlay 服务,有两种做法:

  1. 在网关那里使用 reflector or repeater 软件,在不同网段之间转发多播数据包。这个做法适合小的网络,或者网络中提供服务的设备经常变化的情况。

  2. 在网关那里注册一个「代理」 mDNS 服务,其 SRV 记录和 A 记录的域名 + IP 指向真实的服务 IP。这个做法适合大的网络,以及网络中提供服务的设备很固定的情况。

实现

mDNS 和 DNS-SD 的开源实现有如下几种:

  • Apple Bonjour,其开源的部分 mDNSResponder 支持 BSD、Linux、macOS、Windows,OpenWrt 上的 mdnsresponder 就是打包的这个开源实现,这个 mdnsresponder meta package 依赖 mdnsd 和 mdns-utils 两个包,前者是 mDNS server,后者是一些工具,通过 UNIX domain socket 连接到 mDNS server 来注册服务、查询服务,其中最重要的工具是 dns-sd 命令行查询工具,以及 mDNSResponder 注册服务,这个软件包里还有个 mDNSProxyResponder,可惜一执行就 segmentation fault 崩溃。

  • OpenWrt 上的 umdns,历史原因这个软件包的 GIT 仓库取名叫 mdnsd,但跟 Apple开源的那份 mdnsd 没有关系。这个软件包相当于简化版的 Apple 的 mdnsd,没有 UNIX domain socket 通讯支持。顺带提一句,Apple 的 mDNSResponder 也支持把 mdnsd 和 mDNSResponder 编译到一起,以支持硬件性能很差的设备。

  • Avahi,支持 Linux 和 BSD,这大概是开源界最著名的 mDNS 和 DNS-SD 实现了。Avahi 是定位给 PC 用的,所以 OpenWrt 上打包了更轻量级的 mdnsreponder 和 umdns,这两者功能没有 Avahi 多,例如 Avahi 支持 reflector,OpenWrt 22.03 使用 mdns-repeater 弥补了这个缺憾。

  • Systemd-resolved,万能的 Systemd 自然也会插一脚。

下面演示如何在 OpenWrt 上用 Avahi 配置「代理」 mDNS 服务。

配置

OpenWrt 上有两个版本的 Avahi daemon: avahi-dbus-daemon 和 avahi-nodbus-daemon。前者架构跟 Apple mDNSResponder 里 mdnsd 类似,会监听 DBus 来接收查询和服务注册,后者没有 DBus 依赖,更节约资源,两者都支持从配置文件里注册服务以及域名。因为命令行工具包 avahi-utils 依赖 avahi-dbus-daemon,为了调试方便,建议安装 avahi-utils 和 avahi-dbus-daemon。Avahi 默认使用 .local domain,所有默认配置保持不变即可。

opkg update
opkg install avahi-utils  # 依赖 avahi-dbus-daemon

在 macOS 的 System Preferences -> Sharing 勾选 AirPlay Receiver,选择 Allow AirPlay for: Anyone on the same network,然后在网关的 OpenWrt 盒子上执行如下命令就能看到了:

# avahi-browse -avcr
Server version: avahi 0.8; Host name: Router.local
E Ifce Prot Name                                          Type                 Domain
+ br-lan IPv6 88665A385F2D@my-mbp                     _raop._tcp           local
+ br-lan IPv4 88665A385F2D@my-mbp                     _raop._tcp           local
+ br-lan IPv6 my-mbp                                  _airplay._tcp        local
+ br-lan IPv4 my-mbp                                  _airplay._tcp        local
= br-lan IPv6 88665A385F2D@my-mbp                     _raop._tcp           local
   hostname = [my-mbp.local]
   address = [192.168.1.100]
   port = [7000]
   txt = ["vv=0" "vs=620.8.2" "vn=65537" "tp=UDP" "pk=3ac323669796eb57d83a4e268b88cb223965b08470e41869d4bc6c7da0001d4d" "am=MacBookPro16,1" "md=0,1,2" "sf=0x204" "ft=0x4A7FCFD5,0xB8154FDE" "et=0,3,5" "da=true" "cn=0,1,2,3"]
= br-lan IPv4 88665A385F2D@my-mbp                     _raop._tcp           local
   hostname = [my-mbp.local]
   address = [192.168.1.100]
   port = [7000]
   txt = ["vv=0" "vs=620.8.2" "vn=65537" "tp=UDP" "pk=3ac323669796eb57d83a4e268b88cb223965b08470e41869d4bc6c7da0001d4d" "am=MacBookPro16,1" "md=0,1,2" "sf=0x204" "ft=0x4A7FCFD5,0xB8154FDE" "et=0,3,5" "da=true" "cn=0,1,2,3"]
= br-lan IPv6 my-mbp                                  _airplay._tcp        local
   hostname = [my-mbp.local]
   address = [192.168.1.100]
   port = [7000]
   txt = ["srcvers=620.8.2" "pk=3ac323669796eb57d83a4e268b88cb223965b08470e41869d4bc6c7da0001d4d" "psi=8DAB4D50-21D8-402B-8B1E-0E1778508171" "pi=4f61a4e9-0bee-4cd7-aa37-a09e5ed5e40d" "protovers=1.1" "at=4" "model=MacBookPro16,1" "gcgl=0" "igl=0" "gid=BDF3960F-BAD5-4EE8-9981-8B3C59B83188" "flags=0x204" "features=0x4A7FCFD5,0xB8154FDE" "fex=1c9/St5PFbgm" "deviceid=88:66:5A:38:5F:2D" "acl=0"]
= br-lan IPv4 my-mbp                                  _airplay._tcp        local
   hostname = [my-mbp.local]
   address = [192.168.1.100]
   port = [7000]
   txt = ["srcvers=620.8.2" "pk=3ac323669796eb57d83a4e268b88cb223965b08470e41869d4bc6c7da0001d4d" "psi=8DAB4D50-21D8-402B-8B1E-0E1778508171" "pi=4f61a4e9-0bee-4cd7-aa37-a09e5ed5e40d" "protovers=1.1" "at=4" "model=MacBookPro16,1" "gcgl=0" "igl=0" "gid=BDF3960F-BAD5-4EE8-9981-8B3C59B83188" "flags=0x204" "features=0x4A7FCFD5,0xB8154FDE" "fex=1c9/St5PFbgm" "deviceid=88:66:5A:38:5F:2D" "acl=0"]
: Cache exhausted

可以使用 avahi-browse -vcr _airplay._tcp 只查看 AirPlay 服务的信息。

执行如下命令注册一个代理服务,这个命令会一直运行,中断它则取消注册。

avahi-publish -s -H test-airplay.local "test airplay" \
    _airplay._tcp 7000 ...复制粘贴上面_airplay._tcp的TXT记录里中括号内部的内容...

开一个新窗口,再登录 OpenWrt 执行如下命令,把 test-airplay.local 这个域名映射到真实的服务 IP:

avahi-publish -a -R test-airplay.local 192.168.1.100

avahi-publish-service 命令等价于 avahi-publish -savahi-publish-address 命令等价于 avahi-publish -a,这里有两个坑爹的点:

  1. avahi-publish -a 或者 avahi-publish-address实际只接收两个参数,但 avahi-publish-address(1) 手册里写的是三个参数!

  2. 域名必须在 .local 域里!

根据 avahi 文档说 avahi-publish -s-H HOSTNAME 也可以填 DNS 域名,如果真实服务有自己的域名,也许可以直接用,不必再 avahi-publish -a 了,但我没有试验。

最后一步,在 OpenWrt 防火墙里,打开 GUEST zone -> LAN zone TCP:7000 端口的访问,就可以让处于 GUEST 网络里的 iPhone 投屏到位于 LAN 网络里的 MacBook 上了。有意思的是 iPhone 显示的是真实 mDNS 服务的名字,似乎是以建立 AirPlay TCP 链接后获取到的设备信息为准。

当这些配置验证通过后,就可以把服务定义写入到 /etc/avahi/services/*.service,把域名和地址定义写入到 /etc/avahi/hosts ,参考 avahi.service(5)avahi.hosts(5)。但这里还有个坑,service 文件很容易编写,根据上面 avahi-browse 输出照抄即可:

<service-group>
  <name>test-airplay</name>
  <service protocol="ipv4">
    <type>_airplay._tcp</type>
    <port>7000</port>
    <host-name>test-airplay.local</host-name>
    <txt-record>srcvers=620.8.2</txt-record>
    <txt-record>pk=3ac323669796eb57d83a4e268b88cb223965b08470e41869d4bc6c7da0001d4d</txt-record>
    <txt-record>psi=8DAB4D50-21D8-402B-8B1E-0E1778508171</txt-record>
    <txt-record>pi=4f61a4e9-0bee-4cd7-aa37-a09e5ed5e40d</txt-record>
    <txt-record>protovers=1.1</txt-record>
    <txt-record>at=4</txt-record>
    <txt-record>model=MacBookPro16,1</txt-record>
    <txt-record>gcgl=0</txt-record>
    <txt-record>igl=0</txt-record>
    <txt-record>gid=BDF3960F-BAD5-4EE8-9981-8B3C59B83188</txt-record>
    <txt-record>flags=0x204</txt-record>
    <txt-record>features=0x4A7FCFD5,0xB8154FDE</txt-record>
    <txt-record>fex=1c9/St5PFbgm</txt-record>
    <txt-record>deviceid=88:66:5A:38:5F:2D</txt-record>
    <txt-record>acl=0</txt-record>
  </service>
</service-group>

但是往 /etc/avahi/hosts 里注册这个假域名到真实 IP 的映射是不行的,ssh-publish 有个手册没注明的 -R 参数可以不注册 addr 到 hostname 的反向映射,而 /etc/avahi/hosts 文件不支持,因此会跟真实 airplay 服务的 mDNS 记录冲突。解决办法是:

  1. 在 service 文件里使用 DNS 域名,也就是不在 .local 域的域名,比如 test-airplay.lan

  2. 把这个域名放到 /etc/hosts 里指向 airplay 服务真实 IP 并重启 dnsmasq 生效;

  3. /etc/avahi/avahi-daemon.conf 文件里增加下面参数以支持解析 SRV 记录里的非本地 target 域名:

[wide-area]
enable-wide-area=yes

avahi-daemon.conf(5) 里文档说这个参数默认是 yes,其实写错了,默认是 no。

最后再吐槽一句,avahi 虽然功能很丰富,但是代码真复杂,用了 DBus,又是 C 风格的异步回调函数,还是 mDNSResponder 以及 umdns 简单。