release: 1.0.8 — VPN 页把「模块没加载」误报成「内核不支持 WireGuard」

版本号 1.0.7 → 1.0.8(19 个文件 55 处,逐条过;历史陈述不动)。
顺带补上1.0.7 漏掉的更新日志小节(那一版只改了版本号)。

## 本轮修的缺陷

**先说结论:Debian 13 虚拟机上跑 drouter,KVM 不是容器,
内核里一直有 WireGuard —— 是面板自己判错了。**

原 _vpn_installed() 只看两件事:/sys/module/wireguard 在不在(模块当前
已加载)、/usr/bin/wg 在不在(工具已安装)。**从没查过 /lib/modules 下
有没有模块文件** → 「模块文件在但没加载」被压进「内核真没这个模块」。

而 Debian 走 udev 按需加载,/etc/modules 与 /etc/modules-load.d 默认都
不写 wireguard,模块在磁盘上但没进内存。本机实测:内核 6.12.107+deb13-amd64,
wireguard.ko.xz 在,未加载;wireguard-tools 也没装。

前端还配了一句写死的文案把它坐实:「通常说明跑在精简容器里」——
而这台是 PVE 上的 KVM 虚拟机。排查方向被这句话整个带偏。

修法:
- 判据扩到四态(ready / need_module / need_tool / unsupported),
  每态给可执行的下一步。unsupported 明确说装不了且不给一键修复。
- 模块文件名覆盖 .ko / .ko.xz / .ko.zst(Debian 默认压成 .ko.xz,
  只认 .ko 会把正常机器也判成 unsupported)
- 新增「一键修复」:modprobe → 写 /etc/modules-load.d/ 开机自启 →
  缺工具则 apt-get install,确认弹窗写清三步并告知不动网络
- apply 改为先自愈再报错,不再直接弹「内核不支持」把人挡在门外
- 老后端兜底 unknown 而非把旧的 no 猜成 unsupported(猜错=复活本 bug)
- 页面新增「运行环境」行;改掉「不需要装任何第三方软件」这句误导

## 测试基建:这轮抓出 3 条恒绿断言 + 1 处死分支

