Files
drouter/_dev/live-wizard-verify.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

146 lines
6.2 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 -*-
"""部署后冒烟:确认新手向导在目标机上真的活着(只读,不改任何配置)。
用法(必须在目标机上跑):
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())