跨子网、不依赖多播的 AirPlay 镜像

AirPlay 是苹果的一个私有标准,可以用来将 iDevice(iPhone、iPad、iPod) 上的音视频流或者镜像投射到 Apple TV 上。尽管 AirPlay 协议是私有的,但国内主流的机顶盒,如天猫魔盒、小米盒子等都对其提供了支持。

AirPlay 有一个很大的局限性:只能在 Apple TV(或者支持 AirPlay 的机顶盒)与 iDevice 处在同一子网内才能工作。之所以有这个限制,是因为 AirPlay 的服务发现部分基于 Bonjour

Bonjour 简介

Bonjour 是苹果开发的一种「零配置网络架构」,使得同一局域网内的主机能够相互发现彼此提供的服务,而不需要用户配置 IP 等信息。想象一下,将一台打印机接到局域网内,然后在电脑上就可以直接选择这台打印机。

Bonjour 为了实现「零配置」,做了三件微小的工作:

  • 分配地址

虽然是零配置网络,但实际主机之间的通信还是基于 TCP/IP,于是我们需要分配 IP 地址。传统的 IP 分配方式有两种:静态分配和 DHCP。

苹果增加了另外一种方式:在没有 DHCP 服务器时(如 Ad Hoc 网络),Bonjour 会为主机自动指定随机的一个 IP 地址,然后检测是否有冲突,如果有冲突就再随机指定一个。这样的好处是不依赖路由器,在去中心化的 Ad-hoc 网络中也能正常工作。

  • 命名

Bonjour 为服务(DNS service)指定一个唯一的类似 foo._airplay._tcp.local. 的名字,名字中间的字段表示服务的类型(_airplay)和传输协议(_tcp),后面的服务发现都基于这个名字。类似的,主机(也叫 host,跟西部世界没关系 :])也有唯一的名字,如 magicbox.local.,与服务不同的是,主机的名字没有类型和传输协议。具体命名规则见这里

当一台主机(host)接入局域网时,Bonjour 会自动生成一个局域网内唯一的名字。虽然 host 有了唯一的名字,但实际通信的时候还是需要 IP 地址,名字与 IP 地址的映射依靠 mDNS 来完成。

mDNS(multicast DNS) 不同于常见的 DNS,它是局域网内依赖多播(multicast)工作的 DNS 协议。他不需要独立的 DNS 服务器,当需要解析一个名字时,会向多播地址(224.0.0.251)的 5353 端口来发送查询请求,收到该请求的主机如果发现自己是要找的对象,就会发送回复。macOS、iOS 以及安装了 Bonjour 服务的 Windows 都会有一个 mDNSResponder 进程专门处理这些请求。开发者只需要将自己的服务注册到系统中,mDNSResponder 会自动完成服务发现工作,不需要处理具体的协议细节。

  • 自动发现服务

当用户需要某项服务时,Bonjour 会根据所需类型(如 AirPlay 的类型是 _airplay._tcp)来查找局域网内所有该类型的服务。每个服务对应一个 host,再通过 host 解析为实际的 IP 地址。

通过 DNS 发现服务的过程称为 DNS-SD(DNS Service Discovery)。

AirPlay 的服务发现过程

现在有一台 Mac 和一台天猫魔盒(支持 AirPlay)处在同一子网内,在 Mac 上点击右上角的 AirPlay 按钮,就会自动发现魔盒。

在 Mac 上可以用

1
2
dns-sd -Z _airplay._tcp
dns-sd -Z _raop._tcp

来查看两种类型的服务。

1
2
3
4
5
6
7
_airplay._tcp                                   PTR     zzzzzzz._airplay._tcp
zzzzzzz._airplay._tcp SRV 0 0 7200 MagicBox_M16S-8f144d951aed0c2f.local. ; Replace with unicast FQDN of target host
zzzzzzz._airplay._tcp TXT "deviceid=b5:b7:1b:56:da:b9" "features=0x4A7FFFF7,0xE" "srcvers=220.68" "flags=0x4" "vv=2" "model=HappyCast3,1" "pw=0" "rhd=3.0.0.0" "pk=eb41e959a9ceea6a5d942c032492fdd60b14f6da148bcab48277fdbbfff18816" "pi=2e388006-13ba-4041-9a67-25dd4a43d536"

