Files
drouter/_dev/t-verify.py
T
liuzhuohua 7d646b5f2b fix(helper): dnsmasq 预检失败时诊断出「/dev 节点缺失」
用户在 DHCP 页点「保存并应用」,弹出:
  配置语法检查未通过:dnsmasq: failed to seed the random number
  generator: 没有那个文件或目录

根因不是配置写错,而是目标机的 /dev 标准设备节点被误删过。

/dev 是 devtmpfs,由内核维护,但**已删除的节点内核不会重建**。
2026-09-30 一次 chroot 的 `mount --bind /dev` 变量写错,把宿主机 /dev
挂到了自己身上,随后的清理清空了整个 /dev —— 当时只补回了 /dev/null
一个节点。urandom / random / zero / full / tty / console / kmsg 以及
全部块设备(sda、sda1、sda2、zram0)和 /dev/net/tun 一直缺到今天,
直到 dnsmasq 预检要读随机数才暴露。

内核那句话里既没有 "dev" 也没有「设备」字样,用户只会以为是自己把
地址池填错了。新增 _missing_dev_nodes() 做分诊:命中该错误时返回中文
提示,指明这是系统层面的 /dev 问题、不是配置问题。

- backend/drouter-helper.py:新增 _missing_dev_nodes();_verify 的
  dnsmasq 分支做错误分诊(其它语法错误仍走原路径)
- devtools/fix-dev-nodes.sh:新增。幂等地补齐被删的标准设备节点,
  设备号分别取自内核 devices.txt(字符设备)、/proc/partitions
  (块设备)、/sys/class/input(输入设备);已存在的一律不动
- _dev/t-verify.py:新增 15 项回归,覆盖「节点齐全不误报」「缺谁报谁」
  「随机数错误被翻译」「其它错误不受影响」「合法配置通过」五条路径
- devtools/sync.sh:预检链挂上 t-verify.py

目标机 192.168.7.3 实测:
- fix-dev-nodes.sh 新建 32 个节点、0 失败;二次执行 32 跳过(幂等)
- dnsmasq --test 从「failed to seed the random number generator」
  变为「syntax check OK.」(退出码 0)
- 网络基线零变化:网关仍 192.168.7.2、ip_forward=1、
  5900 仍在听、网卡集合不变、dnsmasq 仍 inactive/disabled、
  nft 表 0 个
2026-10-01 16:01:45 +08:00

