· 网络 / 工程

手机在外面用 5G,能把照片传回家里的电脑吗:NAT 穿透实测

P-Pass 做的事是把手机里的照片传回家里那台电脑。人在家时这件事很简单,手机和电脑在同一个 Wi-Fi 下,局域网里直接传。

麻烦在出门以后。手机用的是 4G/5G,电脑在家里的路由器后面,两边都没有能被对方直接访问的地址。家人问得最直接:“出门在外,照片能不能备份?“开发者会换个问法:蜂窝网络在运营商级 NAT(CGNAT)后面,家宽又隔着一层路由器 NAT,两台设备到底能不能点对点直连?连不上的时候,中继有多慢?

这轮测试做于 2026 年 7 月底,当时项目刚起步,还在验证技术路线能不能走通。手机拨家里的场景每个 20 次连接、每次 100MB,中间还因为一行判断 IP 版本的代码把结论测反过一次。

先交代传输栈

P-Pass 的传输层用的是 iroh,底下是 QUIC,加密用 TLS 1.3。两台设备建连时,iroh 会先经中继服务器会合,交换各自观察到的地址,然后尝试打洞,打通了就把流量切到直连路径上。打不通时,数据经 iroh 的维护方 number 0 运营的公共中继转发。中继转发的是加密后的 QUIC 数据,看不到照片内容,也不落盘。

所以”能不能传回家”其实分两问:

  1. 能不能连上。只要中继可达,就能连上。
  2. 走的是直连还是中继。这决定了速度,也决定了连接是否依赖第三方基础设施。

我们关心的是第二问在真实网络里的答案。

怎么测的

测试工具是两个小程序。家里的电脑跑一个命令行监听端,启动后打印自己的 NodeId 和连接票据(ticket):

cargo run -- listen

手机上装一个测试 App,填入 ticket,点一次”Connect + Send 100MB”,连续跑 20 轮。每轮记录一行 JSONL:选中的路径(lan / direct / relay)、IP 版本、建连耗时、吞吐和错误。一行原始记录长这样:

{"type":"transfer","attempt":2,"path":"relay","ipver":"v6","connect_ms":420,"throughput_mbps":7.91,"error":"","elapsed_s":166,"screen_on":"true","charging":"false"}

每个场景至少 20 次连接尝试,每次传 100MB。测试前我们定了判定线:同一个 Wi-Fi 下直连率要 100%;家宽和蜂窝之间直连率 70% 以上算通过,50% 到 70% 之间算观望,低于 50% 就要回头查 iroh 配置和网络环境。

参与测试的设备有三类:

  • 家宽:家里的电脑做监听端,家用路由器的 UPnP 是关的。初测和复测时家侧的部署形态不同,后面细说。
  • 蜂窝:一台鸿蒙手机用 5G 拨号,关掉 Wi-Fi。
  • 云服务器:国内和新加坡各一台,都有公网 IPv4、没有 NAT、没有 IPv6,用来把”蜂窝这一侧”和”家宽这一侧”拆开单独看。

结果

先放汇总表。直连率一栏写的是”直连轮数 / 总轮数”,没有逐轮路径记录的地方如实标出。

拨号方 → 监听端轮数路径建连耗时吞吐
家里 Wi-Fi 的手机 → 家里 Mac2020/20 直连(18 轮 IPv6,2 轮 IPv4 局域网)—29.3~50.8 Mbps,均值 43.9
5G 手机 → 家里 Mac(初测)200/20 直连,20 轮全部经中继完成P50 405ms,首轮 1259msP50 11.3 Mbps(6.0~13.5)
5G 手机 → 家里 Mac(复测)2020 轮全部完成;之后监听端自记的 6 轮均为 IPv6 直连—自记 6 轮 51.9~68.1 Mbps
5G 手机 → 国内云服务器2020 轮完成,日志可见的 14 轮均为 IPv4 直连34~49ms(一轮 1072ms)18.4~47.7 Mbps
5G 手机 → 新加坡云服务器—建连成功,100MB 传到中途停滞后超时,完成 0 笔——
国内云服务器 → 家里 Mac5首轮中继,第 2 轮起 IPv4 直连直连 11~13ms约 3.2 Mbps(顶到服务器出向 3 Mbps 限速)
新加坡云服务器 → 家里 Mac5首轮中继,第 2 轮起 IPv4 直连直连 154~190ms12~21 Mbps

几点说明。