_raop._tcp PTR b5b71b56dab9@zzzzzzz._raop._tcp
b5b71b56dab9@zzzzzzz._raop._tcp SRV 0 0 6200 MagicBox_M16S-8f144d951aed0c2f.local. ; Replace with unicast FQDN of target host
b5b71b56dab9@zzzzzzz._raop._tcp TXT "ch=2" "cn=0,1,2,3" "da=true" "et=0,3,5" "vv=2" "ft=0x4A7FFFF7,0xE" "am=HappyCast2,1" "md=0,1,2" "rhd=3.0.0.0" "pw=false" "sr=44100" "ss=16" "sv=false" "tp=UDP" "txtvers=1" "sf=0x4" "vs=220.68" "vn=65537" "pk=eb41e959a9ceea6a5d942c032492fdd60b14f6da148bcab48277fdbbfff18816"

可以看到,两种服务分别有三条 DNS 记录:第一条是 PTR 类型的,由服务类型指向服务名;第二条是 SRV 类型的,由服务名指向主机名;第三条是 TXT 类型的,是一些服务参数。

TXT 记录中具体的参数含义可以参考这个非官方的 AirPlay 文档

使用 Wireshark 抓包可以看到:

当点击右上角 AirPlay Icon 时,Mac 首先发出 query,查找 _airplay._tcp. 和 _raop._tcp. 类型的服务。

mDNS query

应该是由于缓存,query 中包含了两个 Answer:分别指向两个服务:b5b71b56dab9@zzzzzzz._raop._tcp.local 和 zzzzzzz._airplay._tcp.local。

其中,zzzzzzz 和 b5b71b56dab9@zzzzzzz 分别是是魔盒注册的两个服务名(默认使用天猫魔盒的名字),b5b71b56dab9 是魔盒的 mac 地址。

接下来,Mac 开始解析 b5b71b56dab9@zzzzzzz._raop._tcp.local 服务

SRV query

最后,魔盒发出响应

mDNS response

可以看到 answer 中的记录指明了 b5b71b56dab9@zzzzzzz._raop._tcp.local 服务指向的 host 是 MagicBox_M16S-8f144d951aed0c2f.local。

在 Additional records 里面,包含了一条 A 记录,其中 MagicBox_M16S-8f144d951aed0c2f.local 指向的 IP 地址是 192.168.43.1。

因此整个过程是:

  1. 通过服务类型搜索服务,得到服务名。
  2. 通过服务名找到 host 名称。
  3. 通过 host 名称得到 IP 地址。

Bonjour 依赖局域网上的多播,因此有两个缺点:Bonjour 无法跨越子网发现服务;企业或者学校等大型网络中,多播通常是被禁用的。

让 Bonjour 跨子网工作

让 Bonjour 跨子网工作有下面几种方法:

这里只介绍最后一种方法。

假设有一台 iPhone 和一台天猫魔盒处于不同的子网,或者在同一个子网但多播被禁用了,现在要将 iPhone 的屏幕投射到魔盒上。

AirPlay 的整个过程可以看做两个部分,服务发现(依赖 Bonjour)和实际连接、传输音视频流。因为实际连接的过程并不要求 iPhone 和魔盒在同一子网内,只要 IP 可达即可,所以现在的问题是服务无法被发现。

DNS-SD Proxy 主要的思路是:在 iPhone 上注册一个与魔盒提供的同样的服务,但服务的 Host 指向魔盒的 IP,达到欺骗 iPhone 的目的。iPhone 在搜索服务时会自问自答,因此不依赖局域网的多播,这样就解决了服务发现的问题。

