v1.0.10 两件大事:
1. 界面中英双语(英文界面无中文残留)
- 新增 web/i18n.js(约 4200 条词条),零依赖零构建
- 后端下发中文数据走 bt4() 查表翻译;独立模块(upstream/netdetail/
realtime/update)纳入翻译并注册切语言重绘钩子
- 后端「状态/聚合」接口只回稳定枚举+原始数值,不下发中文文案
- 顺带修一个真实 XSS:顶层 const 不挂 window,独立脚本 esc 静默退化成零转义
2. 修掉「文字竖着排/错位」(多分辨率反复暴露)
- 单元格 min-content 只有 1 字宽 → word-break:keep-all + 表头 nowrap
- 依赖卡片正文被标签/按钮挤成 27px → .dep-body{flex:1 1 200px} + flex-wrap
- 裸 flex:1(下限为0)给正文 → 一律改 flex:1 1 <px>
- .dep-item 横向容器直接塞块级子项 → 退回块级
- 关键陷阱:auto-fill 布局屏越宽列数越多单卡越窄,窄屏反而测不出来,
真机审计推进到「逐视图点按钮 + 1920/2560 宽视口 + 中英双语」
- 守卫 t-layout.py 扩到 35 条,12 处反向验证全红
3. 修真缺陷:_link_since() 重启检测被误改成 if False:(注释还写着「已修」),
uptime 归零后相减得负数 → 恢复比对 boot_id
附带:新增 25 个检查器(t-*.py)、排版审计工具链(shot-*/ocr-*/live-*)、
版本号兜底值同步到 1.0.10、.gitignore 收纳 i18n/排版排查的一次性脚本。
243 lines
12 KiB
Python
243 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""模块联动审计(#6 收尾):专查「前端点的按钮 → 后端能不能接住」这条链。
|
||
|
||
前五类检查:
|
||
1. 前端 PAGE_MODULES 里的每个 save/apply 模块,后端必须能接住
|
||
—— save 走 /api/config(白名单 keys),apply 走 APPLY_SPEC 或纯配置模块;
|
||
漏一个就是「点了应用弹一个『未知的模块』」,属于用户能直接撞上的 bug。
|
||
2. render.py 的 ALIASES 值必须都真的存在渲染器,且 canonical() 能解析;
|
||
3. act_apply 的语法预检必须传「渲染模块名」而不是前端别名,
|
||
否则 ntp / upnp 这类别名会静默跳过语法检查;
|
||
4. 八个功能模块的动作必须注册 + 有路由(storage/share/deps/nat/qos/
|
||
docker/vlan/theme);
|
||
5. 前端各视图读取的关键字段,必须在对应后端动作源码里出现
|
||
—— 读到后端从来没有的 key 就是「界面永远空白」。
|
||
"""
|
||
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')
|
||
RENDER = read('backend/render.py')
|
||
|
||
# ------------------------------------------------------- 1. 前端页面 → 后端模块
|
||
# 从 app.js 里把 PAGE_MODULES 的字面量抠出来(不执行 JS,纯文本解析)
|
||
seg = APP[APP.index('const PAGE_MODULES = {'):]
|
||
seg = seg[:seg.index('\n};')]
|
||
page_mods = {}
|
||
for line in seg.splitlines():
|
||
m = re.match(r"\s*(\w+):\s*\{\s*save:\s*\[([^\]]*)\],\s*apply:\s*\[([^\]]*)\]", line)
|
||
if not m:
|
||
continue
|
||
get = lambda s: re.findall(r"'([^']+)'", s)
|
||
page_mods[m.group(1)] = (get(m.group(2)), get(m.group(3)))
|
||
chk('解析到 PAGE_MODULES 页面映射', len(page_mods) >= 10, '实际 %d 个' % len(page_mods))
|
||
|
||
# /api/config 的可保存模块白名单
|
||
cfg_keys = set()
|
||
m = re.search(r"keys = \[([^\]]*)\]", WEB)
|
||
if m:
|
||
cfg_keys = set(re.findall(r"'([^']+)'", m.group(1)))
|
||
chk('取到 /api/config 的模块白名单', len(cfg_keys) >= 10, '实际 %d 个' % len(cfg_keys))
|
||
|
||
# APPLY_SPEC 的键(正则会命中所有形如 'x': ('y' 的字典,所以先切到 APPLY_SPEC 段)
|
||
seg2 = HELPER[HELPER.index('APPLY_SPEC = {'):]
|
||
seg2 = seg2[:seg2.index('\n}')]
|
||
apply_keys = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg2, re.M))
|
||
chk('取到 APPLY_SPEC 模块', len(apply_keys) >= 10, '实际 %d 个' % len(apply_keys))
|
||
|
||
# 纯配置模块
|
||
seg3 = HELPER[HELPER.index('CONFIG_ONLY_MODULES = {'):]
|
||
seg3 = seg3[:seg3.index('\n}')]
|
||
config_only = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg3, re.M))
|
||
chk('存在纯配置模块声明', config_only == {'system', 'portfwd'}, '实际 %s' % sorted(config_only))
|
||
|
||
bad_save, bad_apply = [], []
|
||
for page, (saves, applies) in page_mods.items():
|
||
for m in saves:
|
||
if m not in cfg_keys:
|
||
bad_save.append((page, m))
|
||
for m in applies:
|
||
if m not in apply_keys and m not in config_only:
|
||
bad_apply.append((page, m))
|
||
chk('每个页面的 save 模块都能被 /api/config 接受', not bad_save, '→ %s' % bad_save)
|
||
chk('每个页面的 apply 模块后端都认得', not bad_apply, '→ %s' % bad_apply)
|
||
|
||
# 纯配置模块不该出现在任何页面的 apply 列表里(它压根没有配置文件)
|
||
mis = [(p, m) for p, (_s, a) in page_mods.items() for m in a if m in config_only]
|
||
chk('纯配置模块没有被当成渲染目标', not mis, '→ %s' % mis)
|
||
|
||
# 有 save 却没有 apply 的页面,前端必须给出「无独立配置文件」的提示文案
|
||
chk('无渲染目标的页面有对应提示文案',
|
||
'没有独立的配置文件需要渲染' in APP or '没有独立配置文件' in APP)
|
||
|
||
# ------------------------------------------------------- 2. render 别名闭环
|
||
seg4 = RENDER[RENDER.index('ALIASES = {'):]
|
||
seg4 = seg4[:seg4.index('\n}')]
|
||
aliases = dict(re.findall(r"'([a-z_0-9]+)':\s*'([a-z_0-9]+)'", seg4))
|
||
renderers = set(re.findall(r"^RENDERERS = \{", RENDER))
|
||
seg5 = RENDER[RENDER.index('RENDERERS = {'):]
|
||
seg5 = seg5[:seg5.index('\n}')]
|
||
rend_keys = set(re.findall(r"^\s*'([a-z_0-9]+)':", seg5, re.M))
|
||
bad_alias = {k: v for k, v in aliases.items() if v not in rend_keys}
|
||
chk('ALIASES 的每个值都有对应渲染器', not bad_alias, '→ %s' % bad_alias)
|
||
chk('render 暴露了 canonical()', 'def canonical(' in RENDER)
|
||
chk('pppoe 已别名到 ppp(WAN 口页面此前会报未知模块)',
|
||
aliases.get('pppoe') == 'ppp', '实际 %s' % aliases.get('pppoe'))
|
||
chk('APPLY_SPEC 认得 pppoe', 'pppoe' in apply_keys)
|
||
|
||
# ------------------------------------------------------- 3. 语法预检用渲染模块名
|
||
chk('act_apply 用 render.canonical 取渲染模块名',
|
||
'rmodule = render.canonical(module)' in HELPER)
|
||
chk('语法预检传的是 rmodule 不是 module',
|
||
'_verify(rmodule, files)' in HELPER)
|
||
# 别名模块的语法检查以前被整段跳过:ntp->chrony、upnp->miniupnpd
|
||
chk('ntp 别名解析为 chrony(语法检查才不会跳过)',
|
||
aliases.get('ntp') == 'chrony' and "'chrony'" in HELPER)
|
||
chk('upnp 别名解析为 miniupnpd', aliases.get('upnp') == 'miniupnpd')
|
||
# 校验目录必须是能 traverse 的:放在 0700 的私有目录里,chronyd 降权后读不到,
|
||
# NTP 页就会弹「配置检查未通过:Permission denied」
|
||
chk('存在独立的语法预检目录 VERIFY_DIR', "VERIFY_DIR = '/run/drouter-verify'" in HELPER)
|
||
chk('_verify 用的是 VERIFY_DIR 而不是 root 私有目录',
|
||
'checkdir = VERIFY_DIR' in HELPER)
|
||
# chronyd 被 AppArmor 圈死,只能读 /etc/chrony/** 与 /etc/chrony.*
|
||
chk('chrony 的预检文件落在 AppArmor 允许的路径上',
|
||
"'chrony': '/etc/chrony.drouter-verify" in HELPER)
|
||
# ⚠️ 判据原来查 `tmp = VERIFY_PATH.get(module)` 这一行**字面量**。
|
||
# 1.0.10 给临时文件名加线程 id 唯一化后,那行变成了
|
||
# `_vt = VERIFY_PATH.get(module)` + `if _vt:` 两步,判据就假红了。
|
||
# 改��按**语义**查:确实读了 VERIFY_PATH 且确实据此分支。
|
||
chk('_verify 会按模块选用预检路径',
|
||
'VERIFY_PATH.get(module)' in HELPER
|
||
and re.search(r'_vt\s*=\s*VERIFY_PATH\.get\(module\)\s*\n\s*if _vt:',
|
||
HELPER) is not None,
|
||
'(要给两个分支都用上唯一化文件名,判定得跟实现一起改)')
|
||
|
||
# ------------------------------------------------------- 4. 功能模块动作注册
|
||
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)
|
||
# web.py 里登记过的路由(写法有 dict 键、元组 p in ('/api/a','/api/b')、
|
||
# p == '/api/x 三种,只抽 dict 会漏掉大半)
|
||
routes = set()
|
||
for node in ast.walk(ast.parse(WEB)):
|
||
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)
|
||
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'):
|
||
routes.add(e.value)
|
||
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)
|
||
|
||
# 模块名 → (后端动作名, 后端路由, 前端请求的路径)
|
||
MODULES = {
|
||
'storage': ('storage', '/api/storage', '/api/storage'),
|
||
'share': ('share', '/api/share', '/api/share'),
|
||
'deps': ('depcheck', '/api/deps/check', '/api/deps/check'),
|
||
'nat': ('nat_check', '/api/nat/check', '/api/nat/check'),
|
||
'qos': ('qos', '/api/qos', '/api/qos'),
|
||
'docker': ('docker', '/api/docker', '/api/docker'),
|
||
'vlan': ('vlan', '/api/vlan', '/api/vlan'),
|
||
'theme': ('theme', '/api/theme', '/api/theme'),
|
||
}
|
||
for name, (act, route, used) in MODULES.items():
|
||
chk('模块 %s 的动作已注册' % name, act in ACTIONS, '→ 缺 %s' % act)
|
||
chk('模块 %s 有后端路由' % name, route in routes, '→ 路由表缺 %s' % route)
|
||
# 主题页的请求带 query(/api/theme?op=list),只比对路径主干
|
||
chk('模块 %s 前端会请求' % name,
|
||
("api('%s'" % used) in APP or ("api('%s?" % used) in APP
|
||
or ("api('%s/op'" % used) in APP)
|
||
|
||
# ------------------------------------------------------- 5. 前端字段 ↔ 后端键名
|
||
# 只查最有代表性的几个「界面永远空白」高发点:字段名在后端源码里必须出现过。
|
||
FIELD_CASES = [
|
||
# 外置存储挂在「文件共享」页里,视图函数是 viewNfs
|
||
('storage', 'viewNfs', ('enable_smb', 'enable_nfs', 'samba', 'nfs')),
|
||
('deps', 'viewDepCheck', ('items', 'required', 'missing', 'extra')),
|
||
('vlan', 'viewVlan', ('parent', 'vid', 'purpose')),
|
||
('docker', 'viewDocker', ('containers', 'images', 'compose')),
|
||
('theme', 'viewTheme', ('themes', 'active', 'id')),
|
||
]
|
||
for name, fn, fields in FIELD_CASES:
|
||
i = APP.find('function %s(' % fn)
|
||
chk('找到前端函数 %s' % fn, i >= 0)
|
||
if i < 0:
|
||
continue
|
||
body = APP[i:i + 9000]
|
||
miss = [f for f in fields if f not in body]
|
||
chk('%s 视图仍在使用这些字段' % name, not miss, '→ 缺失 %s' % miss)
|
||
miss2 = [f for f in fields if f not in HELPER]
|
||
chk('%s 的字段后端确实会产出' % name, not miss2, '→ 后端无 %s' % miss2)
|
||
|
||
print('\n--- 孤儿控件:模板渲染了却没有任何 JS 接管的输入控件 ---')
|
||
# 曾经的真实 bug:网桥页的「生成树协议 STP」下拉框(#br-stp)只有模板没有 handler,
|
||
# 用户改完点保存,system.bridge_stp 从未更新 —— 一个改不生效的死控件。
|
||
# 这里把「渲染出来但没人管」当成回归项守住。
|
||
_NO_REF_OK = {
|
||
# 这三个是 DDNS 表单按服务商动态渲染的字段,由
|
||
# `$$('#dd-fields input')` 统一按 dataset.f 收集,不需要逐个 id 接管。
|
||
'dd-fuser', 'dd-ftoken', 'dd-fpass',
|
||
# 运行时由 caGenCsr 注入的只读文本区(内容来自模板本身,供全选复制)
|
||
'ca-cs-text',
|
||
# 只读展示框:复制按钮取的是闭包里的 d.secret,不读这个 input
|
||
'oh-secret-val',
|
||
}
|
||
_ids = sorted(set(re.findall(r'<(?:input|select|textarea)\b[^>]*\sid="([\w-]+)"', APP)))
|
||
_orphan = []
|
||
for _i in _ids:
|
||
if _i in _NO_REF_OK:
|
||
continue
|
||
# 模板里出现一次是必然;JS 里若一次都没引用过,说明没人接管它
|
||
if len(re.findall(r"['\"`#]%s\b" % re.escape(_i), APP)) <= 1:
|
||
_orphan.append(_i)
|
||
chk('输入控件没有孤儿(渲染了但没接线)', not _orphan, '→ 疑似死控件 %s' % _orphan)
|
||
print(' 已检查 %d 个输入控件,白名单放行 %d 个' % (len(_ids), len(_NO_REF_OK)))
|
||
|
||
print('\n--- 主题设计器「一键配色」必须真的接了后端 ---')
|
||
# 这个按钮原先只有模板、没有 handler,后端 palette op 却一直存在 —— 点了毫无反应。
|
||
chk('前端为 #tm-palette 绑了 handler', "palBtn.onclick = async" in APP)
|
||
chk('前端用的是真存在的 op=palette', "op: 'palette'" in APP)
|
||
chk('后端 act_theme 有 palette 分支', "op == 'palette'" in HELPER)
|
||
chk('palette 分支没有重复实现', HELPER.count("op == 'palette'") == 1,
|
||
'→ 出现 %d 次' % HELPER.count("op == 'palette'"))
|
||
|
||
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
|
||
sys.exit(1 if fails else 0)
|