家里 Wi-Fi 那一行,测试工具给的路径标签是 direct。工具把 lan 定义成”选中路径 RTT 小于 500 微秒”,Wi-Fi 空口的 RTT 是毫秒级,永远到不了这个阈值。对端地址是 192.168.1.x 和家宽同一个 /64 前缀下的 IPv6 地址,流量没有出局域网。另一个细节是同一个 Wi-Fi 下 iroh 也偏好 IPv6:20 轮里 18 轮走的是 IPv6 全局地址,其中还包括手机隐私地址轮换出来的两个不同后缀。IPv6 轮和 IPv4 轮的速度没有差别,两轮 IPv4 分别是 45.1 和 40.0 Mbps。

5G 复测那一行的写法比较绕,原因是复测时监听端还没有逐笔记录路径。20 轮 100MB 在监听端日志里全部完成、没有停滞,2GB 用了大约 9 分钟;路径是随后换上会自己记账的监听端、再拨几轮拿到的。所以我们只能说”20 轮完成”,直连证据来自之后的 6 轮。

两台云服务器都没有 NAT,蜂窝拨过去不涉及打洞,这两行只回答”蜂窝这一侧的 UDP 出站有没有被限制”。答案是国内的没有,出境的有问题,后面细说。

0/20 是怎么来的

初测的 5G → 家宽是 0/20 直连。按判定线,这是红灯。

但我们没有直接判红。原因是那一轮的家侧环境不干净:监听端跑在家里一台 Mac mini 上的虚拟机里,虚拟机是 NAT 模式;宿主机开着本机代理的 TUN 模式,全局接管了 IPv4、IPv6 和 DNS 的出口。中继注册被代理带到了美国西部的节点,所以那 11.3 Mbps 的中继速度里包含了绕道美国的开销。

一开始我们给这个 0/20 编了一条解释:代理污染了家侧的 IPv4 公网地址判定,IPv4 打洞整体失效;剩下的 IPv6 打洞又被蜂窝入站防火墙拦住,于是全部走中继。

这条解释很快被推翻了。我们把家侧监听端当天实际使用的 ticket 解码出来,里面的直连地址是:

203.0.113.x:46xxx   # 家宽真实公网 IPv4(已打码),没有被代理污染
192.168.64.3        # 虚拟机 NAT 后面的内网地址

IPv4 地址是干净的真实 WAN 地址。而且整张 ticket 里没有任何 IPv6 地址。家侧再去看那台虚拟机:只有 link-local 地址,IPv6 出网不通。

也就是说那一轮测试里,家侧根本没有 IPv6 路径。“IPv6 打洞被防火墙拦截”说的是一条不存在的路。

排查到这里,嫌疑按优先级收敛成三条:

  1. 蜂窝侧 NAT 和家侧”路由器 NAT + 虚拟机 NAT”双层叠在一起,打洞难度太大;
  2. 本机代理的 TUN 模式改写或重路由了一部分打洞用的 UDP 包;
  3. 路由器 UPnP 关着,少了一条端口映射的退路。

为了把变量拆开,我们做了两组对照。

第一组看蜂窝这一侧。同一台 5G 手机拨国内的云服务器,20 轮全部完成,日志可见的 14 轮全是 IPv4 直连,吞吐 18.4~47.7 Mbps。完成总数有服务器的接收计数佐证。这说明国内蜂窝的出站 UDP/QUIC 没有被限制,“运营商封 UDP”这个嫌疑可以排除。

第二组看家宽这一侧。我们在家里另起一台全新的 Mac 做监听端:不装代理,不进虚拟机,纯 Wi-Fi,单层 NAT,UPnP 照旧关着。解码它的 ticket,得到真实公网 IPv4(不是 CGNAT,NAT 把外部端口从 41145 改写成了 7446)、局域网地址 192.168.1.x:41145,以及两个 IPv6 全局地址(2xxx:xxxx::/64)。然后从两台云服务器反向拨它:国内和新加坡的两台都是首轮走中继,第 2 轮起升级成 IPv4 直连。

所以家宽和路由器都不是问题,UPnP 关着也能打通。

最后用同一台 5G 手机拨这台干净的 Mac。监听端记下来的原始行(地址和 NodeId 已打码):

Received 100.0MB from <NodeId> (direct/v6 [2xxx:xxxx:…]:39595) 68.1/59.1/59.1/58.2/58.4/51.9 Mbps

