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:
1 parent
e1125e3978
commit
2f8f09604b
23 files changed
+1669
-79
No files matched your search
@@ -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 — 静默失效专项:六个模块的保存按钮从来没生效过
|
||||
|
||||
纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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
@@ -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 这个词」,确认不了「跑一遍之后
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 网关)均可直接调用。'
|
||||
|
||||
@@ -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('<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); }
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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、
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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,默认关) |
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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() : '';
|
||||
|
||||
Reference in new issue
Block a user