最开始,我只是想解决一个看起来很普通的问题:
让家里的 Android 手机服务器,通过福建联通的公网 IPv6 从外网访问。
最后,这件事却一路从 DDNS、IPv6、Cloudflare Tunnel、OpenFRP、自启动,一直挖到了 Linux mount propagation、Binder、devpts、PTY,甚至把 SSH 一并拖下了水。
真正修复问题的代码其实只有很少几行,但找到这些代码为什么需要存在,花掉了远比修改本身多得多的时间。
这篇就把整个过程记下来,包括那些当时看着合理、后来被新证据推翻的判断。
一切的起点:IPv6 明明通,为什么外网就是连不上?
我的设备环境有点特殊。
服务机并不是一台传统意义上的 Linux 服务器,而是一台 Android 手机。网络结构大致是:
1
2
3
4
5
6
7
8
9
10
福建联通
│
中兴光猫
192.168.1.1
│
小米路由器
192.168.31.1
│
Android 手机
192.168.31.78
手机上通过 chroot 跑着 Ubuntu,里面有 OpenList、nginx、qBittorrent、OpenSSH、Tailscale、frpc、cloudflared 等服务。
最开始,我的目标非常简单:
DDNS-GO 更新公网 IPv6 → 外网直接访问手机上的服务。
手机本身确实拿到了公网 IPv6,出站 IPv6 也完全正常。
一开始甚至连手机的公网 IPv6 都没从局域网邻居表里看到,于是我一度怀疑手机根本没有拿到公网地址。
结果换一种方式,从 DNS 解析出来的 IPv6 地址反向测试,反而发现它确实就是手机。
也就是说:
公网 IPv6 有,IPv6 出站有,甚至 ICMPv6 入站都能通,但 TCP 就是不通。
这时候问题就开始变得有意思了。
从 sshd 一路查到光猫
进一步登录手机检查后,我发现 sshd 最初只监听 IPv4:
1
ListenAddress 0.0.0.0
于是改成 IPv6 后继续测试。
结果又遇到了 Android 特有的奇怪行为:只写 IPv6 后,IPv4 监听反而出了问题。
最后采用双行:
1
2
ListenAddress 0.0.0.0
ListenAddress ::
才实现真正的双栈监听。
但即使这样,外网 TCP 仍然无法连接。
于是开始从网络最上游一路往下查。
全国多个测试节点的结果很有代表性:
- 大多数节点都能解析出 IPv6
- ICMPv6 可以到达
- TCP 连接全部失败
这已经基本排除了 DNS 和 DDNS。
接着继续进入光猫检查。
光猫的普通 Telnet 权限很有限,但通过隐藏管理入口拿到了更高权限,检查了防火墙与转发规则。看到的结果却相当奇怪:
IPv6 转发策略并没有明显拒绝入站 TCP。
甚至光猫自己明确开放的 WAN 端口,从外网测试也一样 TCP 不通,但 ICMPv6 依然可以到达。
这时候结论开始清晰起来:
不是我的 DDNS 错了,不是手机没有公网 IPv6,也不是局域网路由器简单地把端口挡住了。
而是这条 IPv6 入站链路本身存在”可以 Ping,但 TCP 进不来”的限制。
于是我决定不再和这条链路死磕。
换一个方向:既然入站进不来,那就主动向外连
既然家庭网络的 IPv6 入站 TCP 走不通,那最自然的方案就是:
不让外面的客户端主动打进来,而是让手机主动建立出站隧道。
最终采用了两条路线。
Web 服务:Cloudflare Tunnel
OpenList 和 OpenSpeedTest 之类的 HTTP/HTTPS 服务通过 Cloudflare Tunnel 暴露:
1
2
3
4
5
6
7
用户浏览器
↓
Cloudflare
↓
cloudflared
↓
localhost:5244 / localhost:3000
这样客户端不需要安装任何额外软件,浏览器直接访问域名即可。
SSH:OpenFRP
SSH 是另外一种情况。
Cloudflare Tunnel 对普通 TCP/SSH 客户端存在额外限制,如果要求外网直接使用标准 SSH 客户端,我最终还是选择了 OpenFRP:
1
2
3
4
5
6
7
SSH Client
↓
OpenFRP
↓
frpc
↓
sshd :22
最后形成了一个很实用的组合:
| 服务 | 外网入口 |
|---|---|
| OpenList | Cloudflare Tunnel |
| OpenSpeedTest | Cloudflare Tunnel |
| SSH | OpenFRP |
| OpenList 备用入口 | OpenFRP |
而且 Cloudflare 和 OpenFRP 之间还有一定程度的互备。
至此,外网访问问题基本解决。
但真正的大坑,才刚刚开始。
接下来,我决定让手机上的服务真正”像服务器一样”开机自启
最开始这台手机并不像服务器。
Android 开机以后,容器并不会自己完整启动。
没有 systemd,没有传统意义上的 rc.local,/data/adb/service.d 也基本是空的。
于是开始建立自己的启动链。
核心结构大致变成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Android 开机
↓
Magisk service.d
↓
等待网络
↓
挂载 chroot
↓
进入 Ubuntu
↓
启动 sshd
↓
启动 services.sh
↓
启动 Tailscale
↓
启动 cron
OpenList 又比较特殊。
它本身是 Android 原生应用,并不是 chroot 服务,所以只能通过 Android 的 am 来启动,而不是在 Ubuntu 里面直接启动。
慢慢地,这台手机开始真的像一台小型服务器了。
然后,一个”小需求”把事情带到了完全不同的方向
后来只是想做两个小调整:
一个是修复 citie 用户的 sudo。
另一个是把 ddns-go 从主服务里移出去。
结果这两个小需求,最终把整个系统最深层的问题都暴露出来了。
第一个异常:sudo 怎么都不能正常工作?
最终发现问题不是 sudo 本身,而是:
Android 的 /data 所在文件系统以
nosuid方式挂载。
因此 /usr/bin/sudo 即便本身是 setuid,也无法正常利用 setuid 机制提权。
最后通过对 chroot 根目录做 bind/remount,使容器这部分重新具备需要的 suid 行为,再配合 sudoers 配置,sudo 才恢复。
问题解决了。
但真正麻烦的东西随后出现。
第二个异常:OpenCode/Bun 莫名其妙 Segmentation Fault
OpenCode 在容器里面运行时出现:
1
2
Segmentation fault
abort
一开始看起来像是 Bun、JavaScriptCore 或者 ARM64 环境的问题。
但继续深入后,发现一个非常诡异的事实:
1
2
/proc/self/maps
/proc/version
这些本来应该一定存在的 /proc 内容,在容器里面竟然直接报:
1
No such file or directory
这意味着:
不是 OpenCode 本身坏了,是容器里的 /proc 已经坏了。
于是开始把宿主 Android 的:
1
2
3
4
/dev
/proc
/sys
/dev/pts
bind 到 chroot 里面。
OpenCode 果然恢复。
当时我以为:
好了,问题解决。
后来才发现,这其实只是把真正的问题埋了下去。
一个长期存在的误判:35930 到底是什么?
这次排查里,我还纠正了一个自己错了很久的判断。
之前经常拿:
1
:35930
来判断 OpenList 是否正常。
后来发现:
35930 实际上是 tailscaled 使用的端口。
所以之前很多”OpenList 已经正常启动”的判断,其实是假的。
这也让我意识到:
监控指标本身选错了,后面所有”服务正常”的结论就都可能建立在错误的基础上。
后来改成真正的 HTTP 访问:
1
2
3
4
localhost:5244 → HTTP 200
localhost:3000 → HTTP 200
localhost:8080 → HTTP 200
localhost:8443 → HTTP 200
这才算真正验证服务。
OpenList 又”炸了”
过了一阵子,我再次发现 OpenList 访问异常。
这一次的问题已经不像普通应用故障。
SSH 有时候能连上,但登录后马上断开。
Android 的 am 命令偶发异常。
PTY 报:
1
out of pty devices
更严重的是:
1
/dev/binder
开始出现异常。
于是继续排查。
这一次终于碰到了真正的核心:
Mount Propagation
原来 chroot 根本没有你想象中的”隔离”
很多人第一次接触 chroot 时,会天然认为:
“既然进了容器,那里面的挂载就是容器自己的。”
但 chroot 并不自动创建 mount namespace。
这意味着:
文件系统根目录变了,不代表 mount namespace 也隔离了。
而我的 Android chroot 就属于这种情况。
宿主的:
1
2
3
4
/dev
/proc
/sys
/dev/pts
本身处于 shared propagation。
例如:
1
2
3
4
/dev shared:2
/dev/pts shared:3
/proc shared:4
/sys shared:5
问题就从这里开始。
真正危险的不是 bind,本身是 propagation
表面上看,我执行的是很普通的操作:
1
2
3
4
mount -o bind /dev "/dev"
mount -o bind /proc "/proc"
mount -o bind /sys "/sys"
mount -o bind /dev/pts "/dev/pts"
从字符串层面完全没问题。
甚至如果你只是做静态审计,很可能会得出:
“这几个 bind 看起来挺正常。”
但 Linux 真正危险的地方在于:
这些 mount 自己属于什么 propagation group?
因为目标 mount 仍然处于 shared peer。
于是:
1
2
3
4
5
6
7
8
9
10
11
bind
↓
产生 mount event
↓
沿 shared propagation 传播
↓
宿主 peer 收到事件
↓
宿主对应路径产生新的 mount
↓
覆盖原来的 mount
然后灾难开始。
宿主的 mount 开始一层一层堆起来
排查过程中,可以看到宿主的 /dev、/proc、/sys、/dev/pts 上不断出现新的 mount ID。
原本应该是:
1
2
3
4
/dev 41
/dev/pts 42
/proc 43
/sys 44
后来开始出现:
1
2
3
4
41
104227
67901
...
其他路径也是类似情况。
这意味着每次 bind 都没有老老实实停留在 chroot 里。
它实际上在反向影响宿主 Android。
而真正倒霉的是 Binder 和 devpts
Android 的 Binder 本来位于:
1
/dev/binderfs
PTY 则依赖:
1
/dev/pts
当这些目录被新的覆盖层遮掉以后,后果非常直接。
Binder
原本:
1
2
/dev/binder
→ /dev/binderfs/binder
后来 Binderfs 被覆盖,/dev/binder 变成异常状态。
结果:
1
2
3
am
ADB
scrcpy
都开始出现:
1
Binder driver '/dev/binder' could not be opened
PTY
/dev/pts 被异常挂载覆盖之后:
1
/dev/pts/ptmx
消失。
然后 sshd 虽然还能监听:
1
:22
但是给用户创建伪终端失败。
于是出现一个非常迷惑的现象:
1
2
3
4
5
6
7
SSH connection
↓
能连上
↓
能看到 Ubuntu banner
↓
突然 Connection closed
实际上不是 SSH 密码,也不是 sshd 配置。
而是:
sshd 根本拿不到 PTY。
然后又发现了第二个问题:boot 脚本居然执行了两遍
事情已经够复杂了,结果继续查日志又发现:
1
2
boot script start
boot script start
同一时间出现两次。
后来发现 /data/adb/service.d 里居然放着一个:
1
99-chroot-boot.sh.bak-propfix
而且还是:
1
0755
也就是说,它不是普通备份。
它是一个:
可执行的完整 Magisk service.d 脚本。
Magisk 看到以后,自然把它也执行。
于是:
1
2
3
4
5
6
7
8
9
10
11
正式 boot 脚本
+
备份 boot 脚本
↓
执行两遍
↓
bind 两遍
↓
propagation 两遍
↓
宿主 overlay 增长得更快
这就解释了为什么问题会被放大得如此严重。
最终的修复方向终于明确
这时候已经有三个明确的根因:
根因一
mount propagation 没隔离。
根因二
boot 脚本被可执行备份文件重复执行。
根因三
之前使用的 mount –make-private 工具选错了。
Android 的 toybox:
1
/system/bin/mount
不支持我们需要的那种语法。
而 BusyBox:
1
/system/xbin/mount
虽然支持 private,但:
1
mount -o private /path
会因为参数形式进入 fstab 解析,同样失败。
最终实测下来,当前设备真正可用的是:
1
/system/xbin/mount --make-private "/path"
返回:
1
rc=0
并且 shared: 计数实际下降。
最后的修复其实非常简单
对于每个目标:
1
2
3
4
/dev
/proc
/sys
/dev/pts
在 bind 前后做 private:
1
2
3
/system/xbin/mount --make-private "/X" 2>/dev/null
mount -o bind /宿主/X "/X"
/system/xbin/mount --make-private "/X"
也就是:
1
2
3
4
5
6
bind 前:
先把嫁接点设为 private
bind:
再把新 mount 设为 private
同时把可执行的:
1
99-chroot-boot.sh.bak-propfix
移出:
1
/data/adb/service.d
为什么必须”前后各一次”?
这里最关键的一点不是”命令成功了”。
而是:
bind 前的那个 mount 和 bind 后新产生的 mount,都必须确保 propagation 属性符合预期。
所以最终方案不是简单地把一条失败命令换掉,而是形成:
1
2
3
4
5
6
7
8
9
目标 mount
↓
private
↓
bind
↓
新 mount
↓
private
这样才能确保 mount event 不再继续向宿主传播。
真正的验收:重启
这种问题最怕的是:
当前看着好了,下一次开机又复发。
所以最后不是简单地看:
1
rc=0
而是直接重启。
然后分别检查:
1
2
3
boot + 1 min
+120s
+360s
结果非常漂亮。
修复前:
1
2
3
4
/dev 41 → 104227 → 67901 → ...
/proc 43 → 102553 → 49150 → ...
/sys 44 → 103204 → 57972 → ...
/dev/pts 42 → 大量 tmpfs
修复后:
1
2
3
4
/dev 41
/dev/pts 42
/proc 43
/sys 44
三次采样:
没有任何增长。
然后进行真正的功能验证
Binder
恢复:
1
2
3
4
binder
binder-control
hwbinder
vndbinder
am get-current-user:
1
0
PTY
测试:
1
2
pty.openpty()
→ PTY_OK
不再出现:
1
out of pty devices
SSH
最终真的建立:
1
/dev/pts/0
交互式 SSH 会话成功。
服务
同时:
1
2
3
4
5
6
nginx → HTTP 200
OpenList → HTTP 200
qBittorrent → HTTP 200
Tailscale → Online
frpc → 正常
cloudflared → 正常
至此,才真正可以说:
修复完成。
还有一个很有意思的后续:frpc 到底是不是”双实例故障”?
稳定性审计过程中,又发现:
1
2
frpc PID 7732
frpc PID 9005
第一反应很容易是:
“怎么又启动两份了?”
结果只读检查后才发现:
1
2
3
4
5
7732
→ SSH tunnel
9005
→ OpenList tunnel
它们:
- PID 不同
- PPID 都是 1
- 配置来源不同
- 日志不同
- 各自只负责一个 tunnel
而 frpc.sh 本身就明确写着:
1
INSTANCES="ssh openlist"
所以这里的结论恰恰是:
两个 frpc 进程不是异常,而是设计如此。
这又是一次很典型的”不能只看数量判断系统是否异常”。
最终系统状态
经过完整审计之后,当前系统已经运行了超过 20 小时,核心状态稳定。
整个系统大概变成这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Android
│
┌───────┴───────┐
│ │
Magisk boot Android 服务
│
chroot mount
│
┌──────┴──────┐
│ │
private 宿主 shared
│ │
Ubuntu Android
│
┌────┼──────────────┐
│ │ │
sshd services cron
│ │
│ ├─ nginx
│ ├─ OpenList
│ ├─ qBittorrent
│ ├─ frpc ×2
│ └─ cloudflared
│
└── 外部 SSH
而 mount 层则保持:
1
2
3
4
5
6
7
8
9
10
11
宿主:
/dev 41
/dev/pts 42
/proc 43
/sys 44
chroot:
/dev private
/dev/pts private
/proc private
/sys private
没有继续产生覆盖层。
这次故障最值得记住的几个教训
1. chroot ≠ mount namespace 隔离
这是整次事件最大的教训。
很多人看到:
1
chroot
就会下意识认为”容器里的 mount 不会影响外面”。
实际上不是。
如果 mount namespace 没有隔离,传播机制依然可能把你的操作带回宿主。
2. 静态代码看不懂 mount propagation
单纯搜索:
1
mount -o bind
很难发现真正的问题。
因为:
1
mount -o bind /dev "/dev"
从语法上完全正常。
真正的问题是:
这个目标 mount 是 shared 还是 private?
这属于运行时内核语义,而不是字符串层面的 bug。
3. 不要把”端口开着”当成服务健康
35930 当时确实是 UP。
但:
35930 属于 tailscaled。
所以:
1
端口 UP ≠ OpenList 正常
正确做法应该是:
1
2
3
4
HTTP 200
实际 API 响应
真实 SSH 登录
真实 PTY
4. 备份文件绝对不要放进自动执行目录
这一点几乎可以写进每一个 Magisk 脚本项目的 README:
service.d 不是普通文件夹。
一个:
1
99-chroot-boot.sh.bak
如果还带着:
1
0755
那它就不再是备份。
它是:
另一个启动脚本。
5. 满盘问题经常比真正的 bug 更危险
这次排查过程中还出现过:
- 脚本被写成全 \0
- 服务启动失败
- 下载任务刚加就失败
- 开机异常
后来发现 /data 曾经一度接近甚至达到 100%。
所以我越来越觉得:
磁盘空间应该和 CPU、内存一样,属于基础健康指标。
最后的回顾
现在再回头看整件事情,会发现最有意思的地方是:
我最初只是想解决:
“为什么我的公网 IPv6 从外面访问不了?”
真正走到最后,却变成了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
IPv6
↓
光猫
↓
Cloudflare / OpenFRP
↓
Android 自启动
↓
chroot
↓
/proc
↓
/dev
↓
mount propagation
↓
Binder
↓
devpts
↓
PTY
↓
SSH
最后真正解决问题的代码,甚至没有多少。
但这也是系统排障最有意思的地方之一:
真正困难的,从来不是最后写出那几行代码,而是确定那几行代码为什么必须存在。
从”OpenList 又炸了”,到发现 /dev/binder 消失;从 SSH banner 后断开,到发现根本没有 PTY;从 mount ID 不断增长,到最终定位 shared propagation。
每一个看似独立的问题,最后其实都是同一条因果链上的不同表现。
而现在,这台原本只是”拿来折腾”的 Android 手机,已经真正变成了一台跑着 Ubuntu chroot、反向隧道、Web 服务、SSH、定时任务和自动证书续期的小型家庭服务器。
这次故障算是正式结案。
有时候,最深的 bug,藏在最普通的一行 mount -o bind 后面。