苹果提供了 NSNetServiceDNSService 来注册服务,但是这两个库有一个缺点:无法指定 host(认为本机是提供服务的 host)。这里就需要使用更底层的 DNSServiceRegister 来注册服务,苹果提供了示例代码。这个方法可以传入 host 名称,这里我们填入 MagicBox_M16S-8f144d951aed0c2f.local.,端口跟前面魔盒的数据一致,名字分别叫 AirProxy 和 6c5ab5637001@AirProxy。

调用 DNSServiceRegister 时,第三个参数应该传入 kDNSServiceInterfaceIndexLocalOnly,否则无法正常发现和连接;此外还需要传入 TXT 记录。videoTXTDict 是一个包含 TXT 记录内容的字典,内容跟前面抓取的魔盒的 TXT 记录相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
NSDictionary *videoTXTDict = @{
@"deviceid": deviceID,
@"features": @"0x5A7FFFF7,0x1E",
@"flags": @"0x4",
@"model": @"AppleTV2,1",
@"srcvers": @"220.68",
@"vv": @"2",
@"pk": @"8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
@"pi": @"2e388006-13ba-4041-9a67-25dd4a43d536",
@"pw": @"0",
@"rhd": @"5.0.0.5",
};

TXTRecordRef videoTXTRecord;
TXTRecordCreate(& videoTXTRecord, 0, NULL);
for (id key in videoTXTDict.allKeys) {
TXTRecordSetValue(& videoTXTRecord, [key UTF8String], strlen([videoTXTDict[key] UTF8String]), [videoTXTDict[key] UTF8String]);
}

DNSServiceRegister(&_sdRef, kNilOptions, kDNSServiceInterfaceIndexLocalOnly, [name UTF8String], "_airplay._tcp.", NULL, [host UTF8String], htons(self.port), TXTRecordGetLength(&videoTXTRecord), TXTRecordGetBytesPtr(& videoTXTRecord), RegisterReplyCallback, (__bridge void *)self);

然后用同样的办法注册 _raop._tcp. 类型的服务,这个时候选择 AirProxy 服务就会发现系统提示「无法连接到 AirProxy」。

把手机连上 Mac,输入

1
rvictl -s <iPhone 的 UUID>

再用 Wireshark 抓 rvi0 接口上的包,就会发现所有的 MDNS 包中都没有 A 记录,也就是说 Bonjour 搜索到服务,找到 host 之后,无法解析 host 的 IP。

这个时候再回到 dns_sd.h 中,就可以发现有一个用来在服务中增加记录的方法 DNSServiceAddRecord,调用方法如下:

1
2
3
4
5
6
NSArray *IPComponents = [[obj host] componentsSeparatedByString:@"."];
char rawData[5];
sprintf(rawData, "%c%c%c%c", (char)[IPComponents[0] integerValue], (char)[IPComponents[1] integerValue], (char)[IPComponents[2] integerValue], (char)[IPComponents[3] integerValue]);

DNSRecordRef recordRef = NULL;
DNSServiceErrorType errorCode = DNSServiceAddRecord(sdRef, &recordRef, flags, kDNSServiceType_A, strlen(rawData), rawData, 0);

这个时候再抓包,可以看到 MDNS 包中多了两条 A 记录:

A 记录

这个时候,可以看到 AirProxy._airplay._tcp.local 服务指向的 host 是 MagicBox_M16S-8f144d951aed0c2f.local.,但我们添加的 A 记录名称是 AirProxy._airplay._tcp.local 和 6c5ab5637001@AirProxy._raop._tcp.local。

这样在注册的时候把 host 名称改成 AirProxy._airplay._tcp.local 或者 6c5ab5637001@AirProxy._raop._tcp.local Bonjour 就可以正确解析了。

至此,就达到了欺骗 Bonjour 连接 AirPlay 的目的。

参考

给鸡排饭加个蛋