回到笔记

手机当公网网盘:把 OpenList 和 Natter 打洞塞进一个 Android 模块

从 NAT 打洞到 KernelSU 模块:静态 musl Python 怎么在 Android 上跑起来、Natter 为什么一开网页就卡、地址漂移怎么守,以及给会变的 IP:PORT 配固定域名时踩到的鉴权漏洞。

手机当公网网盘:把 OpenList 和 Natter 打洞塞进一个 Android 模块

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.pySTUN 打洞,把公网 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

更麻烦的是它是静默失败的:映射看起来还在,日志也没明显报错,但实际上已经不通了,要等下一次地址变化才恢复。

两种方式的实测:

方式吞吐并发稳定性
socat6.6 MB/s受限于 socat心跳重绑失败,映射会丢
socket(打过补丁)7.57 MB/s2048 线程稳定

所以最后还是用回了 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内存唤醒
openlist0.017%79.6 MB—
natter< 0.01%18.4 MB—
supervise0%4.9 MB—
合计约 0.02%103 MB9 次/分钟

模块本身不持 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。

项目GitHubGitee
模块qiuyuxc/openlist-natkukieol/openlist-nat
Workerqiuyuxc/natter-redirectkukieol/natter-redirect

感谢你读到这里。

继续翻翻

留下一点想法

在 GitHub 查看 ↗

使用 GitHub 账号登录,分享你的想法。

正在准备评论…

来自 Kukie 的小提示

站点公告