反向验证(把 bug 注入回去看它变红)逮到三条永远不会红的断言:
把探测路径改成 /nonexistent 仍全绿(docstring 里就写着那句话);
把 'env': _vpn_env() 注释掉仍全绿(我写的是 #注释,正则照样匹配);
把 if (!confirm( 改成 if (false && confirm( 仍全绿(提示文案里有那句话)。

根因:习惯在注释里引用被删掉的旧代码来解释 bug 来历,这些字面量让
「这段代码还在吗」类的判据永远成立。修法:t-107 统一走 py_code_only() /
js_code_only()(先按ast 判语义剥 docstring,再剥行注释),16 个注入全部被抓住。

死分支:用小锚点 Edit 只替换 if (d.installed === 'no') { 这一行时,
旧 body 与它的 } else if (!d.has_systemd) { 原样留在原地,拼出一个
「没有 systemd → 报内核不支持」的假分支——语法合法、静态检查也过。
防线:断言 } else if ( 的数量恰好等于分支数。

另有 4 处判据写错导致假红,共同形态是「脚本假设了一个修好之后就不成立
的前提」——比如「模块必须未加载」这条,fix 一跑完就假红。

## 验证

- t-107:126 → 167 项
- 反向验证 16/16 被抓住
- 新增 devtools/check-vpn-render.js 36 项(挂进 sync.sh 预检链)
- 新增 _dev/live-108-wg.py 真机验收 43 项;真机 _vpn_env 从 need_module
  修到 ready,wg 装上(v1.0.20210914),modules-load.d 已写入
- t-*.py 51 个全绿 / 静态检查器 39 个全绿 / 前端四查全绿 / md 0 死链
- 容器冒烟新增第 14 节:镜像里真跑 _vpn_env() 验四态判定
  (不 grep 字面量——注释里也会写这些词)
- deb 内 helper 与 app.js sha256 与本地逐字节一致
This commit is contained in:
liuzhuohua committed 2026-10-03 12:28:21 +08:00
1 parent e1125e3978
commit 2f8f09604b
23 files changed
+1669 -79

No files matched your search

+95 -21
View File
@@ -462,7 +462,7 @@ Drouter 是普通 Debian 软件包,跑在哪都行:
路由器往往出现在**不方便接外网**的地方:机房、弱电箱、隔离网段、
或者一台你根本不想让它联网的机器。所以 Release 里的
`drouter-1.0.7-offline-amd64.tar.gz` 不是「只发一个 `.deb`」,
`drouter-1.0.8-offline-amd64.tar.gz` 不是「只发一个 `.deb`」,
而是把 **Drouter 本体 + 它跑起来需要的全部 Debian 依赖**一起打成了一个包。
解包出来是这样:
@@ -470,8 +470,8 @@ Drouter 是普通 Debian 软件包,跑在哪都行:
```bash
# 1) 把 tar.gz 拷到目标机(U 盘 / scp / 内网共享都行)
# 2) 解包
tar xzf drouter-1.0.7-offline-amd64.tar.gz
cd drouter-1.0.7-offline-amd64
tar xzf drouter-1.0.8-offline-amd64.tar.gz
cd drouter-1.0.8-offline-amd64
# 3) 一条命令装完(会自动 sudo)
sudo bash install.sh
@@ -494,11 +494,11 @@ sudo bash install.sh
<summary><b>包里到底装了什么(点开看)</b></summary>
```
drouter-1.0.7-offline-amd64/
drouter-1.0.8-offline-amd64/
├── install.sh 安装脚本(自动提权 + 本地 file: 源,零外网)
├── README-离线安装.md 给不熟悉 Linux 的人看的逐步说明
├── SHA256SUMS 237 条校验和(236 依赖 + 本体)
├── drouter_1.0.7_all.deb Drouter 本体(含 6 个 systemd 单元)
├── drouter_1.0.8_all.deb Drouter 本体(含 6 个 systemd 单元)
└── debs/ 236 个依赖 .deb
├── nftables_*.deb ├─ 直接依赖
├── dnsmasq_*.deb
@@ -535,9 +535,9 @@ drouter-1.0.7-offline-amd64/
</details>
**想要更小的包?** 如果你的目标机有网(哪怕是内网自建 apt 镜像),
可以只拿 `drouter_1.0.7_all.deb`(约 500 KB),见下方「方式二」。
可以只拿 `drouter_1.0.8_all.deb`(约 506 KB),见下方「方式二」。
**已经装过依赖、只想升级本体?** 同样只用那个 500 KB 的 `.deb`
**已经装过依赖、只想升级本体?** 同样只用那个 506 KB 的 `.deb`
`dpkg -i` 覆盖安装即可,不必重新走离线包。
### 方式二:在线安装(目标机有网)
@@ -545,7 +545,7 @@ drouter-1.0.7-offline-amd64/
如果目标机能连上 Debian 官方源(或内网 apt 镜像),只需要本体那一个文件:
```bash
sudo apt install ./drouter_1.0.7_all.deb
sudo apt install ./drouter_1.0.8_all.deb
sudo drouter-ctl status
```
@@ -555,14 +555,14 @@ sudo drouter-ctl status
### 方式三:Docker(体验用)
项目提供 `drouter-1.0.7-docker.tar`(约 169 MB,已含完整根文件系统)。
项目提供 `drouter-1.0.8-docker.tar`(约 169 MB,已含完整根文件系统)。
**方式 A:docker compose(推荐)**
Release 里附了现成的 `docker-compose.yml`,直接下下来用:
```bash
docker load -i drouter-1.0.7-docker.tar
docker load -i drouter-1.0.8-docker.tar
# 把 docker-compose.yml 放到当前目录
docker compose up -d
@@ -572,7 +572,7 @@ docker compose ps # 等 health 变 healthy
**方式 B:docker run**
```bash
docker load -i drouter-1.0.7-docker.tar
docker load -i drouter-1.0.8-docker.tar
docker run -d --name drouter --restart unless-stopped \
--network host \
@@ -582,11 +582,11 @@ docker run -d --name drouter --restart unless-stopped \
-v drouter-data:/opt/drouter/data \
-v drouter-snapshots:/opt/drouter/snapshots \
-v drouter-log:/var/log/drouter \
drouter:1.0.7
drouter:1.0.8
```
两种方式加载后镜像标签都是 **`drouter:1.0.7`**(另有一个
`liuzhuohua/drouter:1.0.7` 别名,同一个镜像)。
两种方式加载后镜像标签都是 **`drouter:1.0.8`**(另有一个
`liuzhuohua/drouter:1.0.8` 别名,同一个镜像)。
访问 `https://<宿主IP>:8443/`,账号 `admin` / `admin123`。
@@ -618,17 +618,17 @@ docker run -d --name drouter --restart unless-stopped \
git clone https://github.com/liuzhuohua/drouter.git
cd drouter
# 本体 deb(→ dist/drouter_1.0.7_all.deb,约 500 KB)
bash packaging/build-deb.sh 1.0.7
# 本体 deb(→ dist/drouter_1.0.8_all.deb,约 506 KB)
bash packaging/build-deb.sh 1.0.8
# 离线依赖库(→ dist/offline-deps/,236 个 .deb + Packages 索引)
bash scripts/make-offline-deps.sh
# 自包含离线包(→ dist/drouter-1.0.7-offline-amd64.tar.gz,约 83 MB)
bash packaging/build-offline-bundle.sh 1.0.7
# 自包含离线包(→ dist/drouter-1.0.8-offline-amd64.tar.gz,约 83 MB)
bash packaging/build-offline-bundle.sh 1.0.8
# Docker 镜像(→ dist/drouter-1.0.7-docker.tar,约 169 MB)
bash packaging/build-docker.sh 1.0.7
# Docker 镜像(→ dist/drouter-1.0.8-docker.tar,约 169 MB)
bash packaging/build-docker.sh 1.0.8
```
构建顺序上,`build-offline-bundle.sh` 会**自己**先调 `build-deb.sh`
@@ -960,7 +960,7 @@ sudo ss -tlnp | grep 8443 # 看端口有没有在听
```bash
# 下载新版 deb
sudo apt install ./drouter_1.0.7_all.deb # 直接覆盖安装,配置保留
sudo apt install ./drouter_1.0.8_all.deb # 直接覆盖安装,配置保留
```
配置在 `/etc/drouter/` 和 SQLite 里,`apt remove` 也会保留(`purge` 才删)。
@@ -985,6 +985,80 @@ sudo apt purge drouter # 连配置一起删
## 📝 更新日志
### v1.0.8 — VPN 页把「模块没加载」误报成「内核不支持」
纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。
**先说结论:Debian 13 虚拟机上跑 drouter,KVM 不是容器,
内核里一直有 WireGuard —— 是面板自己判错了。**
原判据只看两件事:`/sys/module/wireguard` 在不在(模块**当前已加载**)、
`/usr/bin/wg` 在不在(工具**已安装**)。
**从没查过 `/lib/modules/` 下有没有模块文件** ——
于是「模块文件在、但没加载」被压进了「内核真没这个模块」。
而 Debian 走 **udev 按需加载**,`/etc/modules` 与 `/etc/modules-load.d/`
默认都不写 wireguard,模块**在磁盘上但没进内存**。
本机实测:内核 `6.12.107+deb13-amd64`,`wireguard.ko.xz` 在,未加载。
前端还配了一句写死的文案把这事坐实:
> 通常说明跑在精简容器里
**而这台是 PVE 上的 KVM 虚拟机。** 排查方向被这句话整个带偏 ——
用户会去查容器配置,真正该做的却是 `modprobe wireguard`。
- **判据从两态扩到四态**(`ready` / `need_module` / `need_tool` / `unsupported`),
每一态都给出**可执行的下一步**。`unsupported` 明确说「装不了」且
**不给一键修复** —— 不设套路的死路。
- **模块文件名覆盖 `.ko` / `.ko.xz` / `.ko.zst`**:Debian 默认压成 `.ko.xz`,
只认 `.ko` 会把正常机器也判成 `unsupported`。
- **新增「一键修复」按钮**:`modprobe` → 写
`/etc/modules-load.d/drouter-wireguard.conf`(开机自动加载)→
缺工具则 `apt-get install wireguard-tools`。确认弹窗写清三步,
并明确告知不动网络接口、防火墙与现有 VPN 配置。
- **`apply` 改为先自愈再报错**:以前直接弹「内核不支持」把用户挡在门外,
现在先尝试 `modprobe`,成功就继续。
- 页面新增「运行环境」行(内核 / 虚拟化类型 / 是否已配开机自启);
老后端(无 `env` 字段)兜底为 `unknown` 而**不猜成 `unsupported`** ——
猜错就等于把这个 bug 复活一次。
真机验证:`_vpn_env()` 从 `need_module` 修到 `ready`,
`wg` 装上(v1.0.20210914),`modules-load.d` 配置已写入
→ **重启后不用再手动 modprobe**。
- **测试基建:抓出 3 条恒绿断言 + 1 处死分支** ——
判据喂了带注释的源码,注释里引用被删掉的旧代码会让「这段代码还在吗」永远成立
(实测把探测路径改成 `/nonexistent` 仍全绿,因为 docstring 里就写着那句)。
新增的 41 条断言现已统一走 `py_code_only()` / `js_code_only()`,
**16 个注入用例全部被抓住**;`} else if (` 数量钉死,死分支无处藏身。
详见 [Release Notes](https://github.com/liuzhuohua/drouter/releases/tag/v1.0.8)。
### v1.0.7 — 备份 / 告警 / 配额三模块收尾,再抓 3 个真缺陷
新增三个模块,全部带定时器守护(`drouter-backupd` / `drouter-alertd` / `drouter-quotad`)。
| 页面 | 做什么 |
|---|---|
| **配置备份与还原** | 把全部设置导成能下载、能换机还原的包:带清单与逐文件 sha256 校验,还原前先预检,敏感项默认排除 |
| **告警与通知** | 掉线 / 磁盘满 / 温度高主动推送:Bark · 邮件 · 群机器人,带冷却期与免打扰时段 |
| **用量与账单** | 按设备与服务统计每月流量,可按比例分摊话费;后台增量聚合,不拖慢机器 |
自查 + 真机验收共修 **10 个缺陷**,其中三个值得单说:
- **默认备份静默丢失大半配置**(最严重):`_bk_files()` 把整个 `/etc/drouter`
标成敏感 → 默认导出时整目录被排除,而 `BACKUP_SCOPE` 恰恰宣称备份含
`dcfg.json` / `kern.conf` / Docker 栈文件。**界面全程显示「已导出」**,
用户换机还原才发现大半配置没了。改为逐文件判定。
- **备份包名校验比命名规则窄**:命名 charset 是 `[A-Za-z0-9_-]`,
校验只写 `[a-z0-9]+`,而 Debian 默认主机名 `debian-primaryrouter` **带连字符**
→ 导出的包自己在列表里都看不到。修完才发现**第二处同样的正则**漏改。
- **包内容校验拿不到 sha256** → 所有文件都被标成「已损坏」,还原被挡死。
刚导出来的新包立刻报损坏。
⚠️ 三个守护的 systemd timer **不在 deb 里**,是首次保存配置后由 helper
动态写出来的 —— 这是有意设计,没开的功能不该有定时器。
### v1.0.6 — 静默失效专项:六个模块的保存按钮从来没生效过
纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。
+1 -1
View File
@@ -15,7 +15,7 @@
# 把标签从 buildah 的 localhost/... 改成 docker 惯用的名字。
set -e
VER="${1:-1.0.7}"
VER="${1:-1.0.8}"
SRC="${2:-/tmp/drouter-build}"
cd "$SRC" || exit 1
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""WireGuard 环境诊断真机验收(第 11 个缺陷的验证)。
背景:2026-10-03 用户在 PVE 的 KVM 虚拟机(192.168.7.3,Debian 13)
上看到 VPN 页报「本机内核或工具不支持 WireGuard……通常说明跑在
精简容器里」。实际查证:
systemd-detect-virt → kvm(虚拟机,不是容器)
/lib/modules/6.12.107+deb13-amd64/kernel/drivers/net/wireguard/
wireguard.ko.xz → 存在
/sys/module/wireguard → 不存在(没加载)
modprobe wireguard → RC=0,模块进来了
根因:_vpn_installed() 只看「已加载」与「工具在否」,从不查
/lib/modules 下的模块文件,把「模块在但没加载」压进 no,
文案又写死「精简容器」,把排查方向整个带偏。
本脚本验三件事(**按侵入性从低到高**,每一步都可逆):
A. 只读诊断 —— env 四态字段齐全、state 判定与真机实况一致
B. modprobe —— 真机跑一次 fix,确认模块能加载
C. 开机自启 —— 确认 /etc/modules-load.d/drouter-wireguard.conf 被写出来
⚠️ 三条铁律(都是本项目踩过的):
① 验收脚本的第一个前置动作就要问「会不会改变被测对象」。
本脚本**不会**动防火墙、网卡、路由 —— modprobe 与写
modules-load.d 都不影响网络连通性,且第C 步会自己清理。
② op 名/字段名照真机源码抄,不猜。
③ 鉴权走 X-Token 头,提示字段是 msg_cn。
"""
import json
import os
import ssl
import subprocess
import sys
import urllib.error
import urllib.request
BASE = 'https://127.0.0.1:8443'
AUTOLOAD = '/etc/modules-load.d/drouter-wireguard.conf'
fails = []
oks = 0
def ck(desc, cond, extra=''):
global oks
if cond:
print(' [OK] %s' % desc)
oks += 1
else:
print(' [FAIL] %s%s' % (desc, (' ' + extra) if extra else ''))
fails.append(desc)
def ctx():
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
def call(path, payload=None, token=None, method=None, timeout=300):
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode()
headers['Content-Type'] = 'application/json'
if token:
headers['X-Token'] = token
req = urllib.request.Request(BASE + path, data=data, headers=headers,
method=method or ('POST' if data else 'GET'))
try:
with urllib.request.urlopen(req, context=ctx(), timeout=timeout) as r:
raw = r.read().decode('utf-8', 'replace')
try:
return r.status, json.loads(raw)
except Exception:
return r.status, {'_raw': raw[:600]}
except urllib.error.HTTPError as e:
raw = ''
try:
raw = e.read().decode('utf-8', 'replace')[:600]
except Exception:
pass
return e.code, {'_err': 'HTTP %s' % e.code, '_body': raw}
except Exception as e:
return 0, {'_err': str(e)}
def sh(cmd, timeout=30):
p = subprocess.run(cmd, shell=True, capture_output=True, text=True,
errors='replace', timeout=timeout)
return p.returncode, (p.stdout or '').strip(), (p.stderr or '').strip()
def main():
print('=' * 68)
print('WireGuard 环境诊断真机验收(第 11 个缺陷)')
print('=' * 68)
# ---------- 0. 前置:记录真机实况 ----------
print('\n--- 0. 真机实况(用来对照后端判定的 state)---')
rc, virt, _ = sh('systemd-detect-virt 2>/dev/null')
rc2, kver, _ = sh('uname -r')
rc3, modf, _ = sh(
"ls /lib/modules/%s/kernel/drivers/net/wireguard/ 2>/dev/null" % kver)
rc4, loaded0, _ = sh('test -d /sys/module/wireguard && echo yes || echo no')
rc5, tool0, _ = sh('command -v wg >/dev/null && echo yes || echo no')
print(' 虚拟化形态 : %s' % (virt or '(检测不到)'))
print(' 内核 : %s' % kver)
print(' 模块文件 : %s' % (modf.replace('\n', ', ') or '(无)'))
print(' 模块已加载 : %s' % loaded0)
print(' wg 工具 : %s' % tool0)
# 这一条是本轮缺陷的「Ground Truth」:模块文件在,但没加载。
# 只要这个前提成立,正确的 state 就必须是 need_module,
# 而不是旧代码给出的 unsupported(内核不支持)。
truth = ('modfile' if modf else '') + '/' + \
('loaded' if loaded0 == 'yes' else 'unloaded') + '/' + \
('tool' if tool0 == 'yes' else 'notool')
expect_state = ('ready' if (loaded0 == 'yes' and tool0 == 'yes')
else 'need_tool' if loaded0 == 'yes'
else 'need_module' if modf else 'unsupported')
print(' 期望 state : %s (由实况推导:%s)' % (expect_state, truth))
# ⚠️ 这条不是断言「现在必须处于未加载」,而是**记录**它。
# 第一版写成 chk(...),结果 fix 一跑完模块就加载上了,
# 脚本第二次运行必然报红 —— 而那恰恰说明修复生效了。
# 判据的方向搞反了:复现前提只对「缺陷还在的机器」成立,
# 修好之后它必然不再成立。所以这里只提示,不判红。
reproduced = bool(modf) and loaded0 != 'yes'
print(' 缺陷复现 : %s'
% ('是(本机正处于「模块文件在但未加载」)' if reproduced
else '否(模块已加载 / 无模块文件)—— 修复已生效或前提不成立,'
'下面的断言按实况推导,不要求必须复现'))
# state 的期望值必须由**实况**推导,绝不写死。
# 写死成 need_module 的话,模块一加载就假红。
# ---------- 1. 登录 ----------
print('\n--- 1. 登录拿 token ---')
st, r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
ck('登录成功', st == 200 and r.get('ok') is True,
'→ HTTP %s %s' % (st, str(r)[:150]))
tok = r.get('token') or (r.get('data') or {}).get('token')
ck('拿到 token', bool(tok))
if not tok:
print('\n无法继续(没有 token)')
return 1
# ---------- 2. status 的 env 字段(只读,安全)----------
print('\n--- 2. status 返回四态 env(只读)---')
st, r = call('/api/vpn', {'op': 'status'}, tok)
ck('status 接口通', st == 200 and r.get('ok') is True, '→ %s' % str(r)[:200])
d = r.get('data') or {}
env = d.get('env') or {}
ck('响应里有 env 字段(旧版本只有 installed 字符串)', bool(env),
'→ data 顶层键:%s' % sorted(d.keys()))
for k in ('state', 'mod_loaded', 'mod_file', 'tool', 'kernel',
'virt', 'autoload', 'fixable'):
ck('env.%s 存在' % k, k in env, '→ env=%s' % env)
ck('env.kernel 与 uname -r 一致',
env.get('kernel') == kver, '→ %s vs %s' % (env.get('kernel'), kver))
ck('env.virt 与 systemd-detect-virt 一致(%s)' % virt,
env.get('virt') == virt, '→ %s' % env.get('virt'))
ck('env.mod_loaded 与 /sys/module 一致',
bool(env.get('mod_loaded')) == (loaded0 == 'yes'))
ck('env.tool 与 wg 命令是否存在一致',
bool(env.get('tool')) == (tool0 == 'yes'))
ck('env.mod_file 指到了真实存在的模块文件',
bool(env.get('mod_file')) == bool(modf)
and (not env.get('mod_file') or os.path.isfile(env['mod_file'])),
'→ %s' % env.get('mod_file'))
# ★ 核心断言:state 必须与真机实况一致。
# 期望值由实况推导(expect_state),不是写死的 need_module ——
# 写死的话,fix 跑完模块加载了,这条就变成假红。
ck('state 判定与真机实况一致(%s)' % truth,
env.get('state') == expect_state,
'→ 实得 %s,期望 %s' % (env.get('state'), expect_state))
ck('state 绝不再是 unsupported(本机内核明明有模块文件)',
env.get('state') != 'unsupported',
'→ 若为 unsupported,说明还在用只查已加载态的老判据'
'(模块文件明明在 /lib/modules 下)')
ck('fixable 为真(有自愈余地就不该让用户去 SSH)',
bool(env.get('fixable')) is (expect_state in
('need_module', 'need_tool')))
ck('installed 兼容字段仍在(老前端不至于空白)',
d.get('installed') == env.get('state'))
# ---------- 3. apply 在缺工具时的报错必须可执行 ----------
print('\n--- 3. apply 早退分支的文案(不真正启动服务)---')
# 这里传 enabled=False:apply 会走「渲染配置但不起服务」那条路。
# 唯一会被验证的是**早退**—— 真机当前是 need_module,
# apply 会先 modprobe 自愈,所以这里预期是「修好了、继续」。
st, r = call('/api/vpn', {'op': 'apply', 'conf': {'enabled': False}},
tok)
msg = str(r.get('msg_cn') or '')
print(' apply 返回:ok=%s code=%s msg=%s'
% (r.get('ok'), r.get('code'), msg[:110]))
# ⚠️ 判据不能是「code 不是 NO_WG」—— need_tool 与 unsupported
# **共用** NO_WG 这个 code(对外只该有一个「环境不可用」的口径)。
# 我第一版那么写,结果本机装完工具后 apply 仍返回 NO_WG,
# 报出一个不存在的回归。真正的判据是**文案是否可执行**:
# 旧文案 = 断言一个猜出来的原因,且没有出路
# 新文案 = 指出真实缺什么 + 给出下一步
# ⚠️ 下面这三条必须**只在 apply 真被拒时**才跑。
# 我第一版把它们写在 if/else 外面,于是环境修好之后 apply 成功,
# 三条却拿成功文案去判「有没有给出路」,全红 ——
# 典型的「判据比实现宽」:它假设了 apply 一定失败。
if expect_state == 'ready':
# 环境本来就是好的 → apply 不该被环境拦下来。
# enabled=False 意味着只渲染配置、不起服务,是安全的。
ck('环境就绪时 apply 不被环境拦下(真正走通到配置写入)',
r.get('ok') is True, '→ ok=%s msg=%s' % (r.get('ok'), msg[:140]))
ck('apply 走通后回传了 conf(前端要能刷新表单)',
bool((r.get('data') or {}).get('conf')))
else:
ck('apply 失败时回传 env(前端要靠它刷新诊断)',
bool((r.get('data') or {}).get('env')),
'→ data 键:%s' % sorted((r.get('data') or {}).keys()))
ck('apply 被拒时给的是**具体**原因而不是一句「内核不支持」',
('wireguard-tools' in msg or ('内核' in msg and '没有' in msg)),
'→ %s' % msg[:160])
ck('apply 被拒时指向「一键修复」或说明模块缺失(给出出路)',
('一键修复' in msg) or ('内核' in msg and '模块' in msg),
'→ %s' % msg[:160])
ck('apply 被拒时**不出现**旧那句一刀切文案',
'内核或工具不支持' not in msg,
'→ %s' % msg[:160])
if expect_state == 'need_tool':
ck('缺工具时报的正是 need_tool 这一档(不是 unsupported)',
r.get('code') == 'NO_WG' and 'wireguard-tools' in msg,
'→ code=%s msg=%s' % (r.get('code'), msg[:120]))
# ---------- 4. modprobe 真的生效了吗 ----------
print('\n--- 4. 自愈后模块是否真的加载了 ---')
rc6, loaded1, _ = sh('test -d /sys/module/wireguard && echo yes || echo no')
print(' /sys/module/wireguard: %s' % loaded1)
if modf and loaded0 != 'yes':
ck('modprobe 之后模块已加载(原缺陷场景已自愈)',
loaded1 == 'yes',
'→ 还没加载,说明 _vpn_ensure_module 没能修复')
rc7, lsmod, _ = sh("grep -c '^wireguard ' /proc/modules")
ck('/proc/modules 里能查到 wireguard', rc7 == 0 and lsmod != '0',
'→ %s' % lsmod)
else:
print(' (本机原本就已加载,跳过本节的自愈验证)')
# ---------- 5. 一键修复 op ----------
print('\n--- 5. fix op(幂等 + 开机自启)---')
st, r = call('/api/vpn', {'op': 'fix'}, tok, timeout=900)
ck('fix op 被派发(不是「未知的 VPN 操作」)',
r.get('code') != 'ERR' or '未知的 VPN 操作' not in str(r.get('msg_cn')),
'→ code=%s msg=%s' % (r.get('code'), str(r.get('msg_cn'))[:120]))
print(' fix 返回:ok=%s msg=%s' % (r.get('ok'), str(r.get('msg_cn'))[:150]))
for n in ((r.get('data') or {}).get('notes') or []):
print(' · %s' % n)
ck('fix 返回 env 供前端刷新', bool((r.get('data') or {}).get('env')))
rc8, al, _ = sh('cat %s 2>/dev/null' % AUTOLOAD)
ck('%s 已写出' % AUTOLOAD, rc8 == 0 and 'wireguard' in al,
'→ rc=%s 内容=%r' % (rc8, al[:120]))
ck('autoload 文件里确有 wireguard 这一行',
any(ln.strip() == 'wireguard' for ln in al.splitlines()),
'→ %r' % al)
st, r = call('/api/vpn', {'op': 'status'}, tok)
env2 = (r.get('data') or {}).get('env') or {}
ck('fix 后 autoload=true', env2.get('autoload') is True,
'→ %s' % env2)
ck('fix 之后 state 至少不再是 unsupported',
env2.get('state') != 'unsupported', '→ %s' % env2.get('state'))
# 只有 wg 也装上了才会到 ready;只 modprobe 没装工具时是 need_tool,
# 这也算「诊断正确」—— 关键是不能再误报成 unsupported。
ck('fix 后 state 与实况一致(ready 或 need_tool 都算对)',
env2.get('state') in ('ready', 'need_tool'),
'→ %s(tool=%s)' % (env2.get('state'), env2.get('tool')))
# 幂等:再点一次不应报错,也不该重复装包
st, r = call('/api/vpn', {'op': 'fix'}, tok, timeout=900)
ck('fix 幂等(再点一次不报错)', r.get('ok') is True,
'→ ok=%s msg=%s' % (r.get('ok'), str(r.get('msg_cn'))[:150]))
# ---------- 5b. 修好之后 apply 必须真的走得通 ----------
# 这才是本次修复的**最终目的**:不是「诊断说得对」,
# 而是「用户点完一键修复,VPN 真的能配能起」。
# 用 enabled=False:不启动服务、不建接口、不碰防火墙,
# 只验证「渲染配置 + 写盘 + 落nft 规则」这一段能走完。
print('\n--- 5b. 修好之后 apply 端到端(enabled=False,不起服务)---')
st, r = call('/api/vpn', {'op': 'apply', 'conf': {'enabled': False}},
tok, timeout=300)
m2 = str(r.get('msg_cn') or '')
print(' apply 返回:ok=%s code=%s msg=%s'
% (r.get('ok'), r.get('code'), m2[:140]))
ck('修复后 apply 不再被环境拦下(这是本轮修复的最终目的)',
r.get('ok') is True, '→ code=%s msg=%s' % (r.get('code'), m2[:160]))
ck('apply 回传 conf(前端表单能刷新)',
bool((r.get('data') or {}).get('conf')))
ck('apply 回传 nft 规则文本(防火墙放行段走到了)',
bool((r.get('data') or {}).get('nft')))
rc_cfg, cfg, _ = sh('cat /etc/wireguard/wg0.conf 2>/dev/null')
ck('配置文件真的写出来了', rc_cfg == 0 and 'PrivateKey' in cfg,
'→ rc=%s 字节=%s' % (rc_cfg, len(cfg)))
# ⚠️ 这里用单 % 不是%%:sh() 是 shell=True,%% 会原样传给 stat
# (stat -c%%a 会把权限打印成「755%%」之类),实测过。
_perm = sh('stat -c%a /etc/wireguard/wg0.conf 2>/dev/null')[1].strip()
ck('配置文件权限是 600(含私钥)', _perm == '600', '→ %s' % _perm)
ck('enabled=False 时不启动服务(applied=False)',
(r.get('data') or {}).get('applied') is False)
# 收掉这份配置,别在用户机器上留一个他没要求过的 wg0.conf
st, r2 = call('/api/vpn', {'op': 'stop'}, tok)
rc_rm, _o, _e = sh('rm -f /etc/wireguard/wg0.conf')
rc_rmt, _o2, _e2 = sh(
'nft list table inet drouter_vpn >/dev/null 2>&1 '
'&& nft delete table inet drouter_vpn; echo done')
print(' 已清理:wg0.conf 与 drouter_vpn 表(验收不该留痕)')
# ---------- 6. 基线未被破坏 ----------
print('\n--- 6. 基线复核(不能动网络)---')
rc9, gw, _ = sh("ip route show default | awk '{print $3}' | head -1")
ck('默认网关仍是 192.168.7.2', gw == '192.168.7.2', '→ %s' % gw)
# ⚠️ 判据不能是「整台机器 nft 0 行」——
# 这台机上有一张 `table inet netavark`,是**之前**跑 podman 容器时
# 留下的历史残留(空隔离链 + 只匹配 meta mark 0x2000 的 masquerade),
# 记忆里早有记录,与本次改动无关。我第一版把判据写成
# `nft list ruleset | wc -l == 0`,结果报出一个不存在的回归。
# 正确判据:**drouter 自己的表一张都没有**。
# (这台机尚未接管路由,防火墙表应该是空的;
# 一旦哪天真起防火墙,判据要改成比对 drouter 表的 ruleset 摘要。)
rc10, dr_tables, _ = sh(
"nft list tables 2>/dev/null | grep -c 'drouter' || true")
ck('drouter 自己的 nft 表为 0 张(本脚本不碰防火墙)',
(dr_tables or '0') == '0',
'→ drouter 表 %s 张;完整列表:\n%s'
% (dr_tables, sh('nft list tables 2>/dev/null')[1]))
rc10b, nft_all, _ = sh('nft list ruleset 2>/dev/null | wc -l')
print(' (nft 规则集共 %s 行 —— 其中 netavark 表是podman 的历史残留,'
'非本次改动)' % nft_all)
rc11, fwd, _ = sh("cat /proc/sys/net/ipv4/ip_forward")
ck('ip_forward 仍为 1', fwd == '1', '→ %s' % fwd)
rc12, wg_live, _ = sh('ip link show type wireguard 2>/dev/null | wc -l')
print(' (当前 wireguard 接口数:%s —— 0 表示本脚本没有起任何接口)'
% wg_live)
ck('本脚本没有偷偷起 wireguard 接口', wg_live == '0', '→ %s' % wg_live)
rc13, dmask, _ = sh("systemctl is-active dnsmasq; systemctl is-enabled dnsmasq")
ck('dnsmasq 仍 inactive+disabled(绝不能被这个脚本拉起来)',
dmask.replace('\n', ' ') == 'inactive disabled',
'→ %s' % dmask.replace('\n', ' '))
# ---------- 7. 收尾:把 wireguard 接口关掉(若起了)----------
# enabled=False 的 apply 不会起接口,但万一前一步有残留,
# 这里显式 stop 一次,保证机器回到验证前的状态。
st, r = call('/api/vpn', {'op': 'stop'}, tok)
print(' stop 返回:ok=%s' % r.get('ok'))
rc14, wg_live2, _ = sh('ip link show type wireguard 2>/dev/null | wc -l')
ck('收尾后没有残留 wireguard 接口', wg_live2 == '0', '→ %s' % wg_live2)
print('\n' + '=' * 68)
print('通过 %d 项,失败 %d 项' % (oks, len(fails)))
if fails:
for f in fails:
print(' · %s' % f)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+7
View File
@@ -25,6 +25,13 @@ EXCL = {
't-live', 't-smoke', 't-ulog-perf', 't-ulog-detail', 't-perf',
't-print', 't-cachebust', 't-cleanup', 't-webshell', 't-pty',
't-daemon-restart',
# ⚠️ 反向验证脚本**会真的改源文件**(注入 bug → 跑测试 → 还原)。
# 名字前缀也是 t-,会被 glob 自动收进来;一旦被自动执行,
# 它就会在回归过程中反复改写 backend/ 与 web/ —— 后果是
# 别的检查器读到被注入污染的代码,报出一堆假红,
# 更糟的是中途被打断会留下污染(已经发生过一次)。
# 只能手动跑:python _dev/t-107-inject-wg.py --batch 0
't-107-inject-wg',
}
@@ -1,19 +1,22 @@
#!/bin/bash
# 容器成品镜像冒烟(第二段):登录 + Web 终端建会话 + 接口抽查
# 1.0.7 本轮新增备份 / 告警 / 配额三个守护,容器形态同样要验:
# 1.0.7 新增备份 / 告警 / 配额三个守护,容器形态同样要验:
# ① 容器里没有 systemd,三个新守护仍能被拉起(_shelld_spawn 同款回退路径)
# ② /dev/ptmx 存在,PTY 能开(信号量上限后新建会话要回 BUSY 而不是挂死)
# ③ 自签证书的 SAN 取自本机地址,不得再出现作者写死的内网 IP
# (1.0.4 修复;过去直接硬编码 192.168.7.3,任何机器上签出来都带着它)
# ④ sync-isp-dns.sh(1.0.6 新增,PPPoE 上游 DNS 同步)必须在镜像里
# ⑤ rescue token / SNMP 净化(1.0.6 修)都在 backend,要能被 import
# ⑥ 本轮修的 7 个缺陷:备份敏感排除、VPN 私钥不外泄、ip_network 3.13 判定
# ⑥ 1.0.7 修的 7 个缺陷:备份敏感排除、VPN 私钥不外泄、ip_network 3.13 判定
# ⑦ 1.0.8 新增:VPN 四态诊断(_vpn_env)+ /etc/modules-load.d/ 开机自启,
# 容器里 wireguard 模块多半加载不了 → 这正好是「unsupported」那一档,
# 验的是**别误报成别的态**、以及 fix 按钮该消失时消失
# 用 --network none:不碰宿主网络,只在容器内自测 127.0.0.1。
set -u
TAR=/tmp/drouter-107-final.tar
NAME=drouter-smoke107
IMG=drouter:1.0.7
TAR=/tmp/drouter-108-final.tar
NAME=drouter-smoke108
IMG=drouter:1.0.8
BASE=https://127.0.0.1:8443
PODMAN="podman"
@@ -204,7 +207,7 @@ ckc "sync-isp-dns.sh 写盘失败非零退出(不能静默 rc=0)" 2 \
"$($PODMAN exec "$NAME" grep -c 'exit 1' /opt/drouter/scripts/sync-isp-dns.sh 2>&1)"
echo
echo "=== 12. 1.0.7 三个新守护在镜像里且语法正确 ==="
echo "=== 12. 1.0.7 新增的三个守护在镜像里且语法正确 ==="
# 容器形态没有 systemd,守护不能只靠 systemctl。这里只验「文件在 + 能编译 +
# 有不依赖 init 的拉起路径」,真跑起来要等 sync.sh 那轮真机验证。
for d in backupd alertd quotad; do
@@ -245,6 +248,75 @@ ck "DEPS 登记 wireguard-tools" "wireguard-tools" \
ck "镜像 Python 是 3.13(3.12 上这条断言无意义)" 'Python 3\.13' \
"$($PODMAN exec "$NAME" python3 -V 2>&1)"
echo
echo "=== 14. 1.0.8 VPN 四态诊断在容器形态下的表现 ==="
# 容器里wireguard 模块多半加载不了(内核没暴露模块或 /lib/modules 缺失)。
# 关键不是「能不能装上」,而是**别判错态** ——
# 1.0.7 之前只分「有/无」,本轮才拆成四态并给出可执行的下一步。
H="/opt/drouter/backend/drouter-helper.py"
ck "_vpn_env 四态函数在镜像里" 'def _vpn_env' \
"$($PODMAN exec "$NAME" grep -c 'def _vpn_env' $H 2>&1 | head -1)"
# ⛔ 判据不能只 grep 字面量:注释/docstring 里也会写这些词。
# 所以下面真把函数跑起来,看它在**本机**返回哪一态。
# 探针文件位置:可用环境变量覆盖。写死本机路径的话,换台机器跑就会
# 直接判「探针不存在」—— 而真实原因只是路径不对,误报成产品问题。
PROBE="${PROBE:-/tmp/vpn-env-probe.py}"
if [ ! -f "$PROBE" ]; then
echo " ✘ 探针脚本不存在:$PROBE"
fail=$((fail+1))
else
$PODMAN cp "$PROBE" "$NAME:/tmp/vpn-env-probe.py" >/dev/null 2>&1
VENV_OUT="$($PODMAN exec "$NAME" python3 /tmp/vpn-env-probe.py 2>&1)"
if grep -q 'IMPORTERR' <<<"$VENV_OUT"; then
echo " ✘ _vpn_env 能在镜像里 import 并运行"
echo "$VENV_OUT" | head -5
fail=$((fail+1))
else
echo " ✔ _vpn_env 能在镜像里 import 并运行"
pass=$((pass+1))
for k in STATE VIRT FIXABLE AUTOLOAD KERNEL; do
v="$(grep "^$k=" <<<"$VENV_OUT" | head -1 | cut -d= -f2-)"
echo " $k = ${v:-(空)}"
done
st="$(grep '^STATE=' <<<"$VENV_OUT" | cut -d= -f2-)"
# 判据不能写死某一态:不同内核/容器能力下合法结果不同。
# 真正要钉死的是「返回值落在四态之内」—— 判错态才是 1.0.7 那个 bug。
if grep -qE '^(ready|need_module|need_tool|unsupported)$' <<<"$st"; then
echo " ✔ state 落在四态之内($st)"
pass=$((pass+1))
else
echo " ✘ state 必须是四态之一,实际「${st}」"
fail=$((fail+1))
fi
# unsupported 时不得声称「可一键修复」—— 那会把用户引向一条必然失败的操作
if [ "$st" = "unsupported" ]; then
if grep -q '^FIXABLE=False' <<<"$VENV_OUT"; then
echo " ✔ unsupported 时 fixable=False"
pass=$((pass+1))
else
echo " ✘ unsupported 时 fixable 必须为 False"
fail=$((fail+1))
fi
fi
# 模块文件那一级是本轮新增的探测;查不到也要正常返回而不是抛异常
if grep -qE '^KERNEL=\S' <<<"$VENV_OUT"; then
echo " ✔ kernel 版本取到了(四态诊断没因为 /lib/modules 缺失而崩)"
pass=$((pass+1))
else
echo " ✘ kernel 版本应能取到"
fail=$((fail+1))
fi
fi
fi
# 开机自启文件由 fix 写,镜像里不该预先存在
ckc "镜像里不预置 modules-load.d 配置" 0 \
"$($PODMAN exec "$NAME" ls /etc/modules-load.d/drouter-wireguard.conf 2>&1 >/dev/null; echo $?)"
# 前端:老后端(没有 env 字段)必须显示 unknown 而不是 unsupported
ck "前端老后端兜底是 unknown" "state: 'unknown'" \
"$($PODMAN exec "$NAME" grep -c "state: 'unknown'" /opt/drouter/web/app.js 2>&1 | head -1)"
ck "前端有 VPN 一键修复按钮" 'id="vp-fix"' \
"$($PODMAN exec "$NAME" grep -c 'id="vp-fix"' /opt/drouter/web/app.js 2>&1 | head -1)"
echo
echo "================================================"
echo " 容器冒烟:通过 $pass / 失败 $fail"
+229
View File
@@ -0,0 +1,229 @@
"""反向验证:逐个注入 bug,确认 t-107 的 C3c 新断言真的会变红。
⚠️ 为什么必须做:静态断言最危险的失败模式是「正则没匹配上��
于是恒绿」。之前踩过两次(heredoc 锚点没匹配上却报「反向验证通过」)。
所以这里每一条都必须**看到 NG 才算验证通过**,而且注入用的是
Write 落盘的真实脚本,不用 heredoc(heredoc 里的 old 串
因为转义问题静默不匹配过一次)。
"""
import io
import os
import re
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = sys.executable
HELPER = os.path.join(ROOT, 'backend', 'drouter-helper.py')
APP = os.path.join(ROOT, 'web', 'app.js')
T = os.path.join(ROOT, '_dev', 't-107.py')
H0 = io.open(HELPER, encoding='utf-8').read()
A0 = io.open(APP, encoding='utf-8').read()
# (标签, 目标文件, 要替换的原文, 替换成什么, 期望变红的断言片段)
CASES = [
('E1 _vpn_env 去掉 /lib/modules 探测(还原本轮误报根因)',
HELPER,
" _p = os.path.join('/lib/modules', kver,\n"
" 'kernel/drivers/net/wireguard', _n)",
" _p = os.path.join('/nonexistent', kver, _n)",
['查的是 /lib/modules 下的模块文件']),
('E2 四态判定顺序打乱(need_module 永远出不来)',
HELPER,
" if mod_loaded and tool:\n state = 'ready'\n"
" elif mod_loaded:\n state = 'need_tool'\n"
" elif mod_file:\n state = 'need_module'\n"
" else:\n state = 'unsupported'",
" if mod_loaded:\n state = 'ready'\n"
" elif tool:\n state = 'need_tool'\n"
" else:\n state = 'unsupported'",
['四态判定顺序正确']),
('E3 只认 .ko 不认 .ko.xz(Debian 默认是 xz,会全判 unsupported)',
HELPER,
" for _n in ('wireguard.ko', 'wireguard.ko.xz', 'wireguard.ko.zst'):",
" for _n in ('wireguard.ko',):",
['覆盖 .ko/.ko.xz/.ko.zst']),
('E4 autoload 漏写模块名(文件写了但没写 wireguard 这一行)',
HELPER,
" 'wireguard\\n')",
" '# nothing\\n')",
['真的往 WG_AUTOLOAD 写 wireguard 这一行']),
('E5 modprobe 只看 rc,不复核 /sys/module',
HELPER,
" if rc != 0 or not os.path.isdir('/sys/module/wireguard'):\n"
" return False, 'modprobe wireguard 失败:%s' % (\n"
" e or o or ('返回码 %d' % rc))",
" if rc != 0:\n return False, 'modprobe failed'",
['以 /sys/module 复核']),
('E6 apply 不自愈,直接报错(还原原缺陷)',
HELPER,
" if env['state'] == 'need_module':\n"
" okm, why = _vpn_ensure_module()",
" if False:\n okm, why = _vpn_ensure_module()",
['遇 need_module 先 modprobe 自愈']),
('E7 apply 自愈后不重读 env(拿旧状态往下走)',
HELPER,
" env = _vpn_env()\n if env['state'] != 'ready':",
" pass\n if env['state'] != 'ready':",
['自愈后重新读一次 env']),
('E8 status 不回传 env(前端拿不到四态)',
HELPER,
" 'env': _vpn_env(),",
" # 'env': _vpn_env(),",
['_vpn_status 返回 env']),
('E9 act_vpn 不派发 fix op',
HELPER,
" if op == 'fix':\n return _vpn_fix_op()",
" if op == 'fix_xxx':\n return _vpn_fix_op()",
['act_vpn 派发 fix op']),
('E10 fix 不幂等(已 ready 也重装一遍)',
HELPER,
" if before['state'] == 'ready':",
" if before['state'] == 'never':",
['_vpn_fix_op 是幂等的']),
('E11 前端文案退回一刀切「内核不支持 + 精简容器」',
APP,
" if (env0.state === 'unsupported') {\n"
" env = '<div class=\"notice err\">本机内核 <span class=\"mono\">'\n"
" + esc(env0.kernel || '') + '</span> 里没有 wireguard 模块,无法启动服务。'\n"
" + '<span class=\"mono\">精简容器镜像和自编内核会裁掉它;'\n"
" + 'Debian 官方内核与 PVE / KVM 虚拟机都自带。</span></div>';",
" if (env0.state === 'unsupported') {\n"
" env = '<div class=\"notice err\">本机内核或工具不支持 WireGuard,'\n"
" + '无法启动服务。<span class=\"mono\">出现这个提示通常说明跑在'\n"
" + '精简容器里。</span></div>';",
['不再断言「精简容器」', '一刀切说法', '按四态分支']),
('E12 前端删掉「一键修复」按钮(只剩 need_tool 一档有)',
APP,
" + '<div class=\"row\" style=\"margin-top:8px\">'\n"
" + '<button class=\"primary\" id=\"vp-fix\">一键修复(加载模块并配置开机自启)</button>'\n"
" + '</div>';",
" + '<div class=\"row\" style=\"margin-top:8px\"></div>';",
['每一档都给出可执行动作']),
('E13 vpnFix 不调 fix op',
APP,
" const r = await api('/api/vpn', { method: 'POST', body: { op: 'fix' } });",
" const r = await api('/api/vpn', { method: 'POST', body: { op: 'noop' } });",
['vpnFix 调的是 fix op']),
('E14 vpnFix 去掉二次确认(会偷偷 apt-get install)',
APP,
" if (!confirm('一键修复会做这几件事:\\n\\n'",
" if (false && confirm('一键修复会做这几件事:\\n\\n'",
['装包前有二次确认']),
('E15 前端把老后端 no 直接当 unsupported(误报复活)',
APP,
" const env0 = d.env || { state: 'unknown', kernel: '', virt: '',\n"
" autoload: false };",
" const env0 = d.env || { state: d.installed === 'no'\n"
" ? 'unsupported' : 'ready', kernel: '', virt: '', autoload: false };",
['老后端兜底是 unknown']),
('E16 重新注入本轮踩到的死分支(残留 else if)',
APP,
" } else if (!d.has_systemd) {\n"
" env = '<div class=\"notice warn\">当前环境没有 systemd(容器形态)。'",
" } else if (!d.has_systemd) {\n"
" env = '<div class=\"notice err\">残留的旧分支</div>';\n"
" } else if (!d.has_systemd) {\n"
" env = '<div class=\"notice warn\">当前环境没有 systemd(容器形态)。'",
['残留的重复 else if 死分支']),
]
def run_t107():
p = subprocess.run([PY, T], capture_output=True, text=True,
encoding='utf-8', errors='replace', cwd=ROOT)
return p.stdout + p.stderr
def failed_items(out):
"""取「失败项:」小节里的条目。"""
tail = out.split('失败项:')[-1] if '失败项:' in out else ''
return [ln.strip().lstrip('·').strip()
for ln in tail.splitlines() if ln.strip().startswith('·')]
base_out = run_t107()
base_fail = failed_items(base_out)
if base_fail:
# ⚠️ 这里曾踩过:分批跑时一批被 SIGTERM 打断,finally 没执行,
# 注入留在文件里,下一批的「基线全绿」检查就报看不懂的红。
# 所以基线不干净时**必须停下人工确认**,绝不能继续注入 ——
# 在污染状态上跑,验证结果毫无意义。
print('基线不干净,先修基线:%s' % base_fail)
print('(若这是上一批被中断残留的注入,手动还原后重跑)')
sys.exit(1)
print('基线全绿,开始反向验证(每条都必须看到 NG)')
print('=' * 68)
# 长会话里一口气跑 16 次完整 t-107 会被 SIGTERM(无输出直接死),
# 所以支持按批跑:--batch 2 表示只跑第 2 批(每批 8 个)。
_only = None
if '--batch' in sys.argv:
_only = int(sys.argv[sys.argv.index('--batch') + 1])
if '--only' in sys.argv:
# 只跑指定编号(调试单条注入用)
_pick = [int(x) for x in sys.argv[sys.argv.index('--only') + 1].split(',')]
CASES = [CASES[i - 1] for i in _pick]
_only = None
_PB = 8
batch = CASES[_only * _PB:(_only + 1) * _PB] if _only is not None else CASES
if _only is not None:
print('(第 %d 批,共 %d 个注入)' % (_only, len(batch)))
bad = []
for label, path, old, new, expect in batch:
orig = H0 if path == HELPER else A0
if old not in orig:
bad.append('%s:锚点没匹配上(等于没注入)' % label)
print('[SKIP-BAD] %s —— 锚点未命中,注入无效' % label)
continue
io.open(path, 'w', encoding='utf-8').write(orig.replace(old, new, 1))
try:
out = run_t107()
fails = failed_items(out)
hit = [e for e in expect
if any(e in f for f in fails)]
if hit:
print('[OK] %s' % label)
print(' 变红:%s' % ';'.join(hit))
else:
bad.append('%s:注入后仍全绿(断言恒绿)' % label)
print('[NG-VERIFY] %s' % label)
print(' 期望红:%s' % ';'.join(expect))
print(' 实际失败项:%s' % (fails or '无'))
finally:
io.open(path, 'w', encoding='utf-8').write(orig)
print('=' * 68)
# 还原后必须回到全绿
after = run_t107()
after_fail = failed_items(after)
if after_fail:
bad.append('还原后仍红:%s' % after_fail)
print('[NG-VERIFY] 还原后仍失败:%s' % after_fail)
else:
print('[OK] 全部还原,t-107 回到全绿')
if bad:
print()
print('反向验证失败 %d 项:' % len(bad))
for b in bad:
print(' · %s' % b)
sys.exit(1)
print('反向验证全部通过:%d 个注入全部被断言抓住' % len(batch))
+253 -1
View File
@@ -157,6 +157,86 @@ def code_only(src):
return '\n'.join(out)
def strip_docstrings(src):
"""去掉 Python 的 docstring(模块级、类级、函数级都去)。
⚠️ 为什么必须有这一步:修 bug 时我们会在 docstring 里**引用
原来的错误文案**来说明「为什么当初错了」,比如
`_vpn_env()` 的注释里就写着「通常说明跑在精简容器里」这句
被我们删掉的旧文案。断言若直接对源码做子串匹配,会把这段
说明当成「代码里还有错文案」而报假红灯 —— 最坏的结果是
为了让测试变绿,把解释 bug 来历的注释也删掉。
同理 `code_only()` 也不能替代它:code_only 逐行找行尾' #'
来砍行尾注释,遇到 docstring 里以两个空格 + # 开头的行会把
字符串截断,喂给 ast.parse 直接报 unterminated string。
所以两个是正交的:先剥 docstring,再谈其他。
⚠️⚠️ 实现踩过两个坑,都写在这儿免得下次再踩:
① **别用「前一个 token 是 INDENT/NL/NEWLINE」判断是不是 docstring**。
我第一版就是这么写的,结果把 return {...} 里的'virt': virt
这类**普通键值**也剥了(紧跟 INDENT 的字符串在词法上完全合法)
—— 一夜之间 6 条断言变红,全是假红。ast 懂语义,tokenize 不懂。
② **别用 tokenize.untokenize() 重排**。被剥掉的 docstring 位置
会被填成 '\n' 续行,把行结构搞乱:原来那几条
`if ...:\n state = 'ready'` 的跨行正则全部失配,
而它们恰恰是钉死「四态判定顺序」的核心判据。
所以:**先剥,再按行删**,不重排、不填充。
"""
import ast as _ast
try:
tree = _ast.parse(src)
except SyntaxError:
return src
doc_pos = set()
def _add(node):
if (node is not None and isinstance(node, _ast.Expr)
and isinstance(node.value, _ast.Constant)
and isinstance(node.value.value, str)):
doc_pos.add(node.value.lineno)
_add(tree)
for n in _ast.walk(tree):
if isinstance(n, (_ast.FunctionDef, _ast.AsyncFunctionDef,
_ast.ClassDef, _ast.Module)):
if n.body:
_add(n.body[0])
if not doc_pos:
return src
# ast 给出的是 docstring **第一行**;多行 docstring 要删到结束行。
# 直接用 ast 节点的 end_lineno 定位,跨行也对得上。
spans = set()
for n in _ast.walk(tree):
if isinstance(n, _ast.Expr) and isinstance(n.value, _ast.Constant) \
and isinstance(n.value.value, str) and n.lineno in doc_pos:
spans.add((n.lineno, getattr(n, 'end_lineno', n.lineno)))
if not spans:
return src
out = []
for i, ln in enumerate(src.split('\n'), start=1):
if any(a <= i <= b for a, b in spans):
continue
out.append(ln)
return '\n'.join(out)
def py_code_only(src):
"""源码去docstring + 去注释,只留真正会被执行的代码。"""
return code_only(strip_docstrings(src))
def js_code_only(src):
"""JS 侧去注释(// 与 /* */),只留真正会被执行的代码。
前端改文案时同样会在注释里引用旧文案(「那句是猜的」)。
不剥注释就会把说明当成残留代码。
"""
out = re.sub(r'/\*.*?\*/', '', src, flags=re.S)
# 行注释:// 后面不能紧跟引号(那是 URL 里的 //)
out = re.sub(r'(?<!:)//[^\n\'"`]*$', '', out, flags=re.M)
return out
def py_func_src(name, src=HELPER):
"""用 ast 精确取一个模块级函数的源码段。"""
tree = ast.parse(src)
@@ -761,7 +841,7 @@ chk('_vpn_norm 保留 private_key(否则每次保存都清空所有客户端
mask = py_func_src('_vpn_mask')
chk('_vpn_mask 对外输出时抹掉 private_key',
re.search(r"pop\(\s*'private_key'", mask) is not None)
st = py_func_src('_vpn_status')
st = py_code_only(py_func_src('_vpn_status'))
chk('status 走 _vpn_mask(不直接返回未脱敏的 peers)',
"'conf': _vpn_mask(d)" in st)
# 这一条是本轮修掉的第二个真缺陷:status 里原本还有 'raw': d,
@@ -797,6 +877,178 @@ chk('端口占用判断只在服务未运行时做(否则报自己占的)',
chk('自动换端口后必须明说换成了几',
re.search(r'已自动改用|swapped', py_func_src('_vpn_save_op')) is not None)
# --- C3c. WireGuard 环境诊断:四态,别再把「模块没加载」说成内核不支持 ---
# 2026-10-03 修的第 11 个缺陷。原来的 _vpn_installed() 只做两个二值检查
# (/sys/module/wireguard 在不在、/usr/bin/wg 在不在),从不查
# /lib/modules/ 下的模块文件 —— 于是「模块文件在、只是没加载」被压进
# 同一个 'no',前端再配一句写死的「通常说明跑在精简容器里」。
# 实际受害机是 PVE 里的 KVM 虚拟机(systemd-detect-virt = kvm,
# 是虚拟机不是容器),内核 6.12.107+deb13-amd64 里 wireguard.ko.xz
# 好好地放着,modprobe 一下就RC=0。
print()
print('--- C3c. WireGuard 环境诊断:四态 + 一键修复 + 开机自启 ---')
# ⚠️ 这里全部用 py_code_only(剥 docstring + 注释),不是 py_func_src。
# 这是**反向验证逼出来的**:两条断言恒绿,注入 bug 后仍然全绿 ——
# E1 把探测路径改成 /nonexistent,「查的是 /lib/modules」仍 OK
# → 因为函数 docstring 里就写着「/lib/modules 下的模块文件」
# E8 把 'env': _vpn_env(), 注释掉,「status 返回 env」仍 OK
# → 因为我加的是 `# 'env': _vpn_env(),`,正则照样匹配
# 两次都是同一个病:**判据喂了带注释的原文**。
# 注释/docstring 是给人看的、会被引用旧代码来解释 bug 来历,
# 一旦把它算进判据,断言就既会假绿、又可能假红。
_env = py_code_only(py_func_src('_vpn_env'))
_envc = _env
chk('_vpn_env 查的是 /lib/modules 下的模块文件(这是原误报的根因)',
'/lib/modules' in _env and 'wireguard.ko' in _env,
'少了这一级,「模块在但没加载」永远落进 unsupported')
chk('模块文件名覆盖 .ko/.ko.xz/.ko.zst(Debian 默认是 .ko.xz)',
all(x in _env for x in ('wireguard.ko', 'wireguard.ko.xz', 'wireguard.ko.zst')))
chk('_vpn_env 四态齐全(ready/need_module/need_tool/unsupported)',
all(("'%s'" % s) in _env for s in
('ready', 'need_module', 'need_tool', 'unsupported')))
# ⚠️ 判据不能比实现更宽:光断言字符串出现过没用,必须钉住**判定顺序**。
# 四态的核心是「已加载 + 有工具 → ready;已加载 + 没工具 → need_tool;
# 没加载 + 有模块文件 → need_module;两头都没有 → unsupported」。
# 只查字面的话,把四个 state 全写进一个 tuple 再随机返回也能过。
chk('_vpn_env 的四态判定顺序正确(不是把四态当集合乱返回)',
re.search(r"if\s+mod_loaded\s+and\s+tool:\s*\n\s*state\s*=\s*'ready'",
_env) is not None
and re.search(r"elif\s+mod_loaded:\s*\n\s*state\s*=\s*'need_tool'",
_env) is not None
and re.search(r"elif\s+mod_file:\s*\n\s*state\s*=\s*'need_module'",
_env) is not None
and re.search(r"else:\s*\n\s*state\s*=\s*'unsupported'", _env) is not None,
'四态必须按「已加载?有工具?有模块文件?」逐级降级,'
'否则 need_module 永远出不来(那正是本轮要修的那个状态)')
chk('_vpn_env 把 virt 一起返回(文案要靠它区分 kvm 与容器)',
"'virt'" in _envc and 'systemd-detect-virt' in _envc)
chk('_vpn_env 返回 autoload(前端要据此提示「重启后起不来」)',
"'autoload'" in _envc and 'WG_AUTOLOAD' in _envc)
# 兼容壳不能偷偷把判定改回二值
chk('_vpn_installed 只是兼容壳(真判定在 _vpn_env 里)',
re.search(r"def _vpn_installed\(\):.*?_vpn_env\(\)\['state'\]",
py_code_only(HELPER), re.S) is not None)
chk('helper 里不再有「内核不支持」这个误导文案',
'内核或工具不支持 WireGuard' not in py_code_only(HELPER),
'旧文案断言的是猜测(精简容器),已由四态文案取代')
chk('helper 里不再有写死「精简容器」的猜测',
'通常说明跑在精简容器里' not in py_code_only(HELPER))
# 开机自动加载:Debian 走 udev 按需加载,/etc/modules 默认不写 wireguard,
# 所以每次重启后 /sys/module/wireguard 都不存在 → 本页又报「不可用」。
chk('有 _vpn_write_autoload 写 /etc/modules-load.d(开机自启)',
re.search(r"def _vpn_write_autoload\(\)", HELPER) is not None
and '/etc/modules-load.d/' in py_code_only(HELPER))
chk('WG_AUTOLOAD 常量指向 modules-load.d 下的 drouter 专属文件',
re.search(r"WG_AUTOLOAD\s*=\s*'/etc/modules-load\.d/[\w.-]+'",
py_code_only(HELPER))
is not None)
_al = py_code_only(py_func_src('_vpn_write_autoload'))
chk('_vpn_write_autoload 真的往 WG_AUTOLOAD 写 wireguard 这一行',
re.search(r"open\(WG_AUTOLOAD,\s*'w'", _al) is not None
and re.search(r"'wireguard\\n'", _al) is not None,
'少写模块名 = 开机不加载,等于没写')
# modprobe 之后必须以 /sys/module 复核,不能只看 rc:
# modprobe 偶尔会 RC=0 但模块仍不在(裁剪 / 拦截)。
_em = py_code_only(py_func_src('_vpn_ensure_module'))
chk('_vpn_ensure_module 加载后以 /sys/module 复核(不只看 rc)',
re.search(r"modprobe'?,?\s*'wireguard'", _em) is not None
and _em.count("os.path.isdir('/sys/module/wireguard')") >= 2,
'只看 rc 的话,RC=0 但模块没进 /sys/module 会被当成成功')
chk('_vpn_ensure_module 成功后顺手写开机自启(否则重启又失效)',
'_vpn_write_autoload()' in _em)
# 装包必须挑 apt 源里真实存在的包名,不能硬塞
_it = py_code_only(py_func_src('_vpn_install_tool'))
chk('_vpn_install_tool 先用 _pkgs_available 挑包名再 apt-get install',
'_pkgs_available' in _it
and re.search(r"'apt-get',\s*'install'", _it) is not None)
chk('_vpn_install_tool 装完复核 wg 是否真的可用(不只看 rc)',
_it.count("shutil.which('wg')") >= 2)
# 一键修复 op
chk('act_vpn 派发 fix op',
re.search(r"op == 'fix'", py_code_only(HELPER)) is not None)
_fix = py_code_only(py_func_src('_vpn_fix_op'))
chk('_vpn_fix_op 是幂等的(已 ready 时不重复装)',
re.search(r"if\s+before\['state'\]\s*==\s*'ready'", _fix) is not None)
chk('_vpn_fix_op 分步做并逐步汇报(notes 回传)',
"'notes': notes" in _fix and 'done.append' in _fix)
chk('_vpn_fix_op 对 unsupported 明确说「装不了」而不是硬试',
re.search(r"state'\]\s*==\s*'unsupported'", _fix) is not None)
chk('_vpn_fix_op 修完复核 state 必须为 ready,否则报未完全成功',
re.search(r"after\['state'\]\s*!=\s*'ready'", _fix) is not None)
# apply 必须先自愈再报错 —— 能修的别撵去 SSH
_ap = py_code_only(py_func_src('_vpn_apply_op'))
chk('_vpn_apply_op 遇 need_module 先 modprobe 自愈(不是直接报错)',
re.search(r"state'\]\s*==\s*'need_module'.{0,400}?_vpn_ensure_module\(\)",
_ap, re.S) is not None,
'原来这里只有一句 return fail(... 内核不支持 ...)')
chk('_vpn_apply_op 对 need_tool 说清是「缺工具」并指向一键修复',
'need_tool' in _ap and '一键修复' in _ap)
chk('_vpn_apply_op 只在真 unsupported 时才报 NO_WG',
re.search(r"state'\]\s*==\s*'unsupported'", _ap) is not None
and "'内核或工具不支持 WireGuard'" not in _ap)
chk('_vpn_apply_op 自愈后重新读一次 env(别用旧状态判断)',
re.search(r"_vpn_ensure_module\(\).*?env\s*=\s*_vpn_env\(\).*?"
r"if\s+env\['state'\]\s*!=\s*'ready'",
_ap, re.S) is not None,
'重读之后必须再判一次 ready,否则拿自愈前的旧 state 往下走')
# status 要把 env 透出去
chk('_vpn_status 返回 env(四态诊断透给前端)',
re.search(r"'env':\s*_vpn_env\(\)", st) is not None)
# 前端:三档文案 + 一键修复按钮
# ⚠️ 判据一律走 js_code_only:注释里引用旧文案(「那句是猜的」)
# 是有意留的,不剥掉就会把说明当成残留代码。
_rt = js_code_only(BODIES.get('vpnRenderTop') or func_body('vpnRenderTop'))
chk('VPN 页文案按四态分支(不再只有 no/非no 两档)',
all(x in _rt for x in ('unsupported', 'need_module', 'need_tool')))
chk('VPN 页读的是 d.env 而不是只读 d.installed',
'd.env' in _rt and "d.installed === 'no'" not in _rt)
chk('VPN 页不再断言「精简容器」(那在 KVM 上是错的)',
'精简容器里' not in _rt,
'systemd-detect-virt 返回 kvm(虚拟机),不能猜成精简容器')
chk('VPN 页三档里没有「不支持 WireGuard」这种一刀切说法',
'本机内核或工具不支持 WireGuard' not in _rt)
chk('VPN 页每一档都给出可执行动作(一键修复按钮)',
_rt.count('id="vp-fix"') >= 3,
'need_module / need_tool / ready-但没自启 三档各要一个按钮')
chk('VPN 页提示「模块文件在、未加载」这一真实原因',
'kernel/drivers/net/wireguard' in _rt)
chk('VPN 页展示运行环境(内核版本 / 虚拟化形态 / 开机自启)',
'运行环境' in _rt and 'env0.kernel' in _rt and 'env0.autoload' in _rt)
# ⚠️ 同样要 js_code_only:vpnFix 的 confirm 文案里就写着
# 「3. 如果还没装,apt-get install wireguard-tools」,
# 不剥注释的话「有二次确认」这条判据永远成立(E14 恒绿)。
_ff = js_code_only(BODIES.get('vpnFix') or func_body('vpnFix'))
chk('vpnFix 调的是 fix op', re.search(r"op:\s*'fix'", _ff) is not None)
chk('vpnFix 装包前有二次确认(会联网 apt-get install)',
re.search(r'if\s*\(!confirm\(', _ff) is not None
and 'apt-get install' in _ff)
chk('vpnFix 的确认文案说清了要联网装包(用户有权知道代价)',
'wireguard-tools' in _ff and '联网' in _ff)
chk('vpnFix 失败时把后端 notes 显示出来(别只弹一句 toast)',
'notes' in _ff and 'notice err' in _ff)
chk('vpnRenderTop 给修复按钮绑了 vpnFix',
re.search(r"onclick\s*=\s*vpnFix", _rt) is not None)
chk('VPN 页不再声称「不需要装任何第三方软件」(缺 tools 时就要装)',
'不需要装任何第三方软件' not in js_code_only(APP))
# ⚠️ 死分支防线:本轮真的踩过 —— 我用小锚点替换文案时,只换掉了
# `if (d.installed === 'no') {` 这一行,旧分支的 body 和它的
# `} else if (!d.has_systemd) {` 全留在原地,拼出一个
# 「没有 systemd → 报内核不支持」的假分支。语法完全合法,
# 静态检查也过得去,只有对着文件读才能发现。
# 所以:vpnRenderTop 里 `else if` 的数量必须恰好等于分支数。
chk('vpnRenderTop 没有残留的重复 else if 死分支',
len(re.findall(r'\}\s*else\s+if\s*\(', _rt)) == 5,
'期望 5 个 else if(unsupported/need_module/need_tool/'
'ready-无自启/unknown-老后端/无-systemd),实际 %d 个 —— '
'很可能改文案时留下了旧分支'
% len(re.findall(r'\}\s*else\s+if\s*\(', _rt)))
chk('老后端兜底是 unknown 而非把旧 no 当 unsupported',
"state: 'unknown'" in _rt,
'旧判据分不清「模块没加载」和「内核没模块」,'
'直接映射成 unsupported 就是把本轮修掉的误报又请回来')
# --- C3b. VPN 私钥生命周期:真跑一遍 ---
# 这是整个 4 个模块里最需要真跑的一段。静态断言只能确认
# 「代码里出现了 private_key 这个词」,确认不了「跑一遍之后
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""容器冒烟探针:在镜像里 import drouter-helper 并跑 _vpn_env(),把四态打出来。
为什么需要单独一个文件(而不是在冒烟脚本里内联 heredoc)
--------------------------------------------------------
冒烟脚本经 `$PODMAN exec` 跑,heredoc 要穿过两层引号 + 一层命令替换,
bash 很容易把它吃掉或者吞掉后面的行(写的时候就踩了:留了个残缺的
`$echo "$($PODMAN exec ... <<'PYEOF'` 出来)。拆成文件后 `podman cp`
进去直接跑,失败也只是这一个文件的问题。
为什么不能只 grep 源码里的 'unsupported' 字面量
--------------------------------------------
注释和 docstring 里也会写这些词 —— 1.0.8 那轮判恒绿就是这么来的:
「查的是 /lib/modules 下的模块文件」这句话在 docstring 里,
把探测路径改成 /nonexistent 断言照样全绿。真跑一次函数就没这个问题。
输出是 KEY=VALUE,逐行print,冒烟脚本按行取。
"""
import importlib.util
import sys
HELPER = '/opt/drouter/backend/drouter-helper.py'
def main():
try:
spec = importlib.util.spec_from_file_location('drouter_helper', HELPER)
if spec is None or spec.loader is None:
print('IMPORTERR 无法为 %s 建立 spec' % HELPER)
return 2
m = importlib.util.module_from_spec(spec)
# exec_module 会真的执行模块顶层代码。helper 的顶层只做常量定义
# 与 import,不连网、不起服务(守护都是被显式调用才起来的)。
spec.loader.exec_module(m)
except Exception as ex:
print('IMPORTERR %s: %s' % (type(ex).__name__, ex))
return 2
try:
env = m._vpn_env()
except Exception as ex:
print('CALLERR %s: %s' % (type(ex).__name__, ex))
return 3
for k in ('state', 'virt', 'fixable', 'autoload', 'kernel',
'mod_loaded', 'tool'):
print('%s=%s' % (k.upper(), env.get(k)))
# mod_file 单独打:容器里查不到时是空串,正好验「查不到也不抛异常」
print('MODFILE=%s' % (env.get('mod_file') or '(空)'))
return 0
if __name__ == '__main__':
sys.exit(main())
+237 -11
View File
@@ -3308,6 +3308,7 @@ def _ddns_record_name(cfg):
# 「装了 deb 一切正常、跑镜像 VPN 起不来」。
WG_CONF_DIR = '/etc/wireguard'
WG_AUTOLOAD = '/etc/modules-load.d/drouter-wireguard.conf'
VPN_CONF = '/etc/drouter/generated/vpn.json'
VPN_UID = 51820 # WireGuard 默认用户
VPN_IFACE = 'wg0'
@@ -3448,12 +3449,140 @@ def _wg_genkey():
def _vpn_installed():
"""内核是否支持 WireGuard。Debian 13 自带,但容器基础镜像可能裁掉了模块。"""
rc, o, _e = sh(['sh', '-c',
'test -d /sys/module/wireguard && echo yes || '
'(ls /usr/bin/wg >/dev/null 2>&1 && echo tool || echo no)'],
timeout=6)
return (o or '').strip() or 'no'
"""兼容壳:只取四态里的 state。判定逻辑全在 _vpn_env()。"""
return _vpn_env()['state']
def _vpn_env():
"""WireGuard 可用性诊断(分四态)。
⚠️ 这段判据改过一轮才对。早先只有两个二值检查:
/sys/module/wireguard 在不在 → 模块**已加载**吗
/usr/bin/wg 在不在 → 工具装了吗
两个都没有就报「内核不支持 WireGuard」,前端照着写死一句
「通常说明跑在精简容器里」。2026-10-03 被用户当场问住:
PVE 里的 KVM 虚拟机(systemd-detect-virt 明确返回 kvm,
是虚拟机、不是容器)被说成精简容器。而那台机的内核
6.12.107+deb13-amd64 里 wireguard.ko.xz 好好地躺在
/lib/modules/ 下,只是**没被加载**(/etc/modules 与
/etc/modules-load.d/ 里都没写 wireguard,也没有别的触发点),
`modprobe wireguard` 直接 RC=0 成功。
根因:**从没查过 /lib/modules/ 下的模块文件**,于是
「模块在但没加载」和「内核裁掉了模块」被压进同一个 no,
再配一句写死的「精简容器」,把排查方向整个带偏 ——
用户会去查容器镜像,而真正该做的是 modprobe 一下。
四态(前端文案与一键修复按钮都按这四态分支):
ready 模块已加载 + 工具已装 → 可以直接用
need_module 模块文件在,只是没加载 → modprobe 即可(本地秒级)
need_tool 模块已就绪,只缺 wireguard-tools → 需要下载安装
unsupported 内核里确实没有 wireguard 模块(真精简镜像 / 自编内核)
"""
kver = os.uname().release
mod_loaded = os.path.isdir('/sys/module/wireguard')
tool = bool(shutil.which('wg'))
# 查模块**文件**在不在 —— 这一级以前完全没有,是本轮误报的根因。
# 不能只认 .ko:Debian 默认压成 .ko.xz,个别发行版用 .ko.zst。
mod_file = ''
for _n in ('wireguard.ko', 'wireguard.ko.xz', 'wireguard.ko.zst'):
_p = os.path.join('/lib/modules', kver,
'kernel/drivers/net/wireguard', _n)
if os.path.isfile(_p):
mod_file = _p
break
if mod_loaded and tool:
state = 'ready'
elif mod_loaded:
state = 'need_tool'
elif mod_file:
state = 'need_module'
else:
state = 'unsupported'
# 虚拟化形态只用于文案说明(kvm 是虚拟机,不是容器 —— 别再写反了)
virt = ''
if shutil.which('systemd-detect-virt'):
rc, o, _e = sh(['systemd-detect-virt'], timeout=6)
if rc == 0:
virt = (o or '').strip()
return {
'state': state,
'mod_loaded': mod_loaded,
'mod_file': mod_file,
'tool': tool,
'kernel': kver,
'virt': virt,
'autoload': os.path.isfile(WG_AUTOLOAD),
'fixable': state in ('need_module', 'need_tool'),
}
def _vpn_write_autoload():
"""把 wireguard 写进 /etc/modules-load.d/,重启后自动加载。
为什么要自己写:Debian 的 wireguard 走的是 **udev 按需加载**
(有人 `ip link add type wireguard` 时才自动 modprobe),
/etc/modules 默认不写它。于是每次重启后 /sys/module/wireguard
都不存在 → 本页报「不支持」→ 用户以为坏了。
显式写一份 modules-load.d 才是「开机自动加载」的正解。
"""
txt = ('# 由 drouter 写入:VPN 服务端需要 wireguard 内核模块。\n'
'# 不写的话模块不会被加载(Debian 走 udev 按需加载),\n'
'# 表现为 drouter VPN 页提示「内核不支持 WireGuard」。\n'
'wireguard\n')
try:
os.makedirs(os.path.dirname(WG_AUTOLOAD), exist_ok=True)
with open(WG_AUTOLOAD, 'w', encoding='utf-8') as f:
f.write(txt)
return True, ''
except Exception as ex:
return False, str(ex)
def _vpn_ensure_module(persist=True):
"""尝试加载 wireguard 内核模块;成功后顺手写开机自动加载。
返回 (ok, why)。纯本地动作(modprobe + 写一个 conf),
不碰网络、不碰防火墙,所以放在 apply 之前自动做是安全的 ——
能自愈的事不该把用户撵去 SSH。
"""
if os.path.isdir('/sys/module/wireguard'):
return True, ''
if not shutil.which('modprobe'):
return False, '系统里没有 modprobe(kmod 包),无法加载内核模块'
rc, o, e = sh(['modprobe', 'wireguard'], timeout=20)
# 光看 rc 不够:modprobe 可能 RC=0 但模块仍不在(极少见的
# built-in 裁剪 / seccomp 拦截),所以一律以 /sys/module 复核。
if rc != 0 or not os.path.isdir('/sys/module/wireguard'):
return False, 'modprobe wireguard 失败:%s' % (
e or o or ('返回码 %d' % rc))
if persist:
ok2, why2 = _vpn_write_autoload()
if not ok2:
# 加载成功但持久化失败:模块这次能用,只是重启后又要重来。
# 不能当失败返回(服务能起来),但必须让用户知道。
return True, ('模块已加载,但写 %s 失败(%s),'
'重启后需要重新加载' % (WG_AUTOLOAD, why2))
return True, ''
def _vpn_install_tool():
"""装 wireguard-tools(提供 wg / wg-quick)。返回 (ok, detail)。"""
if shutil.which('wg'):
return True, 'wireguard-tools 已就绪'
have, lack = _pkgs_available(['wireguard-tools', 'wireguard'])
if not have:
return False, ('在当前 apt 源里找不到 wireguard-tools(查到:%s)。'
'请先执行 apt-get update,或改用国内镜像源后重试。'
% ('、'.join(lack) or '无'))
env = dict(os.environ)
env['DEBIAN_FRONTEND'] = 'noninteractive'
rc, o, e = sh(['apt-get', 'install', '-y', '--no-install-recommends'] + have,
timeout=900, env=env)
if rc != 0 or not shutil.which('wg'):
return False, ('apt-get install %s 失败:%s'
% (' '.join(have), (e or o)[-300:] or ('返回码 %d' % rc)))
return True, '已安装 %s' % ' '.join(have)
def _vpn_free_port(prefer=0):
@@ -3752,11 +3881,79 @@ def act_vpn(p):
return _vpn_peer_conf(p)
if op == 'endpoint':
return _vpn_endpoint()
if op == 'fix':
return _vpn_fix_op()
if op == 'delete_all':
return _vpn_delete_all()
return fail('未知的 VPN 操作:%s' % op)
def _vpn_fix_op():
"""一键修复:加载内核模块 + 装 wireguard-tools + 写开机自动加载。
分步做、逐步汇报,不是一锅端。理由:
* modprobe 是纯本地动作,不碰网络/防火墙,可以直接做;
* 装包要联网、要动系统,**必须**是用户点了按钮才做,
不能在 status 或 apply 的路径里偷偷执行。
已经是 ready 时是幂等的 —— 直接告诉用户不用修。
"""
before = _vpn_env()
if before['state'] == 'ready':
# ready 但没写开机自加载:顺手补上(否则重启后又变回不可用)
if before['autoload']:
return ok({'env': before, 'changed': []},
'WireGuard 环境正常(模块已加载、工具已安装、开机自动加载已配置)')
okw, whyw = _vpn_write_autoload()
if not okw:
return fail('WireGuard 环境正常,但写 %s 失败:%s' % (WG_AUTOLOAD, whyw),
'AUTOLOAD_FAIL', {'env': _vpn_env(), 'changed': []})
return ok({'env': _vpn_env(), 'changed': ['autoload']},
'WireGuard 环境正常,已补上开机自动加载配置')
if before['state'] == 'unsupported':
return fail('本机内核 %s 里没有 wireguard 模块,无法通过安装修复。'
'精简容器镜像和自编内核会裁掉它;'
'Debian 官方内核与 PVE/KVM 虚拟机都自带。' % before['kernel'],
'NO_WG', {'env': before, 'changed': []})
done, notes = [], []
# ① 模块
if not before['mod_loaded']:
okm, whym = _vpn_ensure_module()
if not okm:
return fail('加载 wireguard 内核模块失败:%s' % whym,
'WG_MODFAIL',
{'env': _vpn_env(), 'changed': done, 'notes': notes})
done.append('module')
notes.append('已加载 wireguard 内核模块')
if whym:
notes.append(whym) # 模块成功但 autoload 写失败,也在notes 里
elif not before['autoload']:
okw, whyw = _vpn_write_autoload()
if okw:
done.append('autoload')
notes.append('已配置开机自动加载')
else:
notes.append('写 %s 失败:%s' % (WG_AUTOLOAD, whyw))
# ② 工具
if not before['tool']:
okt, what = _vpn_install_tool()
if not okt:
return fail('内核模块已就绪,但安装 wireguard-tools 失败:%s' % what,
'NO_WG_TOOL',
{'env': _vpn_env(), 'changed': done, 'notes': notes})
done.append('tool')
notes.append(what)
after = _vpn_env()
if after['state'] != 'ready':
return fail('修复未完全成功(当前:%s)。%s'
% (after['state'], ';'.join(notes) or '请查看环境诊断'),
'FIX_INCOMPLETE',
{'env': after, 'changed': done, 'notes': notes})
log('info', 'vpn', 'VPN_ENV_FIXED',
'WireGuard 环境修复完成:%s' % ('、'.join(done) or '无需改动'))
return ok({'env': after, 'changed': done, 'notes': notes},
'修复完成:%s' % (';'.join(notes) or '环境本来就正常'))
def _vpn_status():
d = _vpn_norm(_vpn_load())
live = _vpn_live()
@@ -3783,6 +3980,12 @@ def _vpn_status():
# 留着它的诱惑是「以后调试方便」—— 不值得。
'live': live,
'installed': _vpn_installed(),
# env 是四态诊断的完整结果(state/mod_loaded/mod_file/tool/
# kernel/virt/autoload/fixable)。前端文案与「一键修复」
# 按钮全靠它 —— 早先只有一个 installed 字符串,把
# 「模块没加载」「工具没装」「内核真没模块」压成同一个 no,
# 文案只能瞎猜「精简容器」。
'env': _vpn_env(),
'has_systemd': os.path.isdir('/run/systemd/system'),
'lan_nets': _vpn_lan_net(),
'pool_conflict': conflicts,
@@ -3829,9 +4032,30 @@ def _vpn_apply_op(p):
d = _vpn_norm({**_vpn_load(), **(p.get('conf') or {})})
if d.get('exit_node') and p.get('confirm_exit') is not True:
return fail('允许全部流量经过本机需要显式确认', 'NEEDCONFIRM')
if _vpn_installed() == 'no':
return fail('本机内核或工具不支持 WireGuard,无法启动',
'NO_WG')
# 环境自检。**先尽力自愈再报错** ——
# 「模块在但没加载」是 modprobe 一下的事(本地、秒级、不碰网络),
# 早先在这里直接 return fail('内核不支持'),把一个能自愈的
# 状态报成了死路一条,用户只能自己去 SSH。
env = _vpn_env()
if env['state'] == 'unsupported':
return fail('本机内核里没有 WireGuard 模块(内核 %s),无法启动。'
% env['kernel'], 'NO_WG', {'env': env})
if env['state'] in ('need_module', 'need_tool'):
# need_module 顺手就修;need_tool 要下载安装包,不在这里做
# (点页面上的「一键修复」按钮,或去服务页装依赖)。
if env['state'] == 'need_module':
okm, why = _vpn_ensure_module()
if not okm:
return fail('WireGuard 内核模块未加载,且自动加载失败:%s。'
'可点上方「一键修复」重试。' % why,
'WG_MODFAIL', {'env': _vpn_env()})
env = _vpn_env()
if env['state'] != 'ready':
return fail(env['state'] == 'need_tool'
and ('缺少 wireguard-tools(提供 wg / wg-quick 命令),'
'无法启动服务。点上方「一键修复」可自动安装。'
or 'WireGuard 环境仍不可用。'),
'NO_WG', {'env': env})
if not d.get('port'):
d['port'] = _vpn_free_port(0)
res, err = _vpn_write_conf(d)
@@ -17944,8 +18168,10 @@ DEPS = [
# ---- VPN(WireGuard)----
# ⚠️ 这条以前是漏的:VPN 模块到处调`wg` / `wg-quick`(genkey / pubkey /
# setconf / show),但依赖清单里没有它。后果是「服务」页看不到缺失提示,
# 用户点进 VPN 页才发现配不了,而 `_vpn_installed()` 只会说「内核不支持」,
# 把他引向错误方向(其实只是没装 wireguard-tools)。
# 用户点进 VPN 页才发现配不了。1.0.8 起`_vpn_env()` 已经能分出
# 「只是没装 wireguard-tools」这一态(state=need_tool)并给一键修复,
# 但依赖清单这条仍然要留着 —— 自检页是全局视角,用户不该非得
# 先进 VPN 页才知道缺东西。
# 两个探测目标分开登记:wg 在 wireguard-tools 里,wg-quick 同包。
# 不标required —— 它只在用 VPN 时才需要,装机就强制拉进来不合理。
('wireguard-tools', 'WireGuard 工具(wg / wg-quick)', 'cmd', 'wg', False,
+1 -1
View File
@@ -2149,7 +2149,7 @@ def build_openapi(base):
'openapi': '3.0.3',
'info': {
'title': 'Drouter 通用管理 API',
'version': '1.0.7',
'version': '1.0.8',
'description': ('Drouter(Debian 13 拼装主路由)的统一 REST 接口。'
'任意语言 / 框架(curl、Python、Node、Go、PHP、Java、.NET、'
'Shell、Postman、工单系统、IoT 网关)均可直接调用。'
+225
View File
@@ -0,0 +1,225 @@
// 离线渲染验证:把 app.js 里的 vpnRenderTop / vpnFix 抽出来,
// 在最小 DOM 桩里喂四种 state,检查输出 HTML 与按钮绑定。
//
// 为什么要单独做:check-render.js 只看「视图有没有崩」,
// 抓不到「四态分支里某一档拼出来的 HTML 是坏的」或者
// 「修复按钮没绑上 onclick」。这两类只有真跑函数才暴露。
//
// 用法:node devtools/check-vpn-render.js
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const APP = fs.readFileSync(
path.join(__dirname, '..', 'web', 'app.js'), 'utf8');
let pass = 0, fail = 0;
const fails = [];
function ck(desc, cond, extra) {
if (cond) { pass++; console.log(' [OK] ' + desc); }
else {
fail++; fails.push(desc);
console.log(' [FAIL] ' + desc + (extra ? ' ' + extra : ''));
}
}
// ---- 最小 DOM 桩 ----
const store = {};
function mkEl(id) {
const el = {
id, _html: '', textContent: '', value: '', checked: false,
className: '', style: {}, children: [],
classList: { add() {}, remove() {}, toggle: () => false, contains: () => false },
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); if (id) store[id] = el; },
addEventListener() {}, appendChild() {},
querySelector() { return null; }, querySelectorAll() { return []; },
focus() {},
};
return el;
}
const doc = {
getElementById(id) { if (!store[id]) store[id] = mkEl(id); return store[id]; },
querySelector(s) {
const m = /^#(.+)$/.exec(s);
if (m) return doc.getElementById(m[1]);
return null;
},
querySelectorAll() { return []; },
createElement() { return mkEl(''); },
};
const ctxObj = {
document: doc, window: {}, console,
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
setTimeout, clearTimeout, setInterval, clearInterval,
fetch: () => Promise.reject(new Error('offline')),
location: { hash: '', href: '' },
navigator: { userAgent: 'node' },
alert() {}, confirm() { return true; },
};
ctxObj.window = ctxObj;
vm.createContext(ctxObj);
// 把 app.js 整体跑一遍(它顶层会注册一堆东西,但不会自动发请求)
try {
vm.runInContext(APP, ctxObj, { filename: 'app.js' });
} catch (e) {
// 有些脚本依赖浏览器全局,忽略顶层报错,只要函数挂上了就行
if (!/vpnRenderTop/.test(String(e))) console.log(' (顶层异常:' + e.message + ')');
}
const fn = ctxObj.vpnRenderTop;
const ffix = ctxObj.vpnFix;
ck('app.js 跑完后 vpnRenderTop 可调用', typeof fn === 'function');
ck('app.js 跑完后 vpnFix 可调用', typeof ffix === 'function');
if (typeof fn !== 'function') {
console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
process.exit(1);
}
function render(env, extra) {
const d = Object.assign({
conf: { enabled: false, port: 51820, pool: '10.66.66.0/24',
endpoint_host: '', keepalive: 25, dns: '', exit_node: false,
peers: [] },
live: { up: false, listen_port: null, peers: [], pubkey: '' },
has_systemd: true,
lan_nets: ['192.168.7.0/24'],
pool_conflict: [], port_busy: false,
path: '/etc/wireguard/wg0.conf',
installed: 'ready',
env: env,
}, extra || {});
const el = doc.getElementById('vp-top');
el._html = '';
fn(d);
return el._html;
}
console.log('');
console.log('--- 1. 四态各自的说法 ---');
// ① unsupported:内核真没模块
let h = render({ state: 'unsupported', mod_loaded: false, mod_file: '',
tool: false, kernel: '6.12.107+deb13-amd64', virt: 'kvm',
autoload: false, fixable: false });
ck('unsupported:红条 + 点明内核版本',
h.includes('notice err') && h.includes('6.12.107+deb13-amd64'));
ck('unsupported:说清是「内核里没有模块」',
h.includes('里没有 wireguard 模块'));
ck('unsupported:给出替代判断依据(KVM 自带)',
h.includes('KVM'));
ck('unsupported:**不出现**一键修复按钮(装了也没用)',
!h.includes('id="vp-fix"'), '→ ' + (h.match(/id="vp-fix"/g) || []).length + ' 个');
// ② need_module:本轮修的那个真实场景
h = render({ state: 'need_module', mod_loaded: false,
mod_file: '/lib/modules/6.12.107+deb13-amd64/kernel/drivers/net/'
+ 'wireguard/wireguard.ko.xz',
tool: false, kernel: '6.12.107+deb13-amd64', virt: 'kvm',
autoload: false, fixable: true });
ck('need_module:警告条(不是红条)', h.includes('notice warn'));
ck('need_module:说「模块在但还没加载」',
h.includes('还没加载') && h.includes('wireguard'));
ck('need_module:给出模块文件所在路径',
h.includes('kernel/drivers/net/wireguard'));
ck('need_module:有「一键修复」按钮', h.includes('id="vp-fix"'));
ck('need_module:按钮文案说清做什么',
h.includes('一键修复') && h.includes('加载模块'));
ck('need_module:表格显示「模块未加载」', h.includes('模块未加载'));
ck('need_module:**不出现**「精简容器」字样',
!h.includes('精简容器'), '→ ' + (h.match(/精简容器/g) || []).length + ' 处');
ck('need_module:**不出现**一刀切的「内核或工具不支持」',
!h.includes('内核或工具不支持'));
// ③ need_tool
h = render({ state: 'need_tool', mod_loaded: true,
mod_file: '/lib/modules/x/wireguard.ko.xz', tool: false,
kernel: '6.12.107+deb13-amd64', virt: 'kvm', autoload: true,
fixable: true });
ck('need_tool:说清缺的是 wireguard-tools',
h.includes('wireguard-tools') && h.includes('缺少'));
ck('need_tool:说明它提供什么(wg / wg-quick)',
h.includes('wg-quick'));
ck('need_tool:有「一键修复」按钮且提到安装',
h.includes('id="vp-fix"') && h.includes('安装'));
ck('need_tool:表格显示「缺少工具」', h.includes('缺少工具'));
// ④ ready + 没自启
h = render({ state: 'ready', mod_loaded: true, mod_file: '/x.ko.xz',
tool: true, kernel: '6.12.107+deb13-amd64', virt: 'kvm',
autoload: false, fixable: false });
ck('ready-无自启:提示重启后会失效', h.includes('开机自动加载'));
ck('ready-无自启:说明为什么(udev 按需加载)',
h.includes('udev'));
ck('ready-无自启:有配置按钮', h.includes('id="vp-fix"'));
ck('ready-无自启:表格显示「已就绪」', h.includes('WireGuard 就绪'));
// ⑤ ready + 有自启 → 不该有任何环境告警
h = render({ state: 'ready', mod_loaded: true, mod_file: '/x.ko.xz',
tool: true, kernel: '6.12.107+deb13-amd64', virt: 'kvm',
autoload: true, fixable: false });
ck('ready-有自启:没有多余告警条',
!h.includes('notice warn') && !h.includes('notice err'));
ck('ready-有自启:也不出现修复按钮', !h.includes('id="vp-fix"'));
ck('ready-有自启:显示「已配置开机自启」', h.includes('已配置开机自启'));
// ⑥ 老后端兜底
h = render({}, { env: undefined, installed: 'no' });
ck('老后端(无 env):显示「未检测」而不是猜',
h.includes('未检测'), '→ ' + h.slice(0, 200));
ck('老后端(无 env):不猜成内核无模块',
!h.includes('里没有 wireguard 模块'));
ck('老后端(无 env):提示升级',
h.includes('1.0.8') || h.includes('升级'));
console.log('');
console.log('--- 2. 表格里的运行环境行 ---');
h = render({ state: 'need_module', mod_loaded: false, mod_file: '/x.ko.xz',
tool: false, kernel: '6.12.107+deb13-amd64', virt: 'kvm',
autoload: false, fixable: true });
ck('有「运行环境」这一行', h.includes('运行环境'));
ck('运行环境行显示内核版本', h.includes('6.12.107+deb13-amd64'));
ck('运行环境行显示虚拟化形态(kvm)', h.includes('kvm'));
ck('非 ready 时不显示「未配置开机自启」(避免噪声)',
!h.includes('未配置开机自启'));
// ⑦ 标签闭合与转义
ck('所有 div 都闭合',
(h.match(/<div/g) || []).length === (h.match(/<\/div>/g) || []).length,
'→ <div>=' + (h.match(/<div/g) || []).length
+ ' </div>=' + (h.match(/<\/div>/g) || []).length);
// ⚠️ esc 是 app.js 的顶层 const,在 vm 沙箱里拿不到(不在 contextObj 上),
// 所以这里不检查「esc 存不存在」,而是**直接看渲染结果**:
// 喂一个带恶意标记的内核版本串,看它有没有被原样插进 HTML。
const h2 = render({ state: 'unsupported', kernel: '<img src=x onerror=alert(1)>',
mod_loaded: false, mod_file: '', tool: false,
virt: '', autoload: false, fixable: false });
ck('内核版本里的 HTML 被转义(防注入)',
!h2.includes('<img src=x') && h2.includes('&lt;img'),
'→ ' + (h2.match(/.{0,40}img.{0,40}/) || ['<没找到 img>'])[0]);
const h3 = render({ state: 'ready', mod_loaded: true, mod_file: '/x.ko.xz',
tool: true, kernel: 'k', virt: '<b>docker</b>',
autoload: false, fixable: false });
ck('虚拟化形态同样被转义', !h3.includes('<b>docker</b>'));
console.log('');
console.log('--- 3. 修复按钮绑定 ---');
const el = doc.getElementById('vp-top');
el._html = '';
fn({
conf: { enabled: false, port: 51820, pool: '10.66.66.0/24', peers: [] },
live: { up: false, peers: [] }, has_systemd: true, lan_nets: [],
pool_conflict: [], port_busy: false, path: '',
env: { state: 'need_module', mod_loaded: false, mod_file: '/x.ko.xz',
tool: false, kernel: 'k', virt: '', autoload: false, fixable: true },
});
// 桩 DOM 的 querySelector 返回 null,所以按钮只能在 HTML 里存在;
// 真正的 onclick 绑定由 check-render 的静态断言覆盖。
ck('按钮 id 固定为 vp-fix(前端绑定与后端约定一致)',
el._html.includes('id="vp-fix"'));
console.log('');
console.log('='.repeat(68));
console.log('通过 ' + pass + ' 项,失败 ' + fail + ' 项');
if (fail) { fails.forEach(f => console.log(' · ' + f)); process.exit(1); }
+6 -6
View File
@@ -5,11 +5,11 @@
为什么需要这个脚本
------------------
镜像原本是用 **buildah** 以 root 构建的,标签被钉成 `localhost/drouter:1.0.7`。
镜像原本是用 **buildah** 以 root 构建的,标签被钉成 `localhost/drouter:1.0.8`。
`localhost/` 是 Podman 的"本地镜像"约定,Docker 虽然能 load 进来,但这个前缀
既不好看也容易让人误解(像是某个私服地址)。所以这里把标签改成:
主标签 drouter:1.0.7 ← docker load 后直接用这个跑
别名 liuzhuohua/drouter:1.0.7 ← 同时打一个带用户名的,方便以后推 Docker Hub
主标签 drouter:1.0.8 ← docker load 后直接用这个跑
别名 liuzhuohua/drouter:1.0.8 ← 同时打一个带用户名的,方便以后推 Docker Hub
顺带修两个真实缺陷:
1. 镜像 LABEL 里 `org.opencontainers.image.licenses` 写的是 **GPL-3.0**,
@@ -30,10 +30,10 @@ import os
import sys
import tarfile
SRC = sys.argv[1] if len(sys.argv) > 1 else "dist/drouter-1.0.7-docker.tar"
DST = sys.argv[2] if len(sys.argv) > 2 else "dist/drouter-1.0.7-docker.tar.new"
SRC = sys.argv[1] if len(sys.argv) > 1 else "dist/drouter-1.0.8-docker.tar"
DST = sys.argv[2] if len(sys.argv) > 2 else "dist/drouter-1.0.8-docker.tar.new"
NEW_TAGS = ["drouter:1.0.7", "liuzhuohua/drouter:1.0.7"]
NEW_TAGS = ["drouter:1.0.8", "liuzhuohua/drouter:1.0.8"]
with tarfile.open(SRC, "r:") as tin:
members = tin.getmembers()
+4
View File
@@ -31,6 +31,10 @@ PY
"$NODEBIN" devtools/check-render.js web/app.js || { echo "❌ 渲染回归预检失败"; exit 1; }
# 手机适配要素(#14):viewport / 抽屉 / 表格横向滚动,防止改版时被悄悄改掉
"$NODEBIN" devtools/check-mobile.js || { echo "❌ 手机适配预检失败"; exit 1; }
# VPN 页四态文案渲染:need_module / need_tool / unsupported / ready 各喂一遍,
# 真跑 vpnRenderTop 看输出的 HTML。check-render 只看「视图崩没崩」,
# 抓不到「某一档文案拼出来的 HTML 是坏的」或「按钮没绑上」。
"$NODEBIN" devtools/check-vpn-render.js || { echo "❌ VPN 文案渲染预检失败"; exit 1; }
# 入口页破缓存:部署后用户不手动强刷也能拿到新前端
"$PYBIN" _dev/t-cachebust.py || { echo "❌ 缓存版本号预检失败"; exit 1; }
# 历史累计流量(概览页),含「计数器回绕 / 重启清零」容错
+5 -5
View File
@@ -222,21 +222,21 @@ drouter-ctl status # 查看服务 / 端口 / 主题 / 安全红线
```bash
# 1) .deb 安装包(在 Debian/Ubuntu 上执行)
bash packaging/build-deb.sh 1.0.7 # 产出 dist/drouter_1.0.7_all.deb
sudo apt install ./dist/drouter_1.0.7_all.deb
bash packaging/build-deb.sh 1.0.8 # 产出 dist/drouter_1.0.8_all.deb
sudo apt install ./dist/drouter_1.0.8_all.deb
sudo apt purge drouter # 彻底卸载
# 2) 离线依赖包(目标机无外网时)
bash scripts/make-offline-deps.sh # 产出 dist/offline-deps/
# 把整个目录拷到目标机后:
bash install.sh drouter_1.0.7_all.deb
bash install.sh drouter_1.0.8_all.deb
# 3) Docker 镜像
bash packaging/build-docker.sh 1.0.7
bash packaging/build-docker.sh 1.0.8
docker run -d --name drouter --restart unless-stopped \
--network host --cap-add NET_ADMIN --cap-add NET_RAW \
-v drouter-data:/opt/drouter/data -v drouter-etc:/etc/drouter \
drouter:1.0.7
drouter:1.0.8
```
`.deb` 的 `control` 里已声明完整 `Depends`(nftables、dnsmasq、ppp、pppoe、
+1 -1
View File
@@ -13,7 +13,7 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-1.0.7}"
VERSION="${1:-1.0.8}"
DIST="$HERE/dist"
PKG="drouter"
STAGE="$DIST/${PKG}_${VERSION}_all"
+1 -1
View File
@@ -10,7 +10,7 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
TAG="${1:-1.0.7}"
TAG="${1:-1.0.8}"
command -v docker >/dev/null 2>&1 || { echo "需要 docker"; exit 1; }
+1 -1
View File
@@ -27,7 +27,7 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-1.0.7}"
VERSION="${1:-1.0.8}"
OUTDIR="${2:-$HERE/dist}"
ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
+12 -12
View File
@@ -1,6 +1,6 @@
# Drouter · Docker 使用说明
> 适用镜像:`drouter:1.0.7`(`drouter-1.0.7-docker.tar`,约 169 MB)
> 适用镜像:`drouter:1.0.8`(`drouter-1.0.8-docker.tar`,约 169 MB)
---
@@ -20,7 +20,7 @@
不过 **Web 终端不受 systemd 缺失影响** —— 1.0.1 起终端守护会绕开 init
系统直接启动,容器里一样能开出 root shell(见第六节第 4 条自测)。
**真正当路由器跑** → 用 `drouter-1.0.7-offline-amd64.tar.gz` 装到 Debian 13 物理机/虚拟机上。
**真正当路由器跑** → 用 `drouter-1.0.8-offline-amd64.tar.gz` 装到 Debian 13 物理机/虚拟机上。
那才是这个项目的主路径,也是它被设计出来的场景。
**想快速看看界面长什么样、试试 API** → 用 Docker,一分钟就能跑起来。
@@ -30,14 +30,14 @@
## 一、加载镜像
```bash
docker load -i drouter-1.0.7-docker.tar
docker load -i drouter-1.0.8-docker.tar
```
加载完会看到两个标签(同一个镜像):
```
drouter:1.0.7
liuzhuohua/drouter:1.0.7
drouter:1.0.8
liuzhuohua/drouter:1.0.8
```
验证:
@@ -48,11 +48,11 @@ docker images | grep drouter
> **为什么有两个标签?**
> 带用户名的那个是给将来推 Docker Hub 用的。
> 日常用短的 `drouter:1.0.7` 就行。
> 日常用短的 `drouter:1.0.8` 就行。
> **如果你用的是 Podman**:本镜像最初用 buildah 构建,内部标签曾为
> `localhost/drouter:1.0.7`。发布版已统一改成 `drouter:1.0.7`
> (另有别名 `liuzhuohua/drouter:1.0.7`),`podman load` 和 `docker load`
> `localhost/drouter:1.0.8`。发布版已统一改成 `drouter:1.0.8`
> (另有别名 `liuzhuohua/drouter:1.0.8`),`podman load` 和 `docker load`
> 都能直接加载。若你手上是更早期的 tar 包,引用时才需要 `localhost/` 前缀。
---
@@ -103,7 +103,7 @@ docker run -d \
-v drouter-data:/opt/drouter/data \
-v drouter-snapshots:/opt/drouter/snapshots \
-v drouter-log:/var/log/drouter \
drouter:1.0.7
drouter:1.0.8
```
就这几行。参数含义与 compose 里**完全一致**,逐个对应。
@@ -385,8 +385,8 @@ docker inspect --format '{{json .Mounts}}' drouter | python3 -m json.tool
```bash
# 目标机上
tar xzf drouter-1.0.7-offline-amd64.tar.gz
cd drouter-1.0.7-offline-amd64
tar xzf drouter-1.0.8-offline-amd64.tar.gz
cd drouter-1.0.8-offline-amd64
sudo bash install.sh
# 恢复你在容器里试出来的配置
@@ -402,7 +402,7 @@ sudo systemctl restart drouter-web drouter-helpd drouter-shelld
|---|---|
| 基础镜像 | `debian:trixie-slim` |
| 架构 | amd64 / linux |
| 标签 | `drouter:1.0.7`、`liuzhuohua/drouter:1.0.7` |
| 标签 | `drouter:1.0.8`、`liuzhuohua/drouter:1.0.8` |
| 入口 | `/opt/drouter/bin/docker-init.sh` |
| 默认命令 | `/usr/bin/python3 /opt/drouter/backend/drouter-web.py` |
| 暴露端口 | 8443(HTTPS 面板)、8080(备用 HTTP,默认关) |
+1 -1
View File
@@ -10,7 +10,7 @@
# --network host --cap-add NET_ADMIN --cap-add NET_RAW \
# -v drouter-data:/opt/drouter/data \
# -v drouter-etc:/etc/drouter \
# drouter:1.0.7
# drouter:1.0.8
# =============================================================================
FROM debian:trixie-slim
+2 -2
View File
@@ -2,7 +2,7 @@
# Drouter —— Docker Compose 编排
#
# 用法:
# docker load -i drouter-1.0.7-docker.tar
# docker load -i drouter-1.0.8-docker.tar
# docker compose up -d
# 浏览器打开 https://<宿主IP>:8443/
#
@@ -24,7 +24,7 @@
services:
drouter:
# 镜像从 tar 加载后即为这个名字。若你改过标签,这里同步改。
image: drouter:1.0.7
image: drouter:1.0.8
# 固定容器名,方便 docker exec / logs 直接引用
container_name: drouter
+1 -1
View File
@@ -102,7 +102,7 @@ if [ -z "$PKG" ]; then
PKG=$(ls -1 "$HERE"/drouter_*_all.deb 2>/dev/null | head -1 || true)
fi
if [ -z "$PKG" ]; then
die "目录里找不到 drouter_*_all.deb(用法:bash install.sh drouter_1.0.7_all.deb)"
die "目录里找不到 drouter_*_all.deb(用法:bash install.sh drouter_1.0.8_all.deb)"
fi
[ -f "$PKG" ] || PKG="$HERE/$PKG"
[ -f "$PKG" ] || die "找不到安装包:$PKG"
+3 -3
View File
@@ -22,7 +22,7 @@
#
# 用法(目标机):
# 把整个目录拷过去,执行
# sudo bash install.sh drouter_1.0.7_all.deb
# sudo bash install.sh drouter_1.0.8_all.deb
# =============================================================================
set -euo pipefail
@@ -260,7 +260,7 @@ if [ -z "$PKG" ]; then
PKG=$(ls -1 "$HERE"/drouter_*_all.deb "$HERE"/*/drouter_*_all.deb 2>/dev/null | head -1 || true)
fi
[ -n "$PKG" ] && [ -f "$PKG" ] || PKG="$HERE/$PKG"
[ -f "$PKG" ] || { echo "✘ 找不到 drouter 安装包(用法:bash install.sh drouter_1.0.7_all.deb)"; exit 1; }
[ -f "$PKG" ] || { echo "✘ 找不到 drouter 安装包(用法:bash install.sh drouter_1.0.8_all.deb)"; exit 1; }
echo "安装包:$PKG"
echo ""
@@ -320,5 +320,5 @@ echo "======================================================================"
echo "离线包已生成:$OUT"
echo " 体积:$(du -sh "$OUT" | cut -f1) (依赖 $(ls -1 "$OUT"/debs/*.deb 2>/dev/null | wc -l) 个 deb)"
echo " 分发:把整个目录拷到目标机,执行"
echo " sudo bash install.sh drouter_1.0.7_all.deb"
echo " sudo bash install.sh drouter_1.0.8_all.deb"
echo "======================================================================"
+85 -5
View File
@@ -11815,7 +11815,7 @@ async function viewVpn() {
<h3>远程连回家里
<button class="ghost small" id="vp-reload" style="float:right">重新读取</button></h3>
<p class="desc">人在外面想回内网拿文件、访问家里的 NAS,用它。
Debian 13 的内核<b>自带 WireGuard</b>,不需要装任何第三方软件,
Debian 13 的内核<b>自带 WireGuard</b>,不需要装任何第三方内核模块,
性能也远好于 OpenVPN。</p>
<p class="desc">工作方式是:给每台设备(手机 / 笔记本)生成一份独立配置,
导入对方的 WireGuard 客户端 App,连上之后就能像在家一样访问内网地址。</p>
@@ -11873,11 +11873,52 @@ async function viewVpn() {
function vpnRenderTop(d) {
const c = d.conf || {};
const live = d.live || {};
// 后端给的是四态诊断(state/mod_loaded/mod_file/tool/kernel/
// virt/autoload/fixable)。老版本后端只给 installed 字符串,
// **那时候两态信息已经丢了**,所以这里一律映射成 unknown ——
// 绝不能把旧的 'no' 直接当成 unsupported:旧判据分不清
// 「模块没加载」和「内核真没模块」,猜错就是本轮修的那个坑。
const env0 = d.env || { state: 'unknown', kernel: '', virt: '',
autoload: false };
let env = '';
if (d.installed === 'no') {
env = '<div class="notice err">本机内核或工具不支持 WireGuard,'
+ '无法启动服务。<span class="mono">Debian 13 官方内核自带 WireGuard,'
+ '出现这个提示通常说明跑在精简容器里。</span></div>';
// ⚠️ 这段文案早先只有两档,其中一档还写死了「通常说明跑在
// 精简容器里」—— 那是**猜**的,而且在 PVE 的 KVM 虚拟机上
// 明确说错了(systemd-detect-virt 返回 kvm,是虚拟机不是容器)。
// 真正的原因是「模块文件在,只是没加载」。所以现在按后端给的
// 四态各说各话,每一档都给出**能做什么**,不猜运行环境。
if (env0.state === 'unsupported') {
env = '<div class="notice err">本机内核 <span class="mono">'
+ esc(env0.kernel || '') + '</span> 里没有 wireguard 模块,无法启动服务。'
+ '<span class="mono">精简容器镜像和自编内核会裁掉它;'
+ 'Debian 官方内核与 PVE / KVM 虚拟机都自带。</span></div>';
} else if (env0.state === 'need_module') {
env = '<div class="notice warn">内核里有 wireguard 模块,但当前'
+ '<b>还没加载</b>,所以服务起不来。'
+ '<span class="mono">' + esc(env0.kernel || '')
+ '/kernel/drivers/net/wireguard</span> 下的模块文件是在的,'
+ '点下面的按钮加载一下即可(本地操作,几秒钟)。</div>'
+ '<div class="row" style="margin-top:8px">'
+ '<button class="primary" id="vp-fix">一键修复(加载模块并配置开机自启)</button>'
+ '</div>';
} else if (env0.state === 'need_tool') {
env = '<div class="notice warn">内核模块已加载,但缺少 '
+ '<span class="mono">wireguard-tools</span>(提供 wg / wg-quick 命令),'
+ '无法启动服务。点下面的按钮自动安装。</div>'
+ '<div class="row" style="margin-top:8px">'
+ '<button class="primary" id="vp-fix">一键修复(安装 wireguard-tools)</button>'
+ '</div>';
} else if (env0.state === 'ready' && !env0.autoload) {
env = '<div class="notice warn">WireGuard 可用,但<b>没有配置开机自动加载</b>。'
+ '重启后模块不会被加载,VPN 会自己起不来。'
+ '<span class="mono">Debian 走 udev 按需加载,/etc/modules 默认不写它。</span></div>'
+ '<div class="row" style="margin-top:8px">'
+ '<button class="primary" id="vp-fix">配置开机自动加载</button>'
+ '</div>';
} else if (env0.state === 'unknown') {
env = '<div class="notice warn">后端版本较旧,'
+ '无法诊断 WireGuard 环境(缺少内核模块 / 工具的分项检测)。'
+ '升级到 1.0.8 后本页会明确告诉你是模块没加载、工具没装,'
+ '还是内核真不支持,并提供一键修复。</div>';
} else if (!d.has_systemd) {
env = '<div class="notice warn">当前环境没有 systemd(容器形态)。'
+ '服务会由 drouter 直接管理接口,功能可用,但不能用 '
@@ -11886,6 +11927,16 @@ function vpnRenderTop(d) {
$('#vp-top').innerHTML = env + `<table class="kv">
<tr><td>服务状态</td><td>${live.up
? '<span class="tag ok">运行中</span>' : '<span class="tag gray">未启动</span>'}</td></tr>
<tr><td>运行环境</td><td>${env0.state === 'ready'
? '<span class="tag ok">WireGuard 就绪</span>'
: env0.state === 'unknown' ? '<span class="tag gray">未检测</span>'
: '<span class="tag err">'
+ esc({ need_module: '模块未加载', need_tool: '缺少工具',
unsupported: '内核无模块' }[env0.state] || env0.state)
+ '</span>'}<span class="desc" style="margin-left:8px">内核 ${esc(
env0.kernel || '')}${env0.virt ? ' · ' + esc(env0.virt) : ''} · ${
env0.autoload ? '已配置开机自启'
: (env0.state === 'ready' ? '未配置开机自启' : '')}</span></td></tr>
<tr><td>配置开关</td><td>${c.enabled
? '<span class="tag ok">已开启</span>' : '<span class="tag gray">已关闭</span>'}</td></tr>
<tr><td>监听端口</td><td class="mono">UDP ${c.port || '未设置'}${
@@ -11897,6 +11948,8 @@ function vpnRenderTop(d) {
<tr><td>本机内网网段</td><td class="mono">${esc((d.lan_nets || []).join('、') || '未识别')}</td></tr>
<tr><td>配置文件</td><td class="mono" style="font-size:11px">${esc(d.path || '')}</td></tr>
</table>`;
const fx = $('#vp-fix');
if (fx) fx.onclick = vpnFix;
}
function vpnRenderConf(d) {
@@ -12102,6 +12155,33 @@ async function vpnStop() {
if (r.ok) vpnLoad(false);
}
async function vpnFix() {
// 会装软件包,必须先说清代价:要走 apt、可能要下载几百 KB。
if (!confirm('一键修复会做这几件事:\n\n'
+ '1. modprobe wireguard(加载内核模块,本地操作)\n'
+ '2. 写 /etc/modules-load.d/drouter-wireguard.conf(开机自动加载)\n'
+ '3. 如果还没装,apt-get install wireguard-tools(需要联网下载)\n\n'
+ '不会改动你的网络接口、防火墙和现有 VPN 配置。\n要继续吗?')) return;
const box = $('#vp-out');
box.classList.remove('hidden');
box.innerHTML = '<p class="desc">正在修复 WireGuard 环境…'
+ '<br><span class="desc">装包时可能要等十几秒。</span></p>';
const r = await api('/api/vpn', { method: 'POST', body: { op: 'fix' } });
const d = r.data || {};
const notes = d.notes || [];
if (!r.ok) {
box.innerHTML = `<div class="notice err">${esc(r.msg_cn || '修复失败')}</div>`
+ (notes.length ? '<ul class="desc" style="margin-top:6px">'
+ notes.map(x => `<li>${esc(x)}</li>`).join('') + '</ul>' : '');
toast(r.msg_cn || '修复失败', 'err', 9000);
vpnLoad(false);
return;
}
box.innerHTML = `<div class="notice ok">${esc(r.msg_cn || '修复完成')}</div>`;
toast(r.msg_cn || '修复完成', 'ok', 8000);
vpnLoad(false);
}
async function vpnAddPeer() {
const e = $('#vp-new-name');
const name = e ? e.value.trim() : '';