Files
drouter/_dev/t-webshell.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

199 lines
8.4 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 -*-
"""Web 终端(#4 重做)的契约测试。
守住这几条最容易回退的约定:
1) helper 只做 socket 客户端:未知 op / 非法用户 / 超长输入都要被挡住;
2) shelld 的帧处理:UTF-8 多字节字符不能被分帧切成乱码;
3) shelld 的会话编号必须唯一(曾用「时间戳+pid」,同秒连两次会互相顶掉);
4) shelld 的 op 分发:不存在会话要返回 NOSESS,满额返回 BUSY;
5) web.py 里 read/write/resize 的超时必须短(这几个在打字路径上,慢了就卡手)。
Windows 上没有 pty 模块,所以这里不 import 整个 shelld,只用 ast 抽函数片段。
"""
import ast
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, '..')
fails = 0
def chk(label, cond, extra=''):
global fails
if not fails and not cond:
pass
if not cond:
fails += 1
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
def src_of(rel):
with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
return f.read()
HELPER_SRC = src_of('backend/drouter-helper.py')
SHELD_SRC = src_of('backend/drouter-shelld.py')
WEB_SRC = src_of('backend/drouter-web.py')
APP_JS = src_of('web/app.js')
APP_CSS = src_of('web/app.css')
def pick(src, names):
"""按名字从源码里抽函数定义,返回可 exec 的代码块。"""
tree = ast.parse(src)
out, got = [], set()
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in names:
out.append(ast.get_source_segment(src, node))
got.add(node.name)
missing = set(names) - got
assert not missing, '未能抽取:%s' % ', '.join(sorted(missing))
return '\n\n'.join(out)
# =========================================================================
# 1. helper 的 act_webshell(socket 客户端)
# =========================================================================
NS = {}
exec('def ok(data=None, msg="ok", code="OK"):\n'
' return {"ok": True, "code": code, "msg_cn": msg, "data": data}\n', NS)
exec('def fail(msg, code="ERR", data=None):\n'
' return {"ok": False, "code": code, "msg_cn": msg, "data": data}\n', NS)
CALLS = []
def fake_shelld_call(req, timeout=25):
CALLS.append((req, timeout))
return {'ok': True, 'msg_cn': 'stub', 'data': {'sid': 'sh1-1'}}
def fake_log(*a, **k):
pass
NS.update({'sh': lambda *a, **k: (0, '', ''), 'log': fake_log,
'_shelld_call': fake_shelld_call,
'SHELL_UNIT': 'drouter-shelld.service', 'time': __import__('time')})
exec(pick(HELPER_SRC, ['act_webshell']), NS)
act_webshell = NS['act_webshell']
r = act_webshell({'op': 'nonsense'})
chk('未知终端操作被拒绝', (not r['ok']) and r['code'] == 'BAD_OP', '→ ' + r['msg_cn'])
r = act_webshell({'op': 'connect', 'user': 'hacker'})
chk('非法登录身份被拒绝', (not r['ok']) and r['code'] == 'BAD_USER', '→ ' + r['msg_cn'])
for u in ('root', 'ajeef', 'drouter'):
CALLS[:] = []
r = act_webshell({'op': 'connect', 'user': u, 'cols': 120, 'rows': 40})
chk('允许以 %s 身份开启终端' % u, r['ok'] and CALLS[0][0]['user'] == u)
CALLS[:] = []
r = act_webshell({'op': 'connect', 'user': 'root'})
chk('connect 会把 cols/rows 透传', CALLS[0][0].get('cols') == 100 and CALLS[0][0].get('rows') == 30,
'→ cols=%s rows=%s' % (CALLS[0][0].get('cols'), CALLS[0][0].get('rows')))
r = act_webshell({'op': 'write', 'sid': 'x', 'data': 'A' * 70000})
chk('超长输入被拒绝', (not r['ok']) and r['code'] == 'TOO_LONG', '→ ' + r['msg_cn'])
CALLS[:] = []
r = act_webshell({'op': 'write', 'sid': 'x', 'data': 'ls\r'})
chk('正常输入放行', r['ok'] and CALLS[0][0]['data'] == 'ls\r')
# 真交互式 shell 不再做命令黑名单:子串匹配既拦不住也会误伤
# (表现为「终端突然打不出字」),所以这里反而要确认没有残留拦截。
chk('已移除危险命令黑名单', 'SHELL_BLOCK' not in HELPER_SRC)
# =========================================================================
# 2. shelld:UTF-8 分帧 / 会话编号 / op 分发
# =========================================================================
SNS = {}
exec(pick(SHELD_SRC, ['_utf8_tail']), SNS)
utf8_tail = SNS['_utf8_tail']
full = '中文abc'.encode('utf-8')
safe, tail = utf8_tail(full)
chk('完整 UTF-8 整段通过', safe == full and tail == b'', '→ tail=%r' % tail)
part = '中文'.encode('utf-8')[:4] # 「中」3 字节 + 「文」的第 1 字节
safe, tail = utf8_tail(part)
chk('半个汉字被留在缓冲区', safe == '中'.encode('utf-8') and tail == part[3:],
'→ safe=%r tail=%r' % (safe, tail))
safe, tail = utf8_tail(b'abc')
chk('纯 ASCII 不会被切', safe == b'abc' and tail == b'')
safe, tail = utf8_tail('中'.encode('utf-8'))
chk('单个汉字完整时通过', safe == '中'.encode('utf-8') and tail == b'')
# 会话编号:不能只用时间戳,秒内连两次会撞
chk('会话编号带自增序列', 'next(_seq)' in SHELD_SRC and 'itertools.count' in SHELD_SRC)
SNS2 = {'_SESSIONS': {}, 'MAX_SESSIONS': 8, 'log': fake_log, 'time': __import__('time'),
'os': os, '_lock': __import__('threading').Lock(), '_reap_idle': lambda: None,
'itertools': __import__('itertools')}
exec(pick(SHELD_SRC, ['handle']), SNS2)
handle = SNS2['handle']
r = handle({'op': 'read', 'sid': 'nope'})
chk('读不存在的会话返回 NOSESS', (not r['ok']) and r['code'] == 'NOSESS')
r = handle({'op': 'write', 'sid': 'nope', 'data': 'x'})
chk('写不存在的会话返回 NOSESS', (not r['ok']) and r['code'] == 'NOSESS')
r = handle({'op': 'resize', 'sid': 'nope'})
chk('改尺寸不存在的会话返回 NOSESS', (not r['ok']) and r['code'] == 'NOSESS')
r = handle({'op': 'whatever'})
chk('未知操作返回 BAD_OP', (not r['ok']) and r['code'] == 'BAD_OP')
r = handle({'op': 'sessions'})
chk('sessions 返回列表', r['ok'] and isinstance(r['data'], list))
# 满额保护:8 个假会话后再 connect 必须被拒(否则小内存机器上能开出一片 shell)
class _Fake(object):
created = 0.0
last = 0.0
alive = True
SNS2['_SESSIONS'] = {('sh%d' % i): _Fake() for i in range(8)}
r = handle({'op': 'connect', 'user': 'root'})
chk('会话数达上限被拒绝', (not r['ok']) and r['code'] == 'BUSY', '→ ' + r['msg_cn'])
# =========================================================================
# 3. web.py:超时分级
# =========================================================================
# read 刻意用较长超时:它走长轮询,服务端在无输出时会挂起最多 5 秒等数据,
# 客户端超时必须大于这个值,否则每次都会提前断开、前端退化成空转轮询。
chk('write/resize 走短超时分支', "sub in ('read', 'write', 'resize')" in WEB_SRC
and re.search(r"tmo\s*=\s*20\s+if\s+sub\s*==\s*'read'\s+else\s+15", WEB_SRC) is not None)
chk('read 用长轮询超时(>服务端 5s 挂起)',
re.search(r"tmo\s*=\s*20\s+if\s+sub\s*==\s*'read'", WEB_SRC) is not None
and 'timeout=tmo' in WEB_SRC)
chk('connect 用较长超时', 'timeout=40' in WEB_SRC)
chk('终端接口要求登录', WEB_SRC.count("if not self.auth()") > 0)
# =========================================================================
# 4. 前端:真终端的关键契约
# =========================================================================
chk('前端不再有单条命令输入框', 'ws-cmd' not in APP_JS and 'wsTermWrite' not in APP_JS)
chk('前端不再保留 SSH 主机/端口/密码表单',
'ws-host' not in APP_JS and 'ws-port' not in APP_JS and 'ws-pass' not in APP_JS)
for fn in ('wsFeed', 'wsCsi', 'wsSgr', 'wsRenderScreen', 'wsKeyDown', 'wsPump',
'wsMeasure', 'wsResizeGrid', 'wsScrollbackPush'):
chk('前端定义了 %s' % fn, ('function %s(' % fn) in APP_JS)
for cls in ('.tl{', '.tcur{', '.ws-capture{'):
chk('样式含 %s' % cls.rstrip('{'), cls in APP_CSS)
chk('终端区禁止自动折行(否则行列对不上)', 'white-space:pre;word-break:normal' in APP_CSS)
chk('Enter 映射为回车', "Enter: '\\r'" in APP_JS)
chk('Backspace 映射为 DEL', "Backspace: '\\x7f'" in APP_JS)
chk('Ctrl+C 走控制码', "k.charCodeAt(0) - 96" in APP_JS)
chk('有选中文字时 Ctrl+C 不中断命令', "k === 'c' && wsSelection()" in APP_JS)
chk('粘贴不会被当成控制码吞掉', "k === 'v') return;" in APP_JS)
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
sys.exit(1 if fails else 0)