Files
drouter/devtools/check-render.js
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

290 lines
14 KiB
JavaScript
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.
// 离线视图渲染回归:用最小 DOM 桩 + 假 fetch,把所有视图函数都跑一遍,
// 检查是否抛异常、是否渲染出内容。不依赖真实路由器。
// 用法:node devtools/check-render.js web/app.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// 以脚本自身位置定位,避免依赖运行时的当前目录(否则换个目录跑就 ENOENT)。
const DEFAULT_APP = path.join(__dirname, '..', 'web', 'app.js');
const file = process.argv[2] || DEFAULT_APP;
const code = fs.readFileSync(file, 'utf8');
function mkEl(id) {
const el = {
id, _html: '', textContent: '', value: '', disabled: false, checked: false,
className: '', tagName: 'DIV', dataset: {}, style: {}, children: [],
classList: {
add(c) { el.className += ' ' + c; },
remove(c) { el.className = el.className.replace(c, ''); },
toggle() { return false; }, contains() { return false; },
},
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); },
addEventListener() {}, removeEventListener() {},
appendChild(c) { el.children.push(c); },
removeChild() {},
querySelector() { return mkEl('sub'); },
querySelectorAll() { return []; },
get parentElement() { return mkEl('parent'); },
get firstChild() { return mkEl('first'); },
insertBefore() {}, replaceChild() {}, cloneNode() { return mkEl('clone'); },
focus() {}, select() {}, scrollIntoView() {}, click() {},
onclick: null, onchange: null, oninput: null, onkeydown: null,
getContext() { return {}; },
};
return el;
}
const ELS = new Map();
function getEl(sel) {
const k = String(sel).replace(/^#/, '').split(/[\s>]/)[0];
if (!ELS.has(k)) ELS.set(k, mkEl(k));
return ELS.get(k);
}
const document = {
getElementById: id => getEl(id),
querySelector: getEl,
querySelectorAll: () => [],
createElement: t => mkEl('new:' + t),
body: mkEl('body'),
addEventListener() {},
};
// 假 fetch:所有 GET 返回一个宽松的 ok 响应
const CALLED = [];
async function fetchStub(url, opts) {
CALLED.push({ url, method: (opts && opts.method) || 'GET' });
let data = { ok: true, data: {}, msg_cn: 'ok' };
// 各接口按需返回形状合理的假数据,避免视图因字段类型不符而抛异常
if (/\/api\/ifaces/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/iface\/meta/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/iface_method/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/ddns/.test(url)) data = { ok: true, data: {
cfg: { provider: 'custom', domain: '', ipv4: true, ipv6: false },
public: { combo: 'both', v4: '1.2.3.4', v6: '2408::1', note: '' },
note: { level: 'ok', text: '' },
providers: [{ v: 'custom', name: '自定义', region: 'other', fields: [] }],
records: [], last_result: '' } };
else if (/\/api\/ulog\/daemon/.test(url)) data = { ok: true, data: { units: { 'drouter-logd.timer': { active: 'active', enabled: 'enabled' } }, next: 'in 42s', last: { ts: '2026-09-28T22:00:00', msg_cn: '已归档 12 条日志', code: 'ULOG_ARCHIVE_OK' } } };
else if (/\/api\/ulog\/daemon/.test(url)) data = { ok: true, data: {
units: { 'drouter-logd.timer': { active: 'active', enabled: 'enabled' } },
next: 'in 42s',
last: { ts: '2026-09-28T22:00:00', msg_cn: '已归档 12 条日志', code: 'ULOG_ARCHIVE_OK' } } };
else if (/\/api\/ulog\/conf/.test(url)) data = { ok: true, data: {
conf: { enabled: true, archive: true, keep_days: 7, keep_rows: 200000,
min_level: 'debug', sources: { fw: true, conntrack: true, flow: true,
wan: true, ddns: true, app: true, system: true } },
caps: { conntrack: true, journalctl: true },
conntrack: { enabled: true, count: 120, max: 65536 }, fw_log: true,
sources: [{ v: 'fw', n: '防火墙', desc: '' }, { v: 'conntrack', n: '连接跟踪', desc: '' },
{ v: 'flow', n: '当前连接', desc: '' }, { v: 'wan', n: 'WAN 接入', desc: '' },
{ v: 'ddns', n: '动态域名', desc: '' }, { v: 'app', n: '应用日志', desc: '' },
{ v: 'system', n: '系统日志', desc: '' }],
levels: [{ v: 'err', n: '错误' }, { v: 'warn', n: '警告' }, { v: 'info', n: '信息' }],
archive: { path: '/var/log/drouter/ulog.jsonl', size: 20480, rows: 128 } } };
else if (/\/api\/ulog\/flow/.test(url)) data = { ok: true, data: {
rows: [{ ts: '', src: 'flow', level: 'info', action: 'ORIG', proto: 'TCP',
saddr: '192.168.7.100', sport: '51234', daddr: '1.1.1.1', dport: '443',
msg_cn: 'TCP 连接', extra: { state: 'ESTABLISHED', state_cn: '已建立',
bytes: 20480, reply: false } }],
stat: { total: 1, tcp: 1, udp: 0, icmp: 0, other: 0 },
cap: { count: 120, max: 65536 }, err: '', note: '快照' } };
else if (/\/api\/ulog/.test(url)) data = { ok: true, data: {
items: [{ ts: '2026-09-28T10:00:00', src: 'fw', level: 'warn', level_cn: '警告',
action: 'DROP', proto: 'TCP', saddr: '203.0.113.9', sport: '54321',
daddr: '192.168.7.3', dport: '22', iface: 'ens19', msg_cn: '丢弃 SSH',
raw: 'DROUTER-FW DROP ...' }],
stat: { total: 1, by_src: { fw: 1 }, by_level: { warn: 1 }, by_action: { DROP: 1 },
by_proto: { TCP: 1 }, top_src_ip: [{ v: '203.0.113.9', n: 1 }],
top_dst_port: [{ v: '22', n: 1 }] },
meta: { sources_used: ['fw'], collected: 1, matched: 1, shown: 1, errors: {} },
conf: { enabled: true, archive: true, keep_days: 7, keep_rows: 200000,
min_level: 'debug', sources: { fw: true } },
sources: [{ v: 'fw', n: '防火墙', desc: '' }],
levels: [{ v: 'err', n: '错误' }, { v: 'warn', n: '警告' }, { v: 'info', n: '信息' }],
archive: { path: '/var/log/drouter/ulog.jsonl', size: 20480, rows: 128 },
note: '统一日志' } };
else if (/\/api\/fflow/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/metrics/.test(url)) data = { ok: true, data: { cpu: 3, mem: 21, disk: 12, up: 0, down: 0 } };
else if (/\/api\/dash|sysinfo/.test(url)) data = { ok: true, data: {} };
else if (/\/api\/users/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/leases/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/routes/.test(url)) data = { ok: true, data: [] };
// 主题之家(#12):list / vars / get 三种 op
const TM_VARS_ALL = ['--bg', '--bg2', '--panel', '--panel2', '--line', '--line2',
'--txt', '--txt2', '--txt3', '--pri', '--pri-d', '--pri-l', '--ok', '--ok-l',
'--warn', '--warn-l', '--err', '--err-l', '--info', '--info-l',
'--r', '--sh', '--grad', '--side', '--side-c'];
function mkTheme(id, name, builtin, dark, pri, bg) {
const sw = [pri, bg, '#ffffff', '#161f30'];
return { id, name, description: name + ' 的说明', author: builtin ? 'Drouter 内置' : 'tester',
version: '1.0', dark, builtin, vars_count: 25, has_css: false, swatch: sw,
vars: { '--pri': pri, '--bg': bg } };
}
if (/\/api\/theme/.test(url)) {
const op = /op=([a-z_]+)/.exec(url);
const which = op ? op[1] : 'list';
if (which === 'vars') {
const defaults = {};
TM_VARS_ALL.forEach(k => { defaults[k] = k.startsWith('--side') ? '236px' : '#1f6feb'; });
data = { ok: true, data: {
vars: TM_VARS_ALL.map(k => ({ name: k, cat: 'base', cat_cn: '基础配色', cn: k })),
cats: [{ k: 'base', n: '基础配色' }, { k: 'color', n: '主色与状态色' },
{ k: 'shape', n: '圆角 / 阴影 / 渐变' }, { k: 'layout', n: '布局尺寸' }],
defaults } };
} else if (which === 'get') {
data = { ok: true, data: { id: 'teal', name: '青竹', description: 'd', author: 'x',
version: '1.0', dark: false, css: '', css_text: ':root{--pri:#0d9488}',
vars: { '--pri': '#0d9488', '--bg': '#f2fbf9' } } };
} else {
data = { ok: true, data: {
themes: [
mkTheme('default', 'Drouter 经典蓝', true, false, '#1f6feb', '#eef2f9'),
mkTheme('teal', '青竹', true, false, '#0d9488', '#f2fbf9'),
mkTheme('violet', '紫罗兰', true, false, '#7c3aed', '#f6f4fe'),
mkTheme('dark-green', '暗夜墨绿', true, true, '#0f766e', '#111827'),
mkTheme('warm', '暖阳', true, false, '#f97316', '#fff8f2'),
mkTheme('dark-rose', '暗夜绯红', true, true, '#e11d48', '#150d14'),
mkTheme('my-theme', '我的自定义主题', false, false, '#3366cc', '#f7f9fc'),
],
active: 'my-theme', active_name: '我的自定义主题',
vars_total: 25, dir: '/etc/drouter/themes',
css_path: '/etc/drouter/generated/theme.css' } };
}
}
else if (/\/api\/services/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/snapshot\/list/.test(url)) data = { ok: true, data: [] };
else if (/\/api\/config/.test(url)) data = { ok: true, data: {} };
if (/\/api\/docker/.test(url)) {
data = { ok: true, data: {
pkgs: { docker: { installed: true, pkgs: ['docker.io'] },
compose: { installed: true }, install_cmd: 'apt-get install -y docker.io' },
info: { version: '27.5.1', containers: '2', running: '1', images: '5',
driver: 'overlay2', root: '/var/lib/docker' },
services: { docker: 'active', containerd: 'active' },
containers: [
{ id: 'abc123def456', name: 'web', image: 'nginx:alpine', state: 'running',
status: 'Up 3 hours', ports: '0.0.0.0:8080->80/tcp', created: '2 hours ago',
size: '12MB', stat: { cpu: '0.42%', mem: '24.1MiB / 1GiB', mem_perc: '2.35%',
net: '1.2MB / 340kB', block: '0B / 0B', pids: '5' } },
{ id: 'ffffeeee1111', name: 'db', image: 'postgres:16', state: 'exited',
status: 'Exited (0) 1 hour ago', ports: '5432/tcp', created: '1 day ago',
size: '90MB', stat: {} },
],
images: [{ repo: 'nginx', tag: 'alpine', id: 'a1b2c3d4e5f6', size: '48MB',
created: '2026-09-20 10:00:00 +0800 CST' }],
networks: [{ name: 'bridge', driver: 'bridge', scope: 'local' }],
volumes: [{ name: 'pgdata', driver: 'local', mountpoint: '/var/lib/docker/volumes/pgdata' }],
stacks: [{ name: 'my-app', dir: '/etc/drouter/docker/stacks/my-app',
file: 'docker-compose.yml', mtime: '2026-09-27 12:00:00', size: 320 }],
compose_cmd: 'docker compose', stack_dir: '/etc/drouter/docker/stacks',
note: '测试数据' }, msg_cn: 'ok' };
} else if (/\/api\/convert/.test(url)) {
data = { ok: true, data: { ok: true, yaml: 'services:\n web:\n image: nginx\n',
service: 'web', image: 'nginx', warnings: [], msg_cn: '解析成功' } };
}
return { status: 200, ok: true, json: async () => data };
}
const sandbox = {
document, fetch: fetchStub, console,
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {},
requestAnimationFrame: () => 0, alert() {}, confirm: () => false,
location: { reload() {}, href: '' },
navigator: { userAgent: 'node' }, window: {},
TextEncoder, TextDecoder, URL, Blob: function () {}, FormData: function () {},
};
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
const ctx = vm.createContext(sandbox);
try {
vm.runInContext(code, ctx, { filename: file });
} catch (e) {
console.error('脚本加载失败:' + e.message);
process.exit(1);
}
// `const` 声明不会挂到 context 对象上,需用表达式取出
let VIEWS;
try {
VIEWS = vm.runInContext('VIEWS', ctx);
} catch (e) {
VIEWS = null;
}
if (!VIEWS || typeof VIEWS !== 'object') {
console.error('未找到 VIEWS 导出(脚本需在顶层用 const VIEWS = {...})');
process.exit(1);
}
// 带内部标签页的视图:{ state: S 上的字段名, tabs: [...], render: '渲染函数名' }
const TABBED = {
theme: { state: 'themeTab', tabs: ['gallery', 'design', 'import'], render: 'tmRender' },
};
(async () => {
const names = Object.keys(VIEWS);
let bad = 0, empty = 0;
for (const n of names) {
try {
const fn = VIEWS[n];
if (typeof fn !== 'function') { console.log(` ✗ ${n}:不是函数`); bad++; continue; }
await fn();
const viewEl = getEl('#view');
const html = viewEl.innerHTML || '';
if (process.env.DUMP_VIEW === n) {
console.log('--- ' + n + ' 渲染 HTML(前 2500 字符)---');
console.log(html.slice(0, 2500));
console.log('--- 结束 ---');
}
if (html.length < 40) {
console.log(` ⚠ ${n}:渲染内容过少(${html.length} 字符)`);
empty++;
}
// 带标签页的视图:逐个标签渲染一遍,否则只有默认标签会被真正执行。
// 主题之家(#12)的三个标签页各自 $("#tm-pane").innerHTML = ...,逻辑完全不同。
if (TABBED[n]) {
for (const tab of TABBED[n].tabs) {
try {
vm.runInContext(`S.${TABBED[n].state} = ${JSON.stringify(tab)}`, ctx);
await vm.runInContext(TABBED[n].render + '()', ctx);
const h2 = getEl('#tm-pane').innerHTML || '';
if (h2.length < 40) {
console.log(` ⚠ ${n}/${tab}:标签内容过少(${h2.length} 字符)`);
empty++;
} else if (process.env.DUMP_VIEW === n) {
console.log('--- ' + n + '/' + tab + ' 前 1200 字符 ---');
console.log(h2.slice(0, 1200));
}
// 每个标签都应跑到;< 若某个标签 HTML 里出现未完成标记要报警
if (/undefined|\[object Object\]/.test(h2)) {
const hit = (/undefined/.test(h2) ? 'undefined' : '[object Object]');
console.log(` ⚠ ${n}/${tab}:输出含 ${hit}`);
empty++;
}
} catch (e) {
const st = String(e.stack || '').split('\n').slice(1, 4).join(' | ');
console.log(` ✗ ${n}/${tab}:抛异常 → ${e.message}\n ${st}`);
bad++;
}
}
// 复位到默认标签,避免影响后续视图
vm.runInContext(`S.${TABBED[n].state} = ${JSON.stringify(TABBED[n].tabs[0])}`, ctx);
}
} catch (e) {
const st = String(e.stack || '').split('\n').slice(1, 4).join(' | ');
console.log(` ✗ ${n}:抛异常 → ${e.message}\n ${st}`);
bad++;
}
}
console.log('='.repeat(60));
console.log(`视图总数 ${names.length}|异常 ${bad}|疑似空渲染 ${empty}`);
const dockerCalls = CALLED.filter(c => /\/api\/docker/.test(c.url)).length;
console.log(`docker 接口调用次数:${dockerCalls}`);
process.exit(bad ? 1 : 0);
})();