前几天无意中发现 OpenWrt 中有 apk 这个命令,当时就想:这不是 Alpine 的包管理器嘛,怎么 OpenWrt 上也有?于是果断尝试,发现还真能用!不过在这之前,需要改一下镜像源并导入密钥:

1
2
3
sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories
apk update --allow-untrust
apk add -X https://dl-cdn.alpinelinux.org/alpine/v3.16/main -u alpine-keys --allow-untrust

仿佛发现了新大陆一样,马上开始想着装些好玩的东西,装什么呢?于是我最先想到的是 Linux 下不是有个展示系统信息的工具 neofetch 嘛,可惜 OpenWrt 的仓库里没这个包,那么 Alpine 有没有呢?

1
apk add neofetch

好,装上了!赶快运行一下试试!

多么炫酷的系统信息展示!(雾)

然而,这只是噩梦的开始。

问题的序曲

通常,为了满足我的日常网络需求,我会在 OpenWrt 上安装 OpenClash。向往常一样,使用 opkg 安装 luci-app-openclash 这个包,然后下载一下 Clash 内核,就可以开始用了。

启动 OpenClash 时并没有碰到什么障碍,就是启动之后似乎界面有点 bug?

此处有伏笔

看上去守护程序显示未运行?可是明明感觉运行的很正常,控制面板也能进,网络访问也没有问题的说。嘛,不管了,反正能用,可能只是界面上的 Bug,等开发者修复就好啦~

次日,我因为某些需求需要暂时把 OpenClash 关掉,然后问题就出现了:关掉 OpenClash 后,怎么没网了?

没错,不仅是国外网站,所有网站都不能访问了,ping 也不通,哪里出了问题?

于是我开始逐项向上排查,很快就发现,我本机的所有 DNS 请求全部都返回拒绝访问。于是去 OpenWrt 后台设置里看了一下,马上发现了一行很可疑的配置项:

我的 DNS 请求为什么要转发到这个端口?首先我想到的便是 OpenClash,可是我现在没有运行它啊?

算了,把这个配置删掉应该就正常了吧。于是点击删除按钮,然后保存并应用。页面也显示保存成功。

然而没用,还是没网,DNS 请求仍然返回拒绝访问。难道 DNS 配置还有其他的问题?于是我又回到了 DNS 配置页面,令我惊讶的是:DNS 转发的输入框中仍然写着 127.0.0.1#7874,没有一点变化。

邪门,我刚才不是删掉了吗?难道我没点应用?于是再删掉,点击保存并应用,页面提示保存成功,刷新页面。

那个刺眼的 127.0.0.1#7874 仍然岿然不动,似乎我怎么样都奈何不了它。

OpenClash 背大锅

真是邪了门了,怎么会呢?于是我想到 OpenClash 运行的时候可能会修改 Dnsmasq 的上游以实现 DNS 劫持,可是我现在没有开 OpenClash 啊?

抱着怀疑的态度,我点开了 OpenClash 的运行日志:

1
2022-09-21 10:46:41 守护程序:重新设置 Dnsmasq 的 DNS 转发选项...

奇了怪了,你守护程序刚才不是说未运行吗?怎么现在还在自动修改我的 Dnsmasq ?

为了确认 OpenClash 到底有没有在运行,我又去确认了一遍:主程序和守护程序都显示未运行。

好,现在我把 OpenClash 启动,观察一下行为。启动之后,网络恢复正常,显然是 clash 的 dns 劫持端口 7874 开始正常工作了。

于是我临时判断:这是 OpenClash 的大 bug。

幕后真凶的浮现

就在我在 OpenClash 的 GitHub 项目主页写 issue 报告的时候,越想越不对劲,按理说当我点击 OpenClash 的关闭按钮之后,它是会清除 Dnsmasq 的转发选项的,但是现在没有清除,难道 OpenClash 的开发者会犯这么低级的错误吗?另外守护程序明明显示未运行却为什么又偷偷改我的 DNS 转发配置呢?

为了一探究竟,我决定自己好好的研究一波。

先从问题的最表面下手:OpenClash 的前端为什么显示守护程序未运行?

抓包看看!

经过前端调试分析,我发现了前端是通过 jsonrpc 来向后端获取运行状态的,每两秒就会请求一次。其中有一个接口的路径是:/cgi-bin/luci/admin/services/openclash/get_run_mode

