Files
drouter/_dev/live-req1-6-10.py
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

185 lines
7.7 KiB
Python
Raw Permalink 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 -*-
"""真机验收:本轮三项改动(QoS 视频优先 / 依赖清单补全 / 终端长轮询)。
必须在目标机上跑(BASE 是 127.0.0.1:8443)。
用法: python3 /tmp/live-req1-6-10.py
"""
import json
import ssl
import sys
import time
import urllib.request
BASE = 'https://127.0.0.1:8443'
CTX = ssl._create_unverified_context()
TOK = None
PASS = FAIL = 0
def call(path, payload=None, timeout=40):
data = json.dumps(payload).encode('utf-8') if payload is not None else None
req = urllib.request.Request(BASE + path, data=data,
method='POST' if data is not None else 'GET')
req.add_header('Content-Type', 'application/json')
if TOK:
req.add_header('X-Token', TOK)
try:
with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
return json.loads(r.read().decode('utf-8'))
except Exception as e:
return {'ok': False, 'msg_cn': 'HTTP 异常:%s' % e}
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))
TOK = call('/api/login', {'username': 'admin', 'password': 'admin123'}).get('data', {}).get('token')
assert TOK, '登录失败'
# =========================================================
# 需求 1:QoS 预设新增「视频优先」
# =========================================================
print('\n--- 需求 1:QoS 视频优先 ---')
# 注意:/api/qos 的预设是走 GET(op=get 由路由层补),不是 POST op=presets
r = call('/api/qos')
items = (r.get('data') or {}).get('presets') or []
chk('QoS 预设接口可用', r.get('ok') is True and len(items) > 0,
'→ %d 个预设' % len(items))
vid = None
for x in items:
if x.get('id') == 'video' or '视频' in (x.get('name') or ''):
vid = x
break
chk('存在「视频优先」预设', vid is not None,
'→ %s' % (vid.get('name') if vid else '未找到'))
if vid:
chk('DSCP 为 af41', vid.get('dscp') == 'af41', '→ %s' % vid.get('dscp'))
ports = str(vid.get('ports') or '')
chk('含 WebRTC/STUN 端口 3478', '3478' in ports, '→ %s' % ports[:60])
chk('含 RTMP 端口 1935', '1935' in ports)
chk('含腾讯会议/zoom 段 8801', '8801' in ports)
chk('有中文说明 why', bool(vid.get('why')))
# =========================================================
# 需求 6:依赖清单补全
# =========================================================
print('\n--- 需求 6:依赖自检清单 ---')
r = call('/api/deps/check')
d = r.get('data') or {}
items = d.get('items') or []
by_key = {x['key']: x for x in items}
chk('自检接口返回条目', len(items) > 0, '→ %d 项' % len(items))
chk('返回分组列表', len(d.get('groups') or []) > 0,
'→ %d 组' % len(d.get('groups') or []))
must = ['e2fsprogs', 'xfsprogs', 'f2fs-tools', 'btrfs-progs', 'exfatprogs',
'ntfs-3g', 'dosfstools', 'kmod', 'procps', 'coreutils', 'tar',
'snmpd', 'snmp-cli', 'cups', 'cups-client', 'usbutils',
'smartmontools', 'lm-sensors', 'dmidecode', 'udev', 'chronyc']
miss = [k for k in must if k not in by_key]
chk('新增依赖全部登记', not miss, '→ 缺 %s' % (miss or '无'))
for k in ('e2fsprogs', 'xfsprogs', 'f2fs-tools'):
x = by_key.get(k) or {}
chk('%s 有中文名与说明' % k, bool(x.get('name')) and bool(x.get('note')),
'→ %s / %s' % (x.get('name'), (x.get('note') or '')[:24]))
chk('%s 归入 storage 组' % k, x.get('group') == 'storage',
'→ %s' % x.get('group_name'))
# 用户原话:页面提示一键安装,但就绪列表没显示其名称
# → 现在每一项都要有 name,且缺失项要带可安装的包名
noname = [x['key'] for x in items if not x.get('name')]
chk('所有条目都有名称(不会在就绪列表里显示空白)', not noname,
'→ 无名称:%s' % (noname or '无'))
pkg_of = {x['key']: x.get('pkg') for x in items}
chk('e2fsprogs 可一键安装', pkg_of.get('e2fsprogs') == 'e2fsprogs',
'→ pkg=%s' % pkg_of.get('e2fsprogs'))
danger_req = [x['key'] for x in items
if x.get('required') and x.get('pkg') in
('xfce4', 'xserver-xorg', 'samba', 'nfs-kernel-server',
'docker.io', 'snmpd', 'cups', 'smartmontools')]
chk('会拉起常驻服务的包都不是必需项', not danger_req, '→ %s' % (danger_req or '无'))
s = d.get('summary') or {}
print(' 就绪 %s / 缺失(必需) %s / 可选未装 %s / 共 %s'
% (s.get('ok'), s.get('missing'), s.get('optional'), s.get('total')))
# =========================================================
# 需求 10:终端长轮询
# =========================================================
print('\n--- 需求 10:终端长轮询与宽字符 ---')
# 终端是子路径路由:/api/webshell/connect、/api/webshell/read ...
r = call('/api/webshell/connect', {'cols': 120, 'rows': 30})
sid = (r.get('data') or {}).get('sid')
chk('终端可连接', bool(sid), '→ %s / %s' % (sid, r.get('msg_cn') or ''))
if sid:
call('/api/webshell/read', {'sid': sid}) # 清掉欢迎帧
# 长轮询:空数据时服务端应挂起 wait 秒再返回,而不是立刻返回空。
# 注意要先排空缓冲区 —— 刚连上时 shell 会陆续吐提示符等残留输出,
# 此时 drain() 立刻有数据、服务端本就该马上返回,测出来是假阴性。
drained = 0
for _ in range(8):
r = call('/api/webshell/read', {'sid': sid, 'wait': 0}, timeout=20)
if not ((r.get('data') or {}).get('data') or ''):
break
drained += 1
print(' (排空 %d 帧残留输出)' % drained)
t0 = time.time()
r = call('/api/webshell/read', {'sid': sid, 'wait': 1.2}, timeout=20)
dt = (time.time() - t0) * 1000
chk('无数据时按 wait 挂起(长轮询生效)', dt >= 900,
'→ 空读往返 %.0f ms(期望 ≈1200ms)' % dt)
chk('长轮询返回仍是可解析 JSON', isinstance(r, dict) and 'ok' in r)
# 有输出时应立刻返回,不能被 wait 拖住
call('/api/webshell/write', {'sid': sid, 'data': 'echo HOLD_PROBE\n'})
t0 = time.time()
got2 = ''
for _ in range(6):
r = call('/api/webshell/read', {'sid': sid, 'wait': 5.0}, timeout=20)
got2 += ((r.get('data') or {}).get('data') or '')
if 'HOLD_PROBE' in got2:
break
dt2 = (time.time() - t0) * 1000
chk('有输出时立刻返回(不被 wait 拖住)', 'HOLD_PROBE' in got2 and dt2 < 900,
'→ %.0f ms' % dt2)
# 命令回显
call('/api/webshell/write', {'sid': sid,
'data': 'echo WS_CJK_中文宽字符测试\n'})
got = ''
t0 = time.time()
for _ in range(40):
r = call('/api/webshell/read', {'sid': sid, 'wait': 1.0}, timeout=20)
got += ((r.get('data') or {}).get('data') or '')
if 'WS_CJK_中文宽字符测试' in got:
break
if (time.time() - t0) > 8:
break
dt = (time.time() - t0) * 1000
chk('命令回显完整(中文不乱码)', 'WS_CJK_中文宽字符测试' in got,
'→ %.0f ms' % dt)
chk('回显无 U+FFFD 替换字符', '\ufffd' not in got)
call('/api/webshell/disconnect', {'sid': sid})
# =========================================================
# 复核红线
# =========================================================
print('\n--- 红线复核 ---')
r = call('/api/sysinfo')
chk('管理接口仍在线', r.get('ok') is True,
'→ %s' % ((r.get('data') or {}).get('hostname') or r.get('msg_cn') or ''))
print('\n' + '=' * 60)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
sys.exit(1 if FAIL else 0)