手机的蜂窝 IPv6 全局地址直接连到家宽的 IPv6 全局地址,不经过任何中继,52~68 Mbps。

回头看,初测的 0/20 是两件事叠出来的:虚拟机没有全局 IPv6,断了 IPv6 这条路;双层 NAT 加上代理的 TUN 模式,堵住了 IPv4 这条路。两条路都断了,只剩中继。跟运营商和路由器都没关系。

同一台手机、同一个家,两种部署形态下的差距:

  • 家侧在虚拟机里、开着代理:0/20 直连,中继 P50 11.3 Mbps,2GB 用了 26.8 分钟;
  • 家侧直接跑在宿主机上:IPv6 直连 52~68 Mbps,2GB 大约 9 分钟。

至于蜂窝这一侧的 IPv4,我们的判断是它在 CGNAT 后面,这也是 IPv6 在”5G 连家宽”这个场景里重要的原因:两端都有全局 IPv6 地址时,免打洞直达。这个判断来自上面的对照推理,我们没有在蜂窝侧做过 NAT 类型的直接测量。

出境方向的 UDP

5G 拨新加坡云服务器那一行是另一个现象。建连成功,握手阶段的小包能过,但 100MB 传到一半停住,最后超时,一笔都没传完。服务器日志只有一句 Client error: connection lost / timed out。

反过来,新加坡云服务器拨家里,第 2 轮起直连,12~21 Mbps。

我们记下的是这个方向不对称:从国内蜂窝往海外发大流量 UDP,传不动;从海外拨回国内家宽,畅通。具体是哪一层在限速,我们没有证据,只记现象。

对产品来说,这件事影响的是中继的部署位置。iroh 的中继协议走 443 端口上的 TCP,初测里经中继的 20 轮全部完成、0 失败,说明出境 TCP 是通的。和海外对端直接走 UDP 不可行,在产品场景里也不存在这种需求;需要关心的是手机到中继那一段的质量。

一次测量事故:contains(":")

上面的故事里有一段我们略过了。初测之后,我们曾经得出过一个结论:“IPv6 是国内直连率的决定性因素”。依据是当时日志里的 ipver 字段几乎都写着 v6。

直到家侧反馈那台虚拟机根本没有全局 IPv6。监听端没有 IPv6,日志却说连接用的是 IPv6,两者不可能同时为真。

问题出在测试 App 判断 IP 版本的那一行。旧代码拿选中路径的远端地址做判断:

remoteAddr.contains(":")   // 包含冒号就判为 IPv6

远端地址的格式是 ip:port,IPv4 地址后面也带着端口号和冒号。这一行对所有地址都返回 true。于是 v1、v2 两个版本 App 产出的所有日志里,ipver 字段全部不可信,直连一律被记成 v6。回头翻初测的 5G 日志,20 行全是 "path":"relay",ipver 也一行不落地写着 v6,而那一轮家侧连一个 IPv6 地址都没有。

修复是先剥掉端口再判断:

// "ip:port" always contains ':' — strip the port first; v6 iff the host part still has one
addr.substringBeforeLast(":").contains(":") -> "v6"

v3 版本同时新增了一个 remote 字段,把真实的对端地址原样记下来,事后可以人工核对。

后果是:v1/v2 期间所有关于 IPv6 的数据作废,“IPv6 是决定性因素”这个结论降级为待验证假设。后来被证实的是一个更窄的版本:5G 连家宽时,IPv6 是能走通直连的那条路。证据是上面那行 direct/v6 [2xxx:xxxx:…],这是整个项目第一笔可信的 IPv6 直连记录。但在那之前,我们是拿一个恒为真的判断在下结论。

这次之后测试工装改了两处。监听端每收一笔就自己记一行 JSONL,写下对端 NodeId、路径、IP 版本、地址和吞吐。iroh 两端是对称可观测的,之前只在手机端记录,属于工具偷懒;改完以后不再依赖手机导出日志。另一处是所有进结论的字段,必须能对上一个原始地址。

测试期间还出过一次形状相似的事故。某次同一张 ticket 连跑两轮,5/5 全部超时,事后发现监听端进程早已被操作端的超时机制误杀了。从拨号方看,“对端进程死了”和”对端中继链路断了”完全一样,都是 connect timed out。

产品上因此定下的规矩

测试结束后的评审里,我们把这些结果写成了几条对产品的约束。

