Files
liuzhuohua 2f8f09604b 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 与本地逐字节一致
2026-10-03 12:28:21 +08:00

367 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())