diff --git a/README.md b/README.md index 2f78518..53626ea 100644 --- a/README.md +++ b/README.md @@ -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 包里到底装了什么(点开看) ``` -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/ **想要更小的包?** 如果你的目标机有网(哪怕是内网自建 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 — 静默失效专项:六个模块的保存按钮从来没生效过 纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。 diff --git a/_dev/build-image-remote.sh b/_dev/build-image-remote.sh index aeb7386..77979de 100644 --- a/_dev/build-image-remote.sh +++ b/_dev/build-image-remote.sh @@ -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 diff --git a/_dev/live-108-wg.py b/_dev/live-108-wg.py new file mode 100644 index 0000000..9dd7023 --- /dev/null +++ b/_dev/live-108-wg.py @@ -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()) diff --git a/_dev/run-static.py b/_dev/run-static.py index 5373aca..60dbed1 100644 --- a/_dev/run-static.py +++ b/_dev/run-static.py @@ -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', } diff --git a/_dev/smoke-docker-107.sh b/_dev/smoke-docker-108.sh similarity index 77% rename from _dev/smoke-docker-107.sh rename to _dev/smoke-docker-108.sh index ae7e41a..8f8c427 100644 --- a/_dev/smoke-docker-107.sh +++ b/_dev/smoke-docker-108.sh @@ -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" diff --git a/_dev/t-107-inject-wg.py b/_dev/t-107-inject-wg.py new file mode 100644 index 0000000..914b8e2 --- /dev/null +++ b/_dev/t-107-inject-wg.py @@ -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 = '
本机内核 '\n" + " + esc(env0.kernel || '') + ' 里没有 wireguard 模块,无法启动服务。'\n" + " + '精简容器镜像和自编内核会裁掉它;'\n" + " + 'Debian 官方内核与 PVE / KVM 虚拟机都自带。
';", + " if (env0.state === 'unsupported') {\n" + " env = '
本机内核或工具不支持 WireGuard,'\n" + " + '无法启动服务。出现这个提示通常说明跑在'\n" + " + '精简容器里。
';", + ['不再断言「精简容器」', '一刀切说法', '按四态分支']), + + ('E12 前端删掉「一键修复」按钮(只剩 need_tool 一档有)', + APP, + " + '
'\n" + " + ''\n" + " + '
';", + " + '
';", + ['每一档都给出可执行动作']), + + ('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 = '
当前环境没有 systemd(容器形态)。'", + " } else if (!d.has_systemd) {\n" + " env = '
残留的旧分支
';\n" + " } else if (!d.has_systemd) {\n" + " env = '
当前环境没有 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)) diff --git a/_dev/t-107.py b/_dev/t-107.py index 1a40bb2..8533562 100644 --- a/_dev/t-107.py +++ b/_dev/t-107.py @@ -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'(?= 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 这个词」,确认不了「跑一遍之后 diff --git a/_dev/vpn-env-probe.py b/_dev/vpn-env-probe.py new file mode 100644 index 0000000..d0fcf4a --- /dev/null +++ b/_dev/vpn-env-probe.py @@ -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()) diff --git a/backend/drouter-helper.py b/backend/drouter-helper.py index a0d3dfd..2dc506b 100644 --- a/backend/drouter-helper.py +++ b/backend/drouter-helper.py @@ -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, diff --git a/backend/drouter-web.py b/backend/drouter-web.py index 9ffe50d..917ea5c 100644 --- a/backend/drouter-web.py +++ b/backend/drouter-web.py @@ -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 网关)均可直接调用。' diff --git a/devtools/check-vpn-render.js b/devtools/check-vpn-render.js new file mode 100644 index 0000000..492ae39 --- /dev/null +++ b/devtools/check-vpn-render.js @@ -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(/
/g) || []).length, + '→
=' + (h.match(/
=' + (h.match(/<\/div>/g) || []).length); +// ⚠️ esc 是 app.js 的顶层 const,在 vm 沙箱里拿不到(不在 contextObj 上), +// 所以这里不检查「esc 存不存在」,而是**直接看渲染结果**: +// 喂一个带恶意标记的内核版本串,看它有没有被原样插进 HTML。 +const h2 = render({ state: 'unsupported', kernel: '', + mod_loaded: false, mod_file: '', tool: false, + virt: '', autoload: false, fixable: false }); +ck('内核版本里的 HTML 被转义(防注入)', + !h2.includes(''])[0]); +const h3 = render({ state: 'ready', mod_loaded: true, mod_file: '/x.ko.xz', + tool: true, kernel: 'k', virt: 'docker', + autoload: false, fixable: false }); +ck('虚拟化形态同样被转义', !h3.includes('docker')); + +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); } diff --git a/devtools/retag-docker-image.py b/devtools/retag-docker-image.py index 9c28660..d26614e 100644 --- a/devtools/retag-docker-image.py +++ b/devtools/retag-docker-image.py @@ -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() diff --git a/devtools/sync.sh b/devtools/sync.sh index 2347e8a..580842f 100644 --- a/devtools/sync.sh +++ b/devtools/sync.sh @@ -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; } # 历史累计流量(概览页),含「计数器回绕 / 重启清零」容错 diff --git a/docs/02-部署与使用手册.md b/docs/02-部署与使用手册.md index 7fbcc84..0ee6aa8 100644 --- a/docs/02-部署与使用手册.md +++ b/docs/02-部署与使用手册.md @@ -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、 diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 4cc5080..bc5606b 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -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" diff --git a/packaging/build-docker.sh b/packaging/build-docker.sh index 5531b88..3056a80 100644 --- a/packaging/build-docker.sh +++ b/packaging/build-docker.sh @@ -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; } diff --git a/packaging/build-offline-bundle.sh b/packaging/build-offline-bundle.sh index 4761c6e..e7a2139 100644 --- a/packaging/build-offline-bundle.sh +++ b/packaging/build-offline-bundle.sh @@ -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)" diff --git a/packaging/docker/DOCKER-GUIDE.md b/packaging/docker/DOCKER-GUIDE.md index 935b638..a0f8b61 100644 --- a/packaging/docker/DOCKER-GUIDE.md +++ b/packaging/docker/DOCKER-GUIDE.md @@ -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,默认关) | diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index 04cf6a8..a57141e 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -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 diff --git a/packaging/docker/docker-compose.yml b/packaging/docker/docker-compose.yml index 8dea65e..28f765a 100644 --- a/packaging/docker/docker-compose.yml +++ b/packaging/docker/docker-compose.yml @@ -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 diff --git a/packaging/offline-install.sh b/packaging/offline-install.sh index 3b28177..484c05d 100644 --- a/packaging/offline-install.sh +++ b/packaging/offline-install.sh @@ -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" diff --git a/scripts/make-offline-deps.sh b/scripts/make-offline-deps.sh index b3b8f8b..e7fe008 100644 --- a/scripts/make-offline-deps.sh +++ b/scripts/make-offline-deps.sh @@ -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 "======================================================================" diff --git a/web/app.js b/web/app.js index bb781ab..9179e95 100644 --- a/web/app.js +++ b/web/app.js @@ -11815,7 +11815,7 @@ async function viewVpn() {

远程连回家里

人在外面想回内网拿文件、访问家里的 NAS,用它。 - Debian 13 的内核自带 WireGuard,不需要装任何第三方软件, + Debian 13 的内核自带 WireGuard,不需要装任何第三方内核模块, 性能也远好于 OpenVPN。

工作方式是:给每台设备(手机 / 笔记本)生成一份独立配置, 导入对方的 WireGuard 客户端 App,连上之后就能像在家一样访问内网地址。

@@ -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 = '
本机内核或工具不支持 WireGuard,' - + '无法启动服务。Debian 13 官方内核自带 WireGuard,' - + '出现这个提示通常说明跑在精简容器里。
'; + // ⚠️ 这段文案早先只有两档,其中一档还写死了「通常说明跑在 + // 精简容器里」—— 那是**猜**的,而且在 PVE 的 KVM 虚拟机上 + // 明确说错了(systemd-detect-virt 返回 kvm,是虚拟机不是容器)。 + // 真正的原因是「模块文件在,只是没加载」。所以现在按后端给的 + // 四态各说各话,每一档都给出**能做什么**,不猜运行环境。 + if (env0.state === 'unsupported') { + env = '
本机内核 ' + + esc(env0.kernel || '') + ' 里没有 wireguard 模块,无法启动服务。' + + '精简容器镜像和自编内核会裁掉它;' + + 'Debian 官方内核与 PVE / KVM 虚拟机都自带。
'; + } else if (env0.state === 'need_module') { + env = '
内核里有 wireguard 模块,但当前' + + '还没加载,所以服务起不来。' + + '' + esc(env0.kernel || '') + + '/kernel/drivers/net/wireguard 下的模块文件是在的,' + + '点下面的按钮加载一下即可(本地操作,几秒钟)。
' + + '
' + + '' + + '
'; + } else if (env0.state === 'need_tool') { + env = '
内核模块已加载,但缺少 ' + + 'wireguard-tools(提供 wg / wg-quick 命令),' + + '无法启动服务。点下面的按钮自动安装。
' + + '
' + + '' + + '
'; + } else if (env0.state === 'ready' && !env0.autoload) { + env = '
WireGuard 可用,但没有配置开机自动加载。' + + '重启后模块不会被加载,VPN 会自己起不来。' + + 'Debian 走 udev 按需加载,/etc/modules 默认不写它。
' + + '
' + + '' + + '
'; + } else if (env0.state === 'unknown') { + env = '
后端版本较旧,' + + '无法诊断 WireGuard 环境(缺少内核模块 / 工具的分项检测)。' + + '升级到 1.0.8 后本页会明确告诉你是模块没加载、工具没装,' + + '还是内核真不支持,并提供一键修复。
'; } else if (!d.has_systemd) { env = '
当前环境没有 systemd(容器形态)。' + '服务会由 drouter 直接管理接口,功能可用,但不能用 ' @@ -11886,6 +11927,16 @@ function vpnRenderTop(d) { $('#vp-top').innerHTML = env + ` +
服务状态${live.up ? '运行中' : '未启动'}
运行环境${env0.state === 'ready' + ? 'WireGuard 就绪' + : env0.state === 'unknown' ? '未检测' + : '' + + esc({ need_module: '模块未加载', need_tool: '缺少工具', + unsupported: '内核无模块' }[env0.state] || env0.state) + + ''}内核 ${esc( + env0.kernel || '')}${env0.virt ? ' · ' + esc(env0.virt) : ''} · ${ + env0.autoload ? '已配置开机自启' + : (env0.state === 'ready' ? '未配置开机自启' : '')}
配置开关${c.enabled ? '已开启' : '已关闭'}
监听端口UDP ${c.port || '未设置'}${ @@ -11897,6 +11948,8 @@ function vpnRenderTop(d) {
本机内网网段${esc((d.lan_nets || []).join('、') || '未识别')}
配置文件${esc(d.path || '')}
`; + 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 = '

正在修复 WireGuard 环境…' + + '
装包时可能要等十几秒。

'; + const r = await api('/api/vpn', { method: 'POST', body: { op: 'fix' } }); + const d = r.data || {}; + const notes = d.notes || []; + if (!r.ok) { + box.innerHTML = `
${esc(r.msg_cn || '修复失败')}
` + + (notes.length ? '
    ' + + notes.map(x => `
  • ${esc(x)}
  • `).join('') + '
' : ''); + toast(r.msg_cn || '修复失败', 'err', 9000); + vpnLoad(false); + return; + } + box.innerHTML = `
${esc(r.msg_cn || '修复完成')}
`; + toast(r.msg_cn || '修复完成', 'ok', 8000); + vpnLoad(false); +} + async function vpnAddPeer() { const e = $('#vp-new-name'); const name = e ? e.value.trim() : '';