Files
drouter/_dev/t-theme-parity.py
T
liuzhuohua 64df3cf331 feat: Drouter 1.0.0 —— Debian 13 软路由管理系统
一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的
Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、
访问控制、DDNS、文件共享、Docker 管理等。

核心设计原则:不重新实现任何协议
  PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。
  Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。
  面板删掉后,系统仍是一台配置完好的 Debian 路由器。

安全默认值
  · 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务
  · 所有改网络的动作都需要用户在面板上亲手点「应用」
  · 避开 5900 端口,不触碰 VNC / X 会话
  · 配置写入前先做语法预检,失败则回滚

架构
  · backend/  Python 3 标准库,零第三方依赖(9 个模块)
  · web/      原生 JS/CSS,无构建步骤
  · 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess
  · 鉴权走 X-Token 请求头

打包
  · packaging/build-deb.sh            → drouter_1.0.0_all.deb(含 6 个 systemd 单元)
  · scripts/make-offline-deps.sh      → 236 个依赖 .deb + 本地仓库索引
  · packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装
  · packaging/build-docker.sh         → 容器镜像(体验用)

测试
  40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配),
  全部挂在 devtools/sync.sh 的预检链里。

许可:MIT
2026-09-30 19:26:24 +08:00

277 lines
9.6 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 -*-
"""
前后端校验一致性测试(#12 主题之家)
背景:为了「输入即时报错」的体验,前端 app.js 里镜像了一份取值校验与 CSS 校验
(tmCheckValue / tmCheckCss),后端 theme.py 里有权威实现(valid_css_value /
_check_extra_css)。两份实现一旦漂移,就会出现
「前端说没问题 → 点保存后端拒绝」或「前端拦住了正常值」这类体验问题。
本测试用同一份语料分别喂给两侧,逐条比对结论,
把「前端通过但后端拒绝」标为 ✗(用户会被后端打脸),
把「前端拒绝但后端通过」标为 ⚠(用户被误拦,功能不可用)。
运行:python3 router-build/_dev/t-theme-parity.py
"""
import io
import os
import re
import sys
import json
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
BACKEND = os.path.join(ROOT, 'backend')
sys.path.insert(0, BACKEND)
import theme # noqa: E402
NODE = os.environ.get('NODE_BIN') or 'node'
# ------------------------------------------------------------------ 语料
VALUES = [
# 颜色
'#fff', '#FFF', '#ffff', '#1f6feb', '#1f6febcc', '#12345678',
'#zzz', '#12345', '#gg', '#', '##fff',
'rgb(31,111,235)', 'rgb(0,0,0)', 'rgb(255,255,255)', 'rgb(256,0,0)', 'rgb(300,0,0)',
'rgba(31,111,235,.35)', 'rgba(0,0,0,0)', 'rgba(0,0,0,1)', 'rgba(1,2,3)',
'hsl(210,90%,55%)', 'hsl(0,0%,0%)', 'hsl(360,100%,100%)', 'hsl(361,50%,50%)',
'hsla(210,90%,55%,0.5)', 'hsl(210deg,90%,55%)', 'hsl(210,101%,50%)', 'hsl(-10,50%,50%)',
# 具名
'transparent', 'currentColor', 'white', 'steelblue', 'red', 'rebeccapurple', 'borange',
# 数值
'12px', '1rem', '2.5em', '50%', '0', '10pt', '3ch', '2ex',
# var()
'var(--pri)', 'var(--pri, #fff)', 'var(--bg)', 'var(--unk)', 'var(', 'var()',
# 渐变 / 阴影
'linear-gradient(135deg,#2b7bf3 0%,#1f6feb 45%,#1552c0 100%)',
'radial-gradient(circle,#fff,#000)',
'0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)',
'inset 0 1px 0 #fff',
# 攻击串
'red;background:url(x)', '#fff}', '#fff{', 'url(http://evil.com/x.css)',
'expression(alert(1))', '@import url(x)', '#fff\\', '/* */ #fff', '#fff !important',
'#fff /* c */', '', ' ', 'none', 'auto', 'inherit', 'initial',
]
CSS_SAMPLES = [
'.card{border-width:2px}',
'.stat .val{font-size:22px}',
'/* 带注释 */ .card{color:red}',
'.card{color:#fff;background:var(--pri)}',
'h3{letter-spacing:.5px}',
'.a{color:red}.b{color:blue}',
'.card{box-shadow:0 1px 2px rgba(0,0,0,.1)}',
'.card{content:"x"}',
'.card{background:url(x.png)}',
'@import url("http://evil.com")',
'@media screen{.card{color:red}}',
'@font-face{src:url(x)}',
'.card{color:red',
'.card < > {color:red}',
'.card{a;b:c}',
'.card{background:expression(1)}',
'.card{background:javascript:alert(1)}',
'',
' ',
]
# ------------------------------------------------------------------ 前端求值
app_js = io.open(os.path.join(ROOT, 'web', 'app.js'), encoding='utf-8').read()
def grab(fn_name):
"""从 app.js 抽取某个顶层函数的源码(大括号配平法)"""
i = app_js.index('function %s(' % fn_name)
j = app_js.index('{', i)
depth = 0
for k in range(j, len(app_js)):
if app_js[k] == '{':
depth += 1
elif app_js[k] == '}':
depth -= 1
if depth == 0:
return app_js[i:k + 1]
raise RuntimeError('未找到函数结尾:' + fn_name)
def grab_array(name):
"""抽取 `const NAME = [...];` 字面量(方括号配平)"""
i = app_js.index('const %s = [' % name)
j = app_js.index('[', i)
depth = 0
for k in range(j, len(app_js)):
if app_js[k] == '[':
depth += 1
elif app_js[k] == ']':
depth -= 1
if depth == 0:
return app_js[i:k + 1] + ';'
raise RuntimeError('未找到数组结尾:' + name)
harness = ("""
'use strict';
// 直接用 app.js 自己的 TM_VARS / TM_NAMED 字面量(数组-of-数组形态),
// 避免因桩写错形态而误判:TM_VARS 元素的 x[0] 才是变量名。
@@TMVARSLIT@@
@@TMNAMEDLIT@@
@@TMCHECKVALUE@@
@@TMCHECKCSS@@
const out = {
values: VALUES.map(v => [v, tmCheckValue(v)]),
css: CSS_SAMPLES.map(c => [c, tmCheckCss(c)]),
unit: {},
};
for (const vr of ['--r', '--side', '--side-c']) {
for (const vl of ['12px', '12vw', '12vh', '2rem', '50%']) {
out.unit[vr + '|' + vl] = tmCheckValue(vl, vr);
}
}
console.log(JSON.stringify(out));
""".replace('@@TMVARSLIT@@', grab_array('TM_VARS'))
.replace('@@TMNAMEDLIT@@', grab_array('TM_NAMED'))
.replace('@@TMCHECKVALUE@@', grab('tmCheckValue'))
.replace('@@TMCHECKCSS@@', grab('tmCheckCss')))
values_json = json.dumps(VALUES, ensure_ascii=False)
css_json = json.dumps(CSS_SAMPLES, ensure_ascii=False)
tmp = os.path.join(tempfile.gettempdir(), 'tm-parity-%d.js' % os.getpid())
io.open(tmp, 'w', encoding='utf-8').write(
'const VALUES = %s;\nconst CSS_SAMPLES = %s;\n%s' % (values_json, css_json, harness))
import subprocess # noqa: E402
p = subprocess.run([NODE, tmp], capture_output=True, text=True, encoding='utf-8')
if p.returncode != 0:
print('前端求值失败:', p.stderr[:800])
sys.exit(1)
fe = json.loads(p.stdout)
os.unlink(tmp)
fe_vals = {k: v for k, v in fe['values']}
fe_css = {k: v for k, v in fe['css']}
# ------------------------------------------------------------------ 比对
pass_n = fail_n = warn_n = 0
lines = []
print('== 取值校验一致性(valid_css_value vs tmCheckValue)==')
for v in VALUES:
be_ok, be_why = theme.valid_css_value(v)
fe_why = fe_vals.get(v, '<前端未返回>')
fe_ok = (fe_why == '')
if be_ok == fe_ok:
pass_n += 1
continue
if not be_ok and fe_ok:
fail_n += 1
lines.append(' ✗ 前端放行但后端拒绝:%r\n 后端原因:%s' % (v, be_why))
else:
warn_n += 1
lines.append(' ⚠ 前端误拦但后端允许:%r' % v)
# 单位限制(只对 --r / --side / --side-c)
print('== 单位限制一致性(--r / --side / --side-c)==')
for var in ('--r', '--side', '--side-c'):
for val in ('12px', '12vw', '12vh', '2rem', '50%'):
be_ok, _ = theme.valid_css_value(val, var)
# 前端同参数
fe_why = fe.get('unit', {}).get(var + '|' + val)
if fe_why is None:
continue
fe_ok = (fe_why == '')
if be_ok == fe_ok:
pass_n += 1
elif not be_ok and fe_ok:
fail_n += 1
lines.append(' ✗ %s=%r 前端放行但后端拒绝' % (var, val))
else:
warn_n += 1
lines.append(' ⚠ %s=%r 前端误拦但后端允许' % (var, val))
print('== 自定义 CSS 校验一致性(_check_extra_css vs tmCheckCss)==')
for c in CSS_SAMPLES:
be_bad, be_why = theme._check_extra_css(c)
fe_why = fe_css.get(c, '<前端未返回>')
fe_bad = (fe_why != '')
be_ok = not be_bad
fe_ok = not fe_bad
if be_ok == fe_ok:
pass_n += 1
continue
if not be_ok and fe_ok:
fail_n += 1
lines.append(' ✗ CSS 前端放行但后端拒绝:%r\n 后端原因:%s' % (c[:60], be_why))
else:
warn_n += 1
lines.append(' ⚠ CSS 前端误拦但后端允许:%r' % c[:60])
print('== 关键安全串:两侧都必须拒绝 ==')
MUST_REJECT = [
'red;background:url(x)', '#fff}', '#fff{', 'url(http://evil.com/x.css)',
'expression(alert(1))', '@import url(x)', '#fff\\', '/* */ #fff', '#fff !important',
'rgb(300,0,0)', 'hsl(361,50%,50%)', 'var(--unk)',
]
for v in MUST_REJECT:
be_ok, _ = theme.valid_css_value(v)
fe_ok = (fe_vals.get(v) == '')
ok_pair = (not be_ok) and (not fe_ok)
if ok_pair:
pass_n += 1
else:
fail_n += 1
lines.append(' ✗ 危险串 %r 未被两侧同时拒绝(后端ok=%s 前端ok=%s)'
% (v, be_ok, fe_ok))
MUST_REJECT_CSS = ['@import url("http://evil.com")', '.card{background:url(x.png)}',
'.card{background:expression(1)}', '@font-face{src:url(x)}']
for c in MUST_REJECT_CSS:
be_bad, _ = theme._check_extra_css(c)
fe_bad = (fe_css.get(c) != '')
if be_bad and fe_bad:
pass_n += 1
else:
fail_n += 1
lines.append(' ✗ 危险 CSS %r 未被两侧同时拒绝(后端bad=%s 前端bad=%s)'
% (c[:40], be_bad, fe_bad))
print('== 清单一致性:前端副本必须等于后端权威表 ==')
drift = 0
# TM_VARS(app.js)的元素 x[0] 应正好等于 THEME_VARS 的键
fe_vars_names = re.findall(r"\[\s*'(--[a-z0-9-]+)'\s*,", grab_array('TM_VARS'))
be_vars_names = list(theme.THEME_VARS.keys())
only_fe = sorted(set(fe_vars_names) - set(be_vars_names))
only_be = sorted(set(be_vars_names) - set(fe_vars_names))
if only_fe or only_be:
drift += 1
lines.append(' ✗ TM_VARS 与 THEME_VARS 不一致:前端多 %s |后端多 %s'
% (only_fe, only_be))
else:
pass_n += 1
# TM_NAMED 应正好等于 _NAMED
fe_named = re.findall(r"'([a-z]+)'", grab_array('TM_NAMED'))
only_fe = sorted(set(fe_named) - set(theme._NAMED))
only_be = sorted(set(theme._NAMED) - set(fe_named))
if only_fe or only_be:
drift += 1
lines.append(' ✗ TM_NAMED 与 _NAMED 不一致:前端多 %s |后端多 %s'
% (only_fe, only_be))
else:
pass_n += 1
fail_n += drift
print()
print('=' * 60)
print('一致 %d 项|不一致 %d 项(其中「前端误拦」%d 项)' % (pass_n, fail_n + warn_n, warn_n))
if lines:
print('-' * 60)
for l in lines[:60]:
print(l)
print('=' * 60)
sys.exit(1 if fail_n + warn_n else 0)