没错,就是他,点开响应看看:

1
2
3
4
5
{
"clash": true,
"watchdog": false,
"mode": null
}

显然这个 watchdog 字段就是守护进程的运行状态。至于为什么叫这个名字,就要谈谈 看门狗(WatchDog)这个东西了:

看门狗计时器(英语:watchdog timer)是一种电脑硬件的计时设备,当系统的主程序发生某些错误事件时,如假死机或未定时的清除看门狗计时器的内含计时值(多半是向对计时器发送清除信号),这时看门狗计时器就会对系统发出重置、重启或关闭的信号,使系统从悬停状态恢复到正常运作状态。看门狗一旦使用便不能停止。一般情况下计数器在系统休眠时依然计数,但在某些芯片上,处于低功耗模式下的看门狗仅仅保留寄存器数据但不计数。

这里的看门狗显然不是硬件看门狗而是软件看门狗了。它的作用也就是监控进程(这里就是 OpenClash 主程序)的运行状态,在必要的时候重启主进程并执行相应的配置操作,所以叫做守护进程也可以理解。

回到正题,为什么后端返回的 watchdog 字段会是 false 呢?

这里继续向下研究就需要翻找 OpenClash 的源码了。于是我打开 GitHub Repo,查找 watchdog 关键字。

在 openclash.sh 的第 698 行,找到了这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
function action_get_run_mode()
if mode() then
luci.http.prepare_content("application/json")
luci.http.write_json({
clash = is_running(),
watchdog = is_watchdog(),
mode = mode();
})
else
luci.http.status(500, "Get Faild")
return
end
end

继续向下搜索 is_watchdog 这个函数:

1
2
3
local function is_watchdog()
return process_status("openclash_watchdog.sh")
end

这里调用了 process_status 这个函数,推测是判断某个进程的运行状态?继续点过去看看:

1
2
3
4
5
6
7
8
function process_status(name)
local ps_version = luci.sys.exec("ps --version 2>&1 |grep -c procps-ng |tr -d '\n'")
if ps_version == "1" then
return luci.sys.call(string.format("ps -efw |grep '%s' |grep -v grep >/dev/null", name)) == 0
else
return luci.sys.call(string.format("ps -w |grep '%s' |grep -v grep >/dev/null", name)) == 0
end
end

这里调用了 luci.sys.call 函数,虽然我没学过 Lua 也没学过 Luci,但看这个名字很明显就是调用系统命令行了。那么命令行输出了什么呢?打开命令行手动执行一下看看:

1
2
3
4
5
6
7
8
9
10
$ ps -w | grep 'openclash_watch_dog.sh'
ps: unrecognized option: w
BusyBox v1.35.0 (2022-09-09 03:13:45 UTC) multi-call binary.

Usage: ps

Show list of processes

-o COL1,COL2=HEADER Select columns for display
-T Show threads

嗯?没有 -w 这个 flag ?

不应该啊,难道是我 ps 的版本有问题?我本地的 ps 命令都有这个 flag 的说。

然而 OpenWrt 没有 ps 这个包,那 ps 命令来源于哪个包呢?先 cd 到 /bin 目录,看一下 ps 这个文件到底是什么东西。

1
2
3
$ cd /bin
$ file ps
ps: symbolic link to busybox

符号链接倒是很正常,但 busybox 是什么,怎么这么耳熟?

… 啊,Busybox 嘛,想起来很久以前刷机的时候,如果 root 之后想整点花活,那就得装 Busybox 这个东西,它是一个工具箱,包含了上百个常用的 linux 常用命令集合。

那么也就是说 OpenWrt 的 ps 命令也是 busybox 提供的咯?看一下这个包的详情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ opkg info busybox
Package: busybox
Version: 1.33.2-32
Depends: libc
Conflicts: busybox-selinux
Status: install user installed
Section: base
Essential: yes
Architecture: x86_64
Size: 232976
Filename: busybox_1.33.2-32_x86_64.ipk
Description: The Swiss Army Knife of embedded Linux.
It slices, it dices, it makes Julian Fries.
Installed-Time: 1657334765

显然是它提供的了,那为什么它的 ps 命令没 -w 参数呢?

