当前环境没有 systemd(容器形态)。'",
+ " } 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() : '';