174 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
配置预检(_verify)与「/dev 节点缺失」分诊的回归测试。
背景(真实故障):
用户在 DHCP 页面点「保存并应用」,弹出
「配置语法检查未通过:dnsmasq: failed to seed the random number
generator: 没有那个文件或目录」
根因不是配置写错,而是那台机器的 /dev 标准设备节点被误删过 ——
devtmpfs 由内核维护,但**已删除的节点不会被内核重建**,于是
/dev/urandom、/dev/zero、/dev/tty 连带全部块设备一起消失了很久,
直到 dnsmasq 预检要读随机数才暴露。内核那句话里没有 "dev"、
也没有"设备"字样,用户只会以为是自己填错了。
这里钉住三件事:
1. _missing_dev_nodes() 能准确报出缺了哪几个节点
2. dnsmasq 报「seed the random number generator」时,要翻译成
中文并指明这是**系统 /dev 层面**的问题、不是配置问题
3. 其它语法错误不受影响,仍走原来的提示路径
纯逻辑测试:把函数抽到干净命名空间里跑,注入替身 os / sh,
不碰真实 /dev,也不需要目标机。
"""
import ast
import os
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(os.path.dirname(HERE), "backend", "drouter-helper.py")
_ck = [0, 0]
def ck(name, cond, extra=''):
_ck[1] += 1
if cond:
_ck[0] += 1
print(" [OK] %s" % name)
else:
print(" [!!] %s %s" % (name, extra))
def load_src():
with open(SRC, encoding='utf-8') as f:
return f.read()
def extract_funcs(src, names):
"""只抽 FunctionDef,不抽模块级赋值 —— 后者会覆盖我们注入的替身。"""
tree = ast.parse(src)
ns = {}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in names:
mod = ast.Module(body=[node], type_ignores=[])
exec(compile(mod, SRC, 'exec'), ns)
return ns
class FakePath:
"""假的 os.path:exists() 只对 missing 集合里的路径返回 False。"""
def __init__(self, missing):
self.missing = set(missing)
def exists(self, p):
return p not in self.missing
def isfile(self, p):
return False
def join(self, *a):
return os.path.join(*a)
def basename(self, p):
return os.path.basename(p)
class FakeOS:
def __init__(self, missing):
self.path = FakePath(missing)
def make_sh(result):
"""构造一个 sh() 替身,记录被调用的命令并返回预设结果。"""
calls = []
def _sh(cmd, **kw):
calls.append(cmd)
return result
_sh.calls = calls
return _sh
def run_verify(ns, expr_os, sh_result):
"""在注入替身后跑一次 _verify('dnsmasq', ...)。返回 ((ok, msg), sh)。
注意:必须**直接**往 ns 里注入,不能 dict(ns) 拷贝一份 —— 抽出来的
函数其 __globals__ 指向的是最初那个 ns,往拷贝里塞替身函数是看不见的。
"""
tmpdir = tempfile.mkdtemp(prefix='drouter-verify-')
sh = make_sh(sh_result)
ns['os'] = expr_os
ns['VERIFY_DIR'] = tmpdir
ns['VERIFY_PATH'] = {}
ns['sh'] = sh
ns['log'] = lambda *a, **k: None
return ns['_verify']('dnsmasq', [('/etc/dnsmasq.d/drouter.conf', 'port=53\n')]), sh
def main():
src = load_src()
print("一、源码必须定义分诊函数")
ck("定义了 _missing_dev_nodes()", 'def _missing_dev_nodes(' in src)
ck("_verify 里引用了它", '_missing_dev_nodes()' in src)
ns = extract_funcs(src, ['_missing_dev_nodes', '_verify'])
print()
print("二、_missing_dev_nodes:节点齐全时不该误报")
ns['os'] = FakeOS([])
ck("四个节点都在 → 返回空列表", ns['_missing_dev_nodes']() == [],
repr(ns['_missing_dev_nodes']()))
print()
print("三、_missing_dev_nodes:缺谁报谁")
ns['os'] = FakeOS(['/dev/urandom'])
got = ns['_missing_dev_nodes']()
ck("只缺 urandom → 只报 urandom", got == ['/dev/urandom'], repr(got))
ns['os'] = FakeOS(['/dev/urandom', '/dev/random', '/dev/zero', '/dev/tty'])
got = ns['_missing_dev_nodes']()
ck("全缺 → 报 4 个", len(got) == 4, repr(got))
print()
print("四、dnsmasq 报随机数设备错误 → 翻译成中文并指向系统问题")
seed_err = ('dnsmasq: failed to seed the random number generator: '
'No such file or directory')
real_os = os # 真 os:让 _verify 能正常创建/删除临时文件
(ok, msg), sh = run_verify(ns, real_os, (1, '', seed_err))
ck("预检判定为失败", ok is False, repr(ok))
ck("提示里点明了是 /dev 问题", '/dev' in msg, msg[:90])
ck("提示里说明是系统层面(非配置写错)",
('系统层面' in msg) or ('不是本次配置' in msg), msg[:120])
ck("没有把内核英文原样抛给用户",
'seed the random number generator' not in msg, msg[:120])
ck("确实调用了 dnsmasq --test", any('--test' in c for c in sh.calls), repr(sh.calls))
print()
print("五、其它语法错误不受影响,走原路径")
other_err = 'dnsmasq: bad option at line 3 of /run/drouter-verify/drouter.conf'
(ok2, msg2), _ = run_verify(ns, real_os, (1, '', other_err))
ck("预检判定为失败", ok2 is False)
ck("原始错误信息保留", 'bad option' in msg2, msg2[:120])
ck("不误加 /dev 相关文案", '/dev' not in msg2, msg2[:120])
print()
print("六、配置合法时预检通过")
(ok3, msg3), _ = run_verify(ns, real_os, (0, 'dnsmasq: syntax check OK.', ''))
ck("判定为通过", ok3 is True, repr(ok3))
ck("返回 '语法检查通过'", msg3 == '语法检查通过', repr(msg3))
print()
print("=" * 56)
print("通过 %d / %d" % (_ck[0], _ck[1]))
sys.exit(0 if _ck[0] == _ck[1] else 1)
if __name__ == '__main__':
main()