一套跑在 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
125 lines
5.5 KiB
JavaScript
125 lines
5.5 KiB
JavaScript
// 在 Node 里用最小 DOM 桩模拟前端启动流程,验证:
|
|
// 1) 登录能拿到 token 2) loadAll 后每个页面都能渲染出内容(不再是「正在载入…」)
|
|
// 用法:node sim-ui.js [host] [port] 默认 192.168.7.3:8080
|
|
// 说明:这是"无浏览器"回归,能抓到 VIEWS 未定义、接口 404、字段名不匹配等
|
|
// 只有真实点击才会暴露的问题。
|
|
const args = process.argv.slice(2);
|
|
const HOST = args[0] || '192.168.7.3';
|
|
const PORT = Number(args[1] || 8080);
|
|
let TOKEN = '';
|
|
|
|
// ---- 极简 DOM 桩 ----
|
|
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); },
|
|
querySelector(s) { return document.querySelector(s); },
|
|
querySelectorAll() { return []; },
|
|
focus() {}, onclick: null, onchange: null, oninput: null, onkeydown: null,
|
|
getContext() { return {}; },
|
|
};
|
|
return el;
|
|
}
|
|
const ELS = {};
|
|
global.document = {
|
|
getElementById: id => (ELS[id] = ELS[id] || mkEl(id)),
|
|
// 关键:querySelector 必须返回同一个实例(视图函数会先赋值 innerHTML 再读回来)
|
|
querySelector: sel => {
|
|
const key = '@sel:' + sel.replace(/^#/, '').replace(/\..*$/, '').replace(/^[^a-zA-Z0-9_-]*/, '');
|
|
return (ELS[key] = ELS[key] || mkEl(key));
|
|
},
|
|
querySelectorAll: () => [],
|
|
createElement: t => mkEl('created:' + t),
|
|
body: mkEl('body'),
|
|
};
|
|
global.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
|
global.window = global;
|
|
global.setTimeout = (f, t) => 0;
|
|
global.setInterval = () => 0;
|
|
global.clearTimeout = () => {};
|
|
const http = require('http');
|
|
// 用 node 内置 http 直接请求,避免 Node 的 fetch 在纯 IPv4 环境下的兼容问题
|
|
global.fetch = (path, opts = {}) => new Promise((resolve, reject) => {
|
|
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
|
|
if (TOKEN) headers['X-Token'] = TOKEN;
|
|
const body = opts.body ? Buffer.from(String(opts.body)) : null;
|
|
if (body) headers['Content-Length'] = body.length;
|
|
const req = http.request({
|
|
host: HOST, port: PORT, path, method: opts.method || 'GET', headers,
|
|
}, res => {
|
|
let data = '';
|
|
res.on('data', c => data += c);
|
|
res.on('end', () => resolve({
|
|
status: res.statusCode,
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
json: async () => JSON.parse(data || '{}'),
|
|
text: async () => data,
|
|
}));
|
|
});
|
|
req.on('error', reject);
|
|
if (body) req.write(body);
|
|
req.end();
|
|
});
|
|
|
|
// ---- 载入前端逻辑(只取需要的函数,避免真跑入口绑定)----
|
|
const fs = require('fs');
|
|
let src = fs.readFileSync('../web/app.js', 'utf8');
|
|
// 去掉入口绑定与自动登录,手动驱动
|
|
src = src.replace(/\/\* ={10,} 入口 ={10,} \*\/[\s\S]*$/, '');
|
|
src += '\nmodule.exports={S,api,doLogin,loadAll,go,PAGES,VIEWS,logout};\n';
|
|
fs.writeFileSync('/tmp/_app_sim.js', src);
|
|
const app = require('/tmp/_app_sim.js');
|
|
|
|
(async () => {
|
|
// 1. 登录
|
|
const r = await app.api('/api/login', { method: 'POST', body: { username: 'admin', password: 'admin123' } });
|
|
if (!r.ok) { console.log('❌ 登录失败:', r.msg_cn); process.exit(1); }
|
|
app.S.token = r.data.token; TOKEN = r.data.token;
|
|
console.log('✅ 登录成功, token =', TOKEN.slice(0, 14) + '...');
|
|
|
|
// 2. loadAll
|
|
await app.loadAll();
|
|
console.log('✅ loadAll 完成');
|
|
console.log(' S.buildMode =', app.S.buildMode);
|
|
console.log(' S.ifaces =', app.S.ifaces.map(i => i.name).join(', '));
|
|
console.log(' S.cfg keys =', Object.keys(app.S.cfg).join(', '));
|
|
|
|
// 3. 逐页渲染
|
|
const allPages = app.PAGES.filter(p => !p.sep);
|
|
const keys = allPages.map(p => p.k);
|
|
let bad = 0, soon = 0;
|
|
console.log('\n=== 逐页渲染 ===');
|
|
const vEl = document.querySelector('#view');
|
|
for (const k of keys) {
|
|
vEl._html = '';
|
|
// 标注"待完善"的页面走 renderSoon 占位,属预期,跳过
|
|
const page = allPages.find(p => p.k === k) || {};
|
|
if (page.tag === 'soon') { console.log(` ○ ${k.padEnd(9)} 规划中(等待作者完善)`); soon++; continue; }
|
|
try {
|
|
const fn = app.VIEWS[k];
|
|
if (!fn) { console.log(` ❌ ${k}: 无视图函数`); bad++; continue; }
|
|
await fn();
|
|
const h = vEl._html || '';
|
|
// 真正的"卡在载入"是整页只剩一个占位卡(长度很短);
|
|
// 若页面已渲染出大量内容,只在个别子区域出现"正在载入"属正常。
|
|
if (!h || (h.length < 400 && h.includes('正在载入'))) {
|
|
console.log(` ❌ ${k}: 仍是占位符 (len=${h.length})`); bad++;
|
|
} else if (h.includes('页面渲染失败')) { console.log(` ❌ ${k}: 渲染失败 ${h.slice(0, 90)}`); bad++; }
|
|
else console.log(` ✅ ${k.padEnd(9)} 渲染 ${h.length} 字符`);
|
|
} catch (e) {
|
|
console.log(` ❌ ${k}: 抛异常 ${e.message}`);
|
|
bad++;
|
|
}
|
|
}
|
|
console.log(`\n已完成模块 ${keys.length - soon} 个,规划中 ${soon} 个`);
|
|
console.log(bad === 0 ? '\nUI_SIM_OK ✔ 全部已实现页面渲染正常' : `\nUI_SIM_FAIL ❌ ${bad} 个页面异常`);
|
|
})();
|