Files
liuzhuohua 79f24aa264 fix: 部署静默回退用户设置 + radvd RDNSS 未校验等 5 处缺陷
1.0.3 为纯修复版本,五个问题全部来自真机(192.168.7.3)深度排查:
翻 journal 与统一日志的 error 记录、全盘扫描文件名控制字符、把现行配置库
里每个模块的渲染结果重新交给对应真守护进程做语法校验。

- render(radvd): RDNSS 字段原先完全不校验(同段代码里 prefix 校验网段、
  DNSSL 校验域名,唯独漏了它),填 IPv4 地址会原样写进 radvd.conf 导致
  radvd 起不来,界面只回一句 radvd.conf:18 error: syntax error。
  现逐项按 IPv6 地址校验并给中文提示;多值支持逗号/空格并统一按空格输出
  (逗号形式 radvd 本就不认);DNSSL 同样支持多域名;修正 DNSSL 生命期
  误读 rdnss_life 的字段错位。

- cleanup: 自动清理的「每天 00:00」根本存不下来——前端 `Number(v) || 4`
  与后端 `int(cfg.get('hour') or 4)` 都把 0 当成「没填」,叠加后保存即变
  04:00 且改不回来。两处改为显式判断 None/''。执行时刻判定由「正好等于」
  放宽为「不早于」,避免计划时刻恰好关机/重启导致当天彻底跳过。

- deploy.sh: 三处无条件覆写改为「只在文件不存在时初始化」——
  active-theme(自定义主题每次部署被打回默认)、generated/isp-dns.conf
  (PPPoE 上游 DNS 被擦、解析变空直到下次拨号)、cleanup enabled=true
  (唯一会真删文件的模块被静默重新打开)。另补兜底:生效主题指向已不
  存在的主题时回退 default 并打印说明。

- drouter-web: socketserver 默认 handle_error 把任何异常连整段 traceback
  打进 journal,浏览器切页/关标签抛的 BrokenPipeError / ConnectionReset
  属「对面先挂了」,三日攒数十条足以淹没真报错。新增 _Server 子类只静默
  这几类,其余照旧抛出。

- devtools: 新增 fix-crlf-names.py 清理 CRLF 环境残留的名字带 \r 脏条目
  (保守:默认只预演、仅删有同名兄弟者、目录需相对文件集合为子集、
  跳过 snapshots/);sync.sh 发布预检增加「文件名不得含控制字符」。
2026-10-01 17:43:28 +08:00

