一套跑在 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
146 lines
6.2 KiB
Python
146 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""部署后冒烟:确认新手向导在目标机上真的活着(只读,不改任何配置)。
|
||
|
||
用法(必须在目标机上跑):
|
||
python3 _dev/live-wizard-verify.py
|
||
"""
|
||
import json
|
||
import ssl
|
||
import sys
|
||
import urllib.request
|
||
|
||
BASE = 'https://127.0.0.1:8443'
|
||
AUTH = ('admin', 'admin123')
|
||
CTX = ssl.create_default_context()
|
||
CTX.check_hostname = False
|
||
CTX.verify_mode = ssl.CERT_NONE
|
||
|
||
PASS = FAIL = 0
|
||
TOKEN = ['']
|
||
|
||
|
||
def chk(name, cond, extra=''):
|
||
global PASS, FAIL
|
||
if cond:
|
||
PASS += 1
|
||
print('[OK] %s %s' % (name, extra))
|
||
else:
|
||
FAIL += 1
|
||
print('[FAIL] %s %s' % (name, extra))
|
||
|
||
|
||
def post(path, body=None, token=None):
|
||
data = json.dumps(body or {}).encode('utf-8')
|
||
req = urllib.request.Request(BASE + path, data=data, method='POST')
|
||
req.add_header('Content-Type', 'application/json')
|
||
if token:
|
||
req.add_header('X-Token', token)
|
||
with urllib.request.urlopen(req, context=CTX, timeout=120) as r:
|
||
return json.loads(r.read().decode('utf-8'))
|
||
|
||
|
||
def login():
|
||
"""先登录拿 token —— 接口认的是 X-Token 头,不是 HTTP Basic。
|
||
|
||
登录体字段是 username / password(不是 user / pass)。
|
||
"""
|
||
r = post('/api/login', {'username': AUTH[0], 'password': AUTH[1]})
|
||
tok = (r.get('data') or {}).get('token') or r.get('token')
|
||
TOKEN[0] = tok or ''
|
||
return bool(tok)
|
||
|
||
|
||
def call(path, body=None):
|
||
return post(path, body, TOKEN[0])
|
||
|
||
|
||
def main():
|
||
print('=== 新手向导 · 部署后冒烟(只读,不改配置)===')
|
||
chk('登录取得 token', login())
|
||
if not TOKEN[0]:
|
||
print('\n无法登录,后续检查跳过')
|
||
return 1
|
||
r = call('/api/wizard', {'op': 'probe'})
|
||
chk('接口返回 ok', r.get('ok') is True, str(r.get('msg') or r.get('error') or ''))
|
||
d = r.get('data') or {}
|
||
|
||
steps = [s.get('k') for s in (d.get('steps') or [])]
|
||
chk('四个步骤齐全且顺序正确', steps == ['wan', 'lan', 'dns', 'v6'], str(steps))
|
||
chk('每步都有标题', all(s.get('n') for s in (d.get('steps') or [])))
|
||
chk('每步都有说明', all(s.get('d') for s in (d.get('steps') or [])))
|
||
|
||
# probe 的返回是分层结构:wan / lan / dns / v6 各一个子对象
|
||
lan = d.get('lan') or {}
|
||
wan = d.get('wan') or {}
|
||
chk('探测到 LAN 接口', bool(lan.get('iface')), lan.get('iface') or '')
|
||
chk('探测到 LAN IP', bool(lan.get('lan_ip')), lan.get('lan_ip') or '')
|
||
chk('LAN 接口与配置库一致(ens18)', lan.get('iface') == 'ens18', lan.get('iface') or '')
|
||
chk('LAN IP 与 ens18 实际地址一致(192.168.7.3)',
|
||
lan.get('lan_ip') == '192.168.7.3', lan.get('lan_ip') or '')
|
||
chk('地址池预设已给出(起止)',
|
||
bool(lan.get('pool_start')) and bool(lan.get('pool_end')),
|
||
'%s - %s' % (lan.get('pool_start'), lan.get('pool_end')))
|
||
chk('LAN 能报出 DHCP 是否在跑', isinstance(lan.get('dhcp_enabled'), bool),
|
||
str(lan.get('dhcp_enabled')))
|
||
chk('WAN 能报出接口与默认路由',
|
||
bool(wan.get('iface')) and isinstance(wan.get('has_default_route'), bool),
|
||
'%s / route=%s' % (wan.get('iface'), wan.get('has_default_route')))
|
||
chk('能报出外网可达性', isinstance(d.get('internet_ok'), bool), str(d.get('internet_ok')))
|
||
chk('能报出构建保护模式状态', isinstance(d.get('build_mode'), bool),
|
||
str(d.get('build_mode')))
|
||
|
||
modes = [m.get('k') for m in (d.get('wan_modes') or [])]
|
||
chk('三种上网方式齐全', modes == ['pppoe', 'dhcp', 'static'], str(modes))
|
||
chk('每种上网方式都有说明+适用场景',
|
||
all(m.get('d') and m.get('when') for m in (d.get('wan_modes') or [])))
|
||
|
||
presets = d.get('dns_presets') or []
|
||
chk('DNS 预设 >= 5 家', len(presets) >= 5, '%d 家' % len(presets))
|
||
# "跟随运营商"这一项的地址本来就是空的(由 PPPoE 下发),不算缺失
|
||
chk('DNS 预设每家都有名字', all(p.get('n') for p in presets))
|
||
chk('DNS 预设的公共 DNS 都带地址',
|
||
all(p.get('v4') for p in presets if p.get('k') != 'isp'),
|
||
str([p.get('k') for p in presets if p.get('k') != 'isp' and not p.get('v4')]))
|
||
chk('DNS 预设每家都有人话说明', all(p.get('d') for p in presets))
|
||
|
||
pool = d.get('pool_hint') or {}
|
||
chk('地址池提示含「为什么」', bool(pool.get('why')))
|
||
chk('地址池提示含规则清单', len(pool.get('rules') or []) >= 3,
|
||
'%d 条' % len(pool.get('rules') or []))
|
||
|
||
chk('网卡下拉(WAN)非空', bool(d.get('wan_ifaces')), '%d 个' % len(d.get('wan_ifaces') or []))
|
||
chk('网卡下拉(LAN)非空', bool(d.get('lan_ifaces')), '%d 个' % len(d.get('lan_ifaces') or []))
|
||
chk('网卡下拉每项带名字与 MAC',
|
||
all(x.get('name') and x.get('mac')
|
||
for x in (d.get('wan_ifaces') or []) + (d.get('lan_ifaces') or [])))
|
||
chk('待办清单存在(可为空)', isinstance(d.get('todo'), list), str(d.get('todo')))
|
||
|
||
print('\n ── 只读校验:非法输入必须被拦住(不写盘)──')
|
||
r2 = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
|
||
'cfg': {'pool_start': '192.168.7.3', 'pool_end': '192.168.7.50'}})
|
||
chk('地址池圈进本机自己 → 拒绝', r2.get('ok') is False,
|
||
str(r2.get('msg') or '')[:80])
|
||
|
||
r3 = call('/api/wizard', {'op': 'apply_step', 'step': 'lan',
|
||
'cfg': {'pool_start': '10.0.0.100', 'pool_end': '10.0.0.200'}})
|
||
chk('地址池跨网段 → 拒绝', r3.get('ok') is False, str(r3.get('msg') or '')[:80])
|
||
|
||
r4 = call('/api/wizard', {'op': 'apply_step', 'step': 'wan', 'cfg': {'mode': 'pppoe'}})
|
||
chk('PPPoE 缺账号 → 拒绝', r4.get('ok') is False, str(r4.get('msg') or '')[:80])
|
||
|
||
r5 = call('/api/wizard', {'op': 'apply_step', 'step': 'nosuch', 'cfg': {}})
|
||
chk('未知步骤 → 拒绝', r5.get('ok') is False, str(r5.get('msg') or '')[:80])
|
||
|
||
r6 = call('/api/wizard', {'op': 'dial', 'action': 'connect'})
|
||
chk('拨号不带确认 → 拒绝(防止误断网)', r6.get('ok') is False,
|
||
str(r6.get('msg') or '')[:80])
|
||
|
||
print('\n' + '=' * 56)
|
||
print('通过 %d / 失败 %d' % (PASS, FAIL))
|
||
return 1 if FAIL else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|