AI 声明:本文由 AI 辅助撰写,代码和实测数据来自本机真实运行记录(OpenList v4.2.7 / Natter 2.2.1 / Python 3.12.15)。打洞能不能成取决于运营商,文中结论只对全锥形 NAT 成立。
前言
一开始只是想验证一件事:家里的宽带到底是不是全锥形 NAT。
Natter 这类工具靠 STUN 拿到自己的公网映射,如果 NAT 是 EIM+EIF(也就是常说的 Full Cone),这个映射就能被外部主动连进来。验证方法很简单,跑起来看有没有 [ OPEN ]。
问题在于,验证完就没下文了——手里没有值得暴露的服务。为了让这条链路真的跑通一次,我挂了个 OpenList 上去,结果一路从「跑个脚本」变成了「写一个模块」,最后拆成两个仓库:
- openlist-nat:KernelSU / Magisk 模块,内置 OpenList 和打洞,开机自启,带 WebUI
- natter-redirect:一个 Cloudflare Worker,给会变的
IP:PORT配固定域名
两个仓库在 Gitee 都有同名镜像(kukieol),下面提到时不再重复写地址。
这篇文章讲的是中间那些不那么显然的部分:为什么要在 Android 上塞一个静态 musl Python、Natter 默认配置为什么会把浏览器卡死、地址漂移该怎么守,以及写 Worker 时差点留下的一个鉴权洞。
为什么不直接在 Termux 里跑
第一版就是 Termux 脚本,能用,但有几个绕不过去的问题:
- 后台会被杀。Android 的进程管理不看你的心情,Termux 常驻要么挂 wakelock(费电),要么随时被回收。
- 依赖会漂。
pkg upgrade一次,Python 版本、OpenSSL 路径全变,脚本就废了。 - 没法给别人用。想分享就得让对方装 Termux、配源、装 Python、再理解一堆路径。
做成模块之后这三点都解决了:/data/adb/service.d 那一套是开机就跑的,进程由 root 拉起不受电池优化影响;运行时全部打包进 zip,不依赖系统里任何东西;别人刷个包就能用。
代价是,你得自己解决「在 Android 上跑一个 Linux 用户态」这件事。
整体结构
公网 运营商 NAT 手机
浏览器 ──────────▶ 114.x.x.x:6829 ─────────────▶ 192.168.2.7:5244 ──▶ OpenList
▲
│ 60s 心跳;地址一变就重打并重新上报
│
Natter(STUN 打洞,socket 转发)
│
└──▶ Cloudflare Worker ──▶ KV:当前 IP:PORT
▲
https://你的域名
模块里跑三个进程:
| 进程 | 作用 |
|---|---|
bin/openlist | 网盘本体,监听 5244 |
natter/natter.py | STUN 打洞,把公网 IP:PORT 映射到 5244 |
natter/supervise.sh | 守护循环,Natter 退出就重启,局域网 IP 变了也重启 |
外面还有一层 ctl.sh,给 WebUI 和命令行共用。
一、在 Android 上跑一个 musl Python
Natter 是 Python 写的。Android 系统里没有 Python,Termux 的又依赖 $PREFIX,所以只能自带一份。
选 python-build-standalone 的 musl 构建:静态、不依赖 glibc,体积也还能接受(裁剪后 34MB)。但直接解压出来是跑不起来的:
CANNOT LINK EXECUTABLE "python3.12": required file not found
原因是 ELF 的 PT_INTERP 指向 /lib/ld-musl-aarch64.so.1,而 Android 上根本没有这个路径。
loader 必须显式调用
解决办法是从 Alpine 的 musl 包里把 loader 抠出来,放进 python/lib/,然后显式调用它:
exec "$D/lib/ld-musl-aarch64.so.1" --library-path "$D/lib" "$D/bin/python3.12" "$@"
注意是 --library-path 这个 loader 参数,不是环境变量。
不要 export LD_LIBRARY_PATH
这里有个很隐蔽的坑。一开始我图省事写的是:
export LD_LIBRARY_PATH="$D/lib"
exec "$D/bin/python3.12" "$@"
Python 本身能跑,但 Natter 一起就炸:
CANNOT LINK EXECUTABLE "/system/bin/sh": cannot find "libc.so"
因为环境变量是会继承的。Natter 内部要用 subprocess 起 /system/bin/sh 去执行回调脚本,子进程带着 LD_LIBRARY_PATH 指向 musl 目录,bionic 的 linker 就去找 musl 的 libc.so,版本对不上直接崩。
换成 loader 的 --library-path 就干净了:只在这一次 exec 生效,不污染子进程。
同理还有 unset LD_PRELOAD——如果从 Termux 里调起来,libtermux-exec-ld-preload.so 会被 musl 加载,报一堆 symbol not found。
DNS 和证书
musl 不经过 Android 的 netd,DNS 只认 /etc/resolv.conf。而 Android 上没有这个文件。
模块的办法是 overlay 一份 system/etc/resolv.conf(Magisk/KSU 的挂载机制会把模块里的 system/ 叠到真实 /system 上),内容就两行:
nameserver 223.5.5.5
nameserver 119.29.29.29
万一 overlay 没生效,supervise.sh 里还有一层兜底:用 unshare -m 起个挂载命名空间,tmpfs 盖掉 /system/etc 再写一份,只影响这一个进程树。
证书是另一个坑。musl 的 OpenSSL 不认 SSL_CERT_DIR=/system/etc/security/cacerts——那是 Android 自己的证书目录格式,不是 OpenSSL 的 hash 目录。只能给一个 pem 文件:
export SSL_CERT_FILE="$D/lib/cacert.pem"
证书直接从 curl.se 拉 Mozilla 的包,构建时下好塞进去。
二、Natter 的两个性能坑
打洞通了之后,第一反应是「怎么这么慢」。页面转圈十几秒才出来,大文件几 MB/s。
线程上限
Natter 默认用 -m socket,也就是自己起 Python 线程转发。上游代码里:
self.buff_size = 8192
self.max_threads = 128
一条 TCP 连接占两个线程(上行一个、下行一个),128 的上限意味着同时只能有 64 条连接。而现代浏览器打开一个 SPA,光首屏就会并发十几条 keep-alive,加上预加载、图标、字体,很容易冲到上限。超了之后 Natter 直接不接,浏览器看到的就是连接被重置,然后无限重试。
缓冲区 8KB 也在拖后腿:每次 recv 只搬 8KB,RTT 一大吞吐就上不去。
这两个值都在 natter.py 里改掉:
self.buff_size = 65536
self.max_threads = 2048
socat 的 SO_REUSEPORT
Natter 还支持 -m socat,把转发交给 socat,省掉 Python 的线程开销。看起来更优雅,但实际不能用。
原因是心跳。Natter 每隔 -k 秒会重新绑定一次端口来续命,这要求 socket 带 SO_REUSEPORT——允许多个 socket 绑同一个端口,切换时不断流。Python 的实现里两个 socket 都设了;socat 只设 SO_REUSEADDR,于是重绑的时候:
[Errno 98] Address already in use
更麻烦的是它是静默失败的:映射看起来还在,日志也没明显报错,但实际上已经不通了,要等下一次地址变化才恢复。
两种方式的实测:
| 方式 | 吞吐 | 并发 | 稳定性 |
|---|---|---|---|
| socat | 6.6 MB/s | 受限于 socat | 心跳重绑失败,映射会丢 |
| socket(打过补丁) | 7.57 MB/s | 2048 线程 | 稳定 |
所以最后还是用回了 socket 模式,只是把两个参数调大。
三、地址漂移是常态
打洞拿到的公网端口不是永久的。PPPoE 重拨、CGNAT 映射回收、运营商换出口,任何一个都会让 IP:PORT 变掉。
Natter 的处理是 -q:一旦检测到地址变化就直接退出,把重新打洞交给外面。所以模块里需要一个守护:
while :; do
"$PY" "$BASE/natter.py" -m socket -p 5244 -k 60 -q -e "$BASE/notify.sh"
sleep 3
done
-k 60 是心跳间隔,60 秒一次,保证映射不会因为空闲被回收。
除了公网地址,局域网地址也会变——切 WiFi、换热点,192.168.2.7 就变成别的了,这时候 Natter 绑的本地地址就失效了。所以守护进程里还有一个小循环,每 10 秒比对一次 ip -4 addr show wlan0:
new=$(lan_ip)
if [ "$new" != "$old" ]; then
pkill -f natter.py # 逼它重来
fi
四、给会变的地址一个固定域名
为什么 DNS 不够
最直觉的做法是 DDNS:把域名的 A 记录指向当前公网 IP。但打洞暴露的不是 80/443,而是运营商随机分配的端口,比如 114.224.183.196:6829。DNS 只能存 IP,存不了端口。
所以域名不能直接指向服务,只能指向一个「知道当前地址在哪」的东西,让它做跳转。
Worker 怎么做
Cloudflare Worker + KV,逻辑很短:
- 设备侧每次拿到新地址,
POST /_nat/update,body 是IP:PORT,带x-auth头 - Worker 校验后写进 KV,key 就叫
addr - 浏览器访问域名,Worker 读 KV,返回
302到当前地址,path 和 query 原样带上
const { addr } = await readAddr(env);
if (!addr) return new Response("No address reported yet.\n", { status: 503 });
return Response.redirect("http://" + addr + url.pathname + url.search, 302);
设备侧就是一个 Natter 的 -e 回调,参数是 <proto> <私网IP> <私网端口> <公网IP> <公网端口>:
[ "$1" = "tcp" ] || exit 0
curl -s -m 10 -X POST "$SITE/_nat/update" \
-H "x-auth: $SECRET" \
--data "$4:$5"
没上报过的时候返回 503 而不是跳到某个默认地址,这样出问题一眼能看出来是「设备没上报」还是「映射挂了」。
一个真实的安全漏洞
第一版写出来是这样的:
if (!safeEqual(request.headers.get("x-auth") || "", env.SECRET || "")) {
return json({ error: "unauthorized" }, 401);
}
safeEqual 是逐字节异或比较,长度不等直接 false,看起来没问题。
但如果部署的时候忘了配 SECRET 这个 binding,env.SECRET 就是 undefined,|| "" 把它变成空串;请求头没带也是空串。空串和空串长度相等,异或结果为 0,函数返回 true。
也就是说,没配密钥 = 没有鉴权,任何人 POST 一下就能把你的域名指到任意 IP。这比「配错了打不开」危险得多,因为它是静默放行的。
修法是让「没配置」变成一个显式的拒绝条件:
function authorized(request, env) {
const secret = env.SECRET;
if (typeof secret !== "string" || secret.length < 16) return false;
return safeEqual(request.headers.get("x-auth") || "", secret);
}
顺手把 /_nat/status 也加上了同样的校验——它原本是公开的,等于把当前公网 IP:PORT 白送给任何来问的人。
这类问题的通用形态是:安全判断的默认分支必须是拒绝。写成「校验失败才拒绝」的时候,一定要问一句「校验函数在输入缺失时返回什么」。
五、WebUI 和模块本身
KernelSU 的模块 WebUI 就是一个 HTML 页面,管理器用 WebView 打开 webroot/index.html,并注入一个 window.ksu 对象。调用长这样:
ksu.exec("命令", "{}", "回调名");
没有 Promise,结果通过全局函数 window[回调名](errno, stdout, stderr) 回来。所以前端包了一层:
function sh(cmd) {
return new Promise(function (res, rej) {
var cb = "cb" + Math.random().toString(36).slice(2);
window[cb] = function (errno, out) { errno === 0 ? res(out) : rej(errno); };
ksu.exec(cmd, "{}", cb);
});
}
页面本身不解析任何系统状态,全部交给 ctl.sh status,输出一串 key=value:
openlist=1
natter=1
port=5244
lan=192.168.2.7
map=114.224.183.196:6829
domain=https://ol.kukie.cn
前端 parseKV() 拆开填表,每 6 秒刷一次。这样页面和脚本的职责是分开的:要加一个状态字段,改 ctl.sh 和一行渲染就够了,不用碰系统调用。
模块目录和数据目录是分开的:
| 路径 | 内容 | 升级时 |
|---|---|---|
/data/adb/modules/openlist-nat | 程序本体 | 整个替换 |
/data/adb/openlist | 数据库、文件 | 保留 |
/data/adb/openlist-nat/config | 上报域名 | 保留 |
/data/adb/openlist-nat/secret | 上报密钥 | 保留 |
customize.sh 只在配置文件不存在时才写默认值,所以升级不会把用户的域名冲掉。
功耗实测
「后台常驻」最容易被质疑的就是耗电,所以专门测了一段:
| 进程 | CPU | 内存 | 唤醒 |
|---|---|---|---|
| openlist | 0.017% | 79.6 MB | — |
| natter | < 0.01% | 18.4 MB | — |
| supervise | 0% | 4.9 MB | — |
| 合计 | 约 0.02% | 103 MB | 9 次/分钟 |
模块本身不持 wakelock,唤醒主要来自 60 秒一次的心跳和 10 秒一次的 IP 检查。
顺带发现一个真凶:测试期间电池掉得明显,查下来是 Termux 的 termux:service-wakelock 已经持了 1 小时 33 分——那是我之前调试留下的,跟模块无关。termux-wake-unlock 放掉之后功耗就正常了。
怎么用
刷入 Release 里的 zip,重启。之后三种入口都能用:
WebUI:管理器 → 模块 → 打开。状态、启停、日志、上报域名都在一页里。
操作按钮:模块页的「操作」直接切换整套服务的启停。
命令行:
su -c '/data/adb/modules/openlist-nat/ctl.sh status'
su -c '/data/adb/modules/openlist-nat/ctl.sh start'
su -c '/data/adb/modules/openlist-nat/ctl.sh set-domain https://nat.example.com'
set-domain 传空字符串就是清空,只打洞不上报,适合只想在局域网里用的情况。
局限
- 只对全锥形 NAT 有效。对称 NAT、双重 CGNAT 打不通,这不是代码能解决的。
- 地址一定会变。心跳能延长映射寿命,但运营商随时可以回收。
- 没有 TLS。Natter 穿的是裸 TCP,跳转是
http://。要 HTTPS 得自己在设备侧加反代,或者在 Cloudflare 那边做一层。 - arm64 only。内置的 OpenList 和 Python 都是 aarch64 的。
打洞这件事本质上是借运营商的宽松策略用,什么时候收紧不由你决定。当个临时方案可以,别当生产环境。
结语
从「验证一下 NAT 类型」到两个仓库,中间大部分时间其实花在三件跟打洞无关的事上:让 musl 的 Python 在 bionic 的世界里跑起来、把 Natter 的默认参数调到能用、以及处理「地址随时会变」这个前提。
这些坑单看都不复杂,但凑在一起就是一个完整的「在非标准环境里跑标准服务」的样本。代码都在仓库里,模块部分 GPL-3.0(因为 natter.py 是上游的 GPLv3),Worker 是 MIT。
| 项目 | GitHub | Gitee |
|---|---|---|
| 模块 | qiuyuxc/openlist-nat | kukieol/openlist-nat |
| Worker | qiuyuxc/natter-redirect | kukieol/natter-redirect |

留下一点想法
使用 GitHub 账号登录,分享你的想法。