285 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全项目静态审计(#6):专抓「编译得过、跑起来才炸」的低级错误。
四类检查,全部基于源码静态分析,不需要目标机:
1. web.py 里 self.helper('xxx') 的动作名必须都在 helper 的 ACTIONS 里
—— 写错一个字母就是「接口返回 ok 但实际报未知操作」;
2. app.js 里 api('/api/xxx') 的路径必须在 web.py 的路由里有分支
—— 漏了就是 404,前端拿到 HTML 报错页,表现为「点了没反应」;
3. 同一个顶层名字(函数 / 常量)不能重复定义
—— 后定义的静默覆盖前面的,最难查的一类 bug;
4. 前端引用的 CSS 类必须在 app.css 里有定义(只查我们自己新引入的类)。
"""
import ast
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, '..')
fails = 0
def chk(label, cond, extra=''):
global fails
if not cond:
fails += 1
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
def read(rel):
with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
return f.read()
HELPER = read('backend/drouter-helper.py')
WEB = read('backend/drouter-web.py')
APP = read('web/app.js')
CSS = read('web/app.css')
# ---------------------------------------------------------------- 1. 动作名
h_tree = ast.parse(HELPER)
ACTIONS = set()
for node in ast.walk(h_tree):
if isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == 'ACTIONS' and isinstance(node.value, ast.Dict):
for k in node.value.keys:
if isinstance(k, ast.Constant) and isinstance(k.value, str):
ACTIONS.add(k.value)
chk('helper 至少注册了 60 个动作', len(ACTIONS) >= 60, '实际 %d 个' % len(ACTIONS))
used = set(re.findall(r"self\.helper\(\s*'([^']+)'", WEB))
used |= set(re.findall(r'helper\(\s*"([^"]+)"', WEB))
bad = sorted(a for a in used if a not in ACTIONS)
chk('web.py 调用的动作全部已注册', not bad, '→ 未注册:%s' % bad)
# 反向:注册了但从来没人调(不算错,只是提示,避免误当失败)
unused = sorted(a for a in ACTIONS if a not in used)
print(' 提示:已注册但 web.py 未直接调用的动作 %d 个(可能由其它入口使用)'
% len(unused))
# ---------------------------------------------------------------- 2. 前端路径
# web.py 的路由写法有三种:p == '/api/x'、p in ('/api/x','/api/y')、
# p.startswith('/api/x'),还有一张 readings 字典把路径映射到 action。
# 只用正则会漏掉字典键(/api/ifaces 等 13 个路径就是这么被误报的),
# 所以这里改用 AST 精确收集。
routes, prefixes = set(), set()
w_tree = ast.parse(WEB)
for node in ast.walk(w_tree):
# 任何字典里以 /api 开头的键(readings 路由表等)
if isinstance(node, ast.Dict):
for k in node.keys:
if isinstance(k, ast.Constant) and isinstance(k.value, str) \
and k.value.startswith('/api'):
routes.add(k.value)
# 元组/列表里的路径字面量(p in ('/api/a','/api/b'))
if isinstance(node, (ast.Tuple, ast.List)):
for e in node.elts:
if isinstance(e, ast.Constant) and isinstance(e.value, str) \
and e.value.startswith('/api') and '*' not in e.value:
routes.add(e.value)
# p == '/api/x' / p in ... 的比较
if isinstance(node, ast.Compare) and isinstance(node.left, ast.Name) \
and node.left.id == 'p':
for c in node.comparators:
if isinstance(c, ast.Constant) and isinstance(c.value, str) \
and c.value.startswith('/api'):
routes.add(c.value)
# p.startswith('/api/x')
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \
and node.func.attr == 'startswith':
for a in node.args:
if isinstance(a, ast.Constant) and isinstance(a.value, str) \
and a.value.startswith('/api'):
prefixes.add(a.value)
# 前端实际请求
paths = set(re.findall(r"api\(\s*'([^']+)'", APP))
paths |= set(re.findall(r'api\(\s*`([^`]+)`', APP))
paths = set(p for p in paths if p.startswith('/api'))
missing = []
for p in sorted(paths):
if p in routes:
continue
# 前缀匹配(如 /api/webshell/connect 由 startswith('/api/webshell') 处理)
if any(p.startswith(pre) for pre in prefixes if pre.startswith('/api')):
continue
# 形如 '/api/xxx?op=yyy' 或带模板变量的,只看主干
base = p.split('?')[0]
if base in routes:
continue
if any(base.startswith(pre) for pre in prefixes if pre.startswith('/api')):
continue
missing.append(p)
chk('前端请求的接口路径都有后端路由', not missing, '→ 缺少路由:%s' % missing)
# ---------------------------------------------------------------- 3. 重复定义
def top_names(src, lang):
out = {}
if lang == 'py':
tree = ast.parse(src)
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
out.setdefault(node.name, []).append(node.lineno)
elif isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name):
out.setdefault(t.id, []).append(node.lineno)
else:
# JS:顶层 function 声明 + const/let/var
for m in re.finditer(r'^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(',
src, re.M):
out.setdefault(m.group(1), []).append(src[:m.start()].count('\n') + 1)
for m in re.finditer(r'^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=',
src, re.M):
out.setdefault(m.group(1), []).append(src[:m.start()].count('\n') + 1)
return out
for rel, src, lang in (('backend/drouter-helper.py', HELPER, 'py'),
('backend/drouter-web.py', WEB, 'py'),
('web/app.js', APP, 'js')):
names = top_names(src, lang)
dup = {k: v for k, v in names.items() if len(v) > 1}
# 白名单:JS 里允许用 let 再赋值覆盖;Python 里 __main__ 守卫下的同名不算
chk('%s 无重复顶层定义' % rel, not dup,
'→ %s' % dict(list(dup.items())[:5]))
# ---------------------------------------------------------------- 4. CSS 类
# 只查我们新引入的、带 t- 前缀的终端样式类
for cls in ('t-b', 't-dim', 't-i', 't-u', 'tcur', 'tl', 'ws-capture',
'term-screen', 'ws-probe'):
chk('app.css 定义了 .%s' % cls, ('.' + cls + '{') in CSS or ('.' + cls + ' ') in CSS)
# ---------------------------------------------------------------- 5. 外部命令 ↔ 依赖清单
# helper 里 sh(['xxx', ...]) 调的每个外部命令,都应该在 DEPS 里有对应条目。
# 漏了的表现最阴:依赖自检页一片绿,但某功能因为命令不存在静默失败。
BASE_CMDS = {
# coreutils / util-linux / systemd / 账号管理:任何 Linux 都自带,不必登记
'sh', 'bash', 'cat', 'ls', 'rm', 'cp', 'mv', 'date', 'hostname', 'uname',
'mount', 'umount', 'df', 'du', 'tar', 'which', 'chown', 'chmod', 'mkdir',
'sleep', 'echo', 'env', 'timeout', 'dpkg-query', 'apt', 'apt-get',
'apt-cache', 'apt-mark', 'systemctl', 'systemd-detect-virt', 'modinfo',
'modprobe', 'ss', 'pgrep', 'useradd', 'userdel', 'usermod', 'chpasswd',
'kill', 'ip', 'getent', 'sed', 'grep', 'awk', 'head', 'tail', 'wc', 'sort',
'id', 'stat', 'readlink', 'python3', 'find', 'touch', 'tr', 'cut', 'sync',
'blockdev', 'partprobe', 'dmesg', 'free',
}
# 同一软件包提供的兄弟命令:DEPS 用其中一个登记即可
# 同包不同命令:DEPS 里登记的是该包的一个代表命令(如 procps 用 ps),
# 代码里却可能调用同包的另一个命令(pkill)。按命令名比对会误报,这里做映射。
PKG_ALIAS = {
'chronyc': 'chronyd', 'docker-compose': 'docker', 'smbpasswd': 'smbd',
'pkill': 'ps', 'pgrep': 'ps', 'free': 'ps', 'top': 'ps',
'ss': 'ip', 'tc': 'ip', 'devlink': 'ip',
'mount': 'lsblk', 'umount': 'lsblk', 'blkid': 'lsblk', 'findmnt': 'lsblk',
'modinfo': 'modprobe', 'lsmod': 'modprobe', 'depmod': 'modprobe',
'du': 'df', 'ls': 'df', 'stat': 'df',
'fsck.exfat': 'mkfs.exfat', 'fsck.f2fs': 'mkfs.f2fs', 'fsck.vfat': 'mkfs.vfat',
'xfs_repair': 'mkfs.xfs', 'btrfs': 'mkfs.btrfs', 'resize2fs': 'mkfs.ext4',
'snmpwalk': 'snmpd', 'snmpget': 'snmpd',
# 「内核转发与加速」用它即时生效 IP 转发 / BBR(同包不同命令)
'sysctl': 'ps',
# 打印服务:这几个命令全由 cups / cups-client 提供,DEPS 里以 cupsd 为代表登记
'lpadmin': 'cupsd', 'lpstat': 'cupsd', 'lpinfo': 'cupsd',
'lp': 'cupsd', 'cancel': 'cupsd', 'cupsctl': 'cupsd',
'cupsaccept': 'cupsd', 'cupsreject': 'cupsd',
}
called = {}
for node in ast.walk(h_tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
and node.func.id in ('sh', 'sh_out', 'run'):
if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
elts = node.args[0].elts
if elts and isinstance(elts[0], ast.Constant) \
and isinstance(elts[0].value, str):
c = elts[0].value
called[c] = called.get(c, 0) + 1
deps_node = None
for node in h_tree.body:
if isinstance(node, ast.Assign) and node.value.__class__.__name__ == 'List':
for t in node.targets:
if isinstance(t, ast.Name) and t.id == 'DEPS':
deps_node = node.value
dep_targets = set()
for row in deps_node.elts:
if isinstance(row, (ast.Tuple, ast.List)) and len(row.elts) >= 4:
kind = row.elts[2]
tgt = row.elts[3]
if isinstance(kind, ast.Constant) and kind.value == 'cmd' \
and isinstance(tgt, ast.Constant) and isinstance(tgt.value, str):
for c in tgt.value.split('|'):
dep_targets.add(c.strip())
unknown = sorted(c for c in called
if c not in dep_targets and c not in BASE_CMDS
and PKG_ALIAS.get(c) not in dep_targets)
chk('helper 调用的外部命令都已登记到依赖清单', not unknown,
'→ 未登记:%s' % [(c, called[c]) for c in unknown])
# ---------------------------------------------------------------- 6. 已知易错点
chk('helper 仍保留 PATH 归一化(/usr/sbin)', '_ensure_sbin_path' in HELPER)
DEPLOY = read('scripts/deploy.sh')
chk('Web 终端守护有独立 systemd 单元', 'drouter-shelld' in DEPLOY)
chk('常驻执行守护有独立 systemd 单元', 'drouter-helpd' in DEPLOY)
chk('部署脚本会校验 9 个后端模块',
all(m in DEPLOY for m in ('render.py', 'drouter-helper.py', 'drouter-web.py',
'drouter-logd.py', 'drouter-snapshotd.py',
'drouter-rescue.py', 'drouter-shelld.py',
'drouter-helpd.py', 'theme.py')))
# 守护把 helper 常驻在内存:只 enable --now 的话跑的还是上一版代码
chk('部署时会 restart 常驻执行守护(否则旧代码不生效)',
'systemctl restart drouter-helpd' in DEPLOY)
# 两个守护共用 /run/drouter。用 RuntimeDirectory 的话,systemd 会在最后一个
# 声明它的单元停止时把整个目录重建,顺手删掉另一个守护的 socket ——
# shelld 依旧 active 但 socket 没了,终端直接废。所以必须各自 mkdir。
# 只匹配真正的配置行:单元注释里也会提到 RuntimeDirectory 这个词
chk('两个守护都不再用 RuntimeDirectory(否则互相删 socket)',
not re.search(r'^RuntimeDirectory=', DEPLOY, re.M))
chk('部署后会校验终端 socket 真的存在',
'-S /run/drouter/shell.sock' in DEPLOY)
SHELLD = read('backend/drouter-shelld.py')
chk('shelld 自己创建 /run/drouter 并设 0755',
'os.makedirs(d, exist_ok=True)' in SHELLD and 'os.chmod(d, 0o755)' in SHELLD)
chk('helper 暴露了 run_action(供守护复用同一套分发)',
'def run_action(' in HELPER)
chk('前端入口破缓存版本号仍在', '?v=' in APP or 'cachebust' in APP.lower()
or '?v=' in read('web/index.html'))
# ---------------------------------------------------------------- 部署副作用
# deploy.sh 是唯一会「替用户改机器」的脚本。它最容易犯、也最难被用户归因的错
# 是**静默回退用户设置** —— 用户只会觉得「部署完之后我的设置莫名其妙变回去了」。
chk('部署只在「还没有生效主题」时播种 default(不再每次覆写)',
'if [ ! -f /etc/drouter/active-theme ]' in DEPLOY
and DEPLOY.count('echo "default" > /etc/drouter/active-theme') == 1)
chk('部署只在 isp-dns.conf 缺失时才补占位(不再擦掉运营商 DNS)',
'if [ ! -f /etc/drouter/generated/isp-dns.conf ]' in DEPLOY
and DEPLOY.count('cat > /etc/drouter/generated/isp-dns.conf') == 1)
chk('生效主题指向已不存在的主题时会兜底回退',
'_theme_read_active()' in DEPLOY and '_theme_write_active(' in DEPLOY)
# 清理是本项目唯一会真删文件的模块:无条件传 enabled=true 会把用户在界面上
# 明确关掉的自动清理重新打开 —— 静默恢复一个「自动删文件」的策略最不该发生。
chk('部署不再强制打开用户的自动清理开关',
'if [ ! -f /etc/drouter/cleanup.conf ]' in DEPLOY)
chk('清理策略按「已有/新建」分支处理',
DEPLOY.count('"op":"save","enabled":true') == 1
and "cleanup '{\"op\":\"save\"}'" in DEPLOY)
# ---------------------------------------------------------------- 日志噪声
# socketserver 默认会把任何异常连整段 traceback 打进 journal(stderr)。
# 浏览器切页 / 关标签抛的 BrokenPipeError / ConnectionResetError 属于
# 「对面先挂了」,却足以把 journal 刷满、淹掉真正的报错。
chk('web 服务用自带 handle_error 的子类',
'class _Server(ThreadingHTTPServer)' in WEB
and 'super().handle_error(request, client_address)' in WEB)
chk('不再直接实例化 ThreadingHTTPServer(会把断连打成 traceback)',
re.search(r'=\s*ThreadingHTTPServer\(', WEB) is None)
chk('HTTP 与 HTTPS 两个监听都换成 _Server',
'httpd = _Server(' in WEB and 'httpsd = _Server(' in WEB)
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
sys.exit(1 if fails else 0)