… 等下,仔细看看,是不是,哪里不太对劲?

上面使用 ps 命令的时候,输出的错误信息里面是 BusyBox v1.35.0,而我使用 opkg info 查看这个包的详情的时候,发现这个包的最新版本是 1.33.2-32

排查到这里,我大概已经知道了问题在哪了,我的 busybox 版本被替换了,那么是谁替换的呢?显然答案只有一个:apk 包管理器。

验证一下:

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
26
27
28
29
$ apk info
alpine-keys
musl
busybox
busybox-binsh
ncurses-terminfo-base
ncurses-libs
readline
bash
neofetch

$ apk info busybox
busybox-1.35.0-r25 description:
Size optimized toolbox of many common UNIX utilities

busybox-1.35.0-r25 webpage:
https://busybox.net/

busybox-1.35.0-r25 installed size:
944 KiB

busybox-1.35.0-r17 description:
Size optimized toolbox of many common UNIX utilities

busybox-1.35.0-r17 webpage:
https://busybox.net/

busybox-1.35.0-r17 installed size:
940 KiB

因为 neofetch 这个包依赖 busybox,但是 apk 以为我没装 busybox,于是就从 Alpine Linux 的仓库下载了 Busybox 给我装到了系统里,但这个 Busybox 似乎跟 OpenWrt 的并不通用。

不过到这里还并没有完全确定,为了验证 OpenWrt 的 busybox 的 ps 命令是有 -w 这个参数的,我又打开了之前测试用的装在虚拟机里的 OpenWrt,执行一下 ps 命令,没错,果然是有 -w 这个参数的。

问题的解决

既然已经找到了问题的根源,那么怎么解决呢?最好还是让 opkg 把原本的 busybox 装回去:

1
2
3
4
5
6
7
$ opkg install busybox --force-reinstall
Refusing to remove essential package busybox.
Removing an essential package may lead to an unusable system, but if
you enjoy that kind of pain, you can force opkg to proceed against
its will with the option: --force-removal-of-essential-packages
No packages removed.
Package busybox (1.33.2-32) installed in root is up to date.

嗯… 显然 opkg 并不想这么干。因为 busybox 是系统关键包,而强制重新安装要先走一遍卸载,opkg 也不确定会出什么问题。

当然,我也不确定。

那就想一个折中一点的方案吧:把 busybox 的二进制文件替换回去。

首先得拿到原版 busybox 的包文件:

1
2
3
4
$ mkdir busybox && cd busybox
$ opkg download busybox
Downloading https://mirrors.vsean.net/openwrt/releases/21.02.1/packages/x86_64/base/busybox_1.33.2-32_x86_64.ipk
Downloaded busybox as ./busybox_1.33.2-32_x86_64.ipk.

然后解包,提取出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ tar -xvf busybox_1.33.2-32_x86_64.ipk
./debian-binary
./data.tar.gz
./control.tar.gz
$ tar -zxvf data.tar.gz
./
./bin/
./bin/ash
./bin/busybox
./bin/cat
./bin/chgrp
./bin/chmod
./bin/chown
./bin/cp
...

然后把 busybox 文件强制覆盖过去:

1
$ cp -f ./bin/busybox /bin/

使用 ps 命令检查一下,发现已经有 -w 参数了:

1
2
3
4
5
6
7
BusyBox v1.33.2 (2022-09-09 03:13:45 UTC) multi-call binary.

Usage: ps

Show list of processes

w Wide output

回到 OpenClash 的管理页面查看,守护进程的运行状态也已经变成了 “运行中”。

尝试关闭 OpenClash,然后打开 DNS 配置页面,DNS 转发的配置也已经被正常清除。

这个世界又恢复过来了。(长舒一口气.jpg)

后记

其实在排查之前我是真没想到能排查到这里的,更没想过会是 busybox 的原因,不过这一次我做到了。

后来我又去 Busybox 的官方仓库看了一下,最新的版本确实是 1.35.0 版本,而且 OpenWrt 所用的 1.33.2 版本似乎并没有出现在官方的 Binaries 里面。但 OpenWrt 用这个版本应该是有原因的,至于是什么原因我也就懒得深究了。

总结

永远不要在 OpenWrt 上使用 apk 包管理器。

(完)