存储端必须能被全局 IPv6 访问。 电脑端要在宿主机上直接跑,不能放进 NAT 模式的虚拟机。初测的 0/20 里,断掉 IPv6 那条路的就是虚拟机,对用户来说,这等于把最快的那条路自己堵上了。

“本机没有全局 IPv6”列为一级诊断项。 用户问”为什么这么慢”时,第一件要回答的事就是这一条。

中继兜底必须一直在。 这次测试里 IPv6 直连的表现很好,但它有前提:两端都要有全局 IPv6,家侧部署形态要对。前提不满足,就只剩中继。

最后这一条在后来的真机回归里又被验证了一次。9 月中旬,我们用另一台手机切到 5G、关掉 Wi-Fi,家里 Mac 保持在家庭 Wi-Fi 上,电脑端开了 debug 日志。结果连接能建立,但全程停在中继上,日志里每隔约 5 秒出现一次:

iroh::socket::remote_map::remote_state: connections are not good enough, triggering holepunching

连接的 network_path 始终是 Relay(aps1-1.relay.n0.iroh.link),一次也没有升级成直连。传输没有卡死,经中继传完了多个文件,其中有一个约 220MB 的视频,只是明显比直连慢。这一次为什么没打通,现有的日志还回答不了。这台手机在这个网络下有没有全局 IPv6,当时没有采集。

所以 5G 连家宽有时能直连,有时会落到中继上,产品要把两种情况都当成常态来处理。

中继路径还暴露了一个超时问题。手机端的建连和单次请求共用同一个 15 秒超时,这个数是按局域网的直觉定的。中继路径下建连本来就慢。另一次回归里手机连的是蜂窝热点,传一个 288MB 的视频时,手机端连续三次报 no response from the computer within 15000ms,随后转入退避重试,界面上看起来一切正常,照片就是不动。电脑端日志里这三次没有对应的交付记录,所以我们只能确定手机在电脑交付完成前耗尽了 15 秒,请求到底有没有送到电脑,还分不清。拆开”建连超时”和”请求超时”是我们确定的修复方向,目前还在等一次可复现的蜂窝窗口来验证。

另一件在推进的事是中继本身。n0 的公共中继是 anycast 分配的,这次测试里,家里的 Mac 被分到过德国的节点,国内的云服务器连美国西部的节点时出现过超时,然后切到了德国。我们在评估自己部署中继,放在离国内更近的区域,比如新加坡,避免被分配到太远的节点。这件事目前还在计划阶段。

常见问题

手机用 5G 时连得上家里的电脑吗?

连得上。我们的测试里,5G 拨家宽的每一轮都完成了传输:直连不通时经中继完成,初测 20 轮全部走中继、0 失败。能不能走直连取决于两端的网络:家侧电脑直接跑在宿主机上、两端都有全局 IPv6 时,我们测到了 52~68 Mbps 的 IPv6 直连;家侧放在虚拟机里时,0/20 直连。

走中继会慢多少?

同一台 5G 手机、同一个家,中继路径的吞吐 P50 是 11.3 Mbps,2GB 用了 26.8 分钟;IPv6 直连是 52~68 Mbps,2GB 大约 9 分钟。那次的中继速度里包含了家侧代理绕道美国西部的开销,干净环境下的中继速度我们还没有单独测过。建连耗时方面,中继路径的 P50 是 405ms,首轮 1259ms。

IPv6 对远程访问家里电脑有什么用?

蜂窝网络的 IPv4 我们判断在 CGNAT 后面,打洞难度大。IPv6 下两端都有全局地址,可以免打洞直达:我们测到的第一笔可信 IPv6 直连就是 5G 手机到家宽,52~68 Mbps。同一个 Wi-Fi 下,iroh 也在 20 轮里有 18 轮选了 IPv6。

NAT 打洞成功率到底有多高?

要看两端是什么网络。我们测到的组合里,同一个 Wi-Fi 是 20/20;国内和新加坡的云服务器拨家宽都是首轮中继、第 2 轮起直连,各 5/5 完成;5G 拨放在虚拟机里的家侧是 0/20。我们观察到打洞的首轮走中继,用来会合和交换地址,直连从第二次连接开始。

走中继时,照片会不会被中继服务器看到?

P-Pass 的传输基于 QUIC 和 TLS 1.3,中继转发的是加密后的数据,看不到照片内容,也不落盘。中继负责建连时的会合,以及在直连不通时把加密流量从一端转到另一端。