一套跑在 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
137 lines
5.0 KiB
Python
137 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""Web 终端(真 PTY)真机验收 —— 必须在目标机上跑(BASE 是 127.0.0.1:8443)。
|
||
|
||
验收点:
|
||
1. 能连上终端守护(后台已 enable --now);
|
||
2. 敲 echo 立刻有回显(说明是真 PTY 而不是「发整条命令等结果」);
|
||
3. 中文不被分帧切坏;
|
||
4. 交互式程序能起来(printf 之后 shell 仍在,能继续收命令);
|
||
5. 断开后会话真的消失。
|
||
"""
|
||
import json
|
||
import ssl
|
||
import sys
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
BASE = 'https://127.0.0.1:8443'
|
||
CTX = ssl.create_default_context()
|
||
CTX.check_hostname = False
|
||
CTX.verify_mode = ssl.CERT_NONE
|
||
|
||
fails = 0
|
||
|
||
|
||
def chk(label, cond, extra=''):
|
||
global fails
|
||
if not cond:
|
||
fails += 1
|
||
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
|
||
|
||
|
||
def call(path, body=None, token=None, method=None):
|
||
data = None
|
||
headers = {}
|
||
if body is not None:
|
||
data = json.dumps(body).encode('utf-8')
|
||
headers['Content-Type'] = 'application/json'
|
||
method = method or 'POST'
|
||
if token:
|
||
headers['X-Token'] = token
|
||
req = urllib.request.Request(BASE + path, data=data, headers=headers,
|
||
method=method or 'GET')
|
||
try:
|
||
with urllib.request.urlopen(req, context=CTX, timeout=45) as r:
|
||
return json.loads(r.read().decode('utf-8'))
|
||
except urllib.error.HTTPError as e:
|
||
# 会话不存在时服务端返回 400 + JSON;这里要把响应体读出来,
|
||
# 否则只看状态码没法判断是不是预期的 NOSESS。
|
||
try:
|
||
return json.loads(e.read().decode('utf-8'))
|
||
except Exception:
|
||
return {'ok': False, 'code': 'HTTP%s' % e.code, 'msg_cn': str(e)}
|
||
|
||
|
||
def main():
|
||
r = call('/api/login', {'username': 'admin', 'password': 'admin123'})
|
||
chk('登录成功', r.get('ok'), r.get('msg_cn'))
|
||
if not r.get('ok'):
|
||
return 1
|
||
tk = r['data']['token']
|
||
|
||
r = call('/api/webshell/connect', {'user': 'root', 'cols': 100, 'rows': 30}, tk)
|
||
chk('终端连接成功', r.get('ok'), r.get('msg_cn') or r.get('code'))
|
||
if not r.get('ok'):
|
||
return 1
|
||
sid = r['data']['sid']
|
||
print(' sid = %s' % sid)
|
||
|
||
def drain(rounds=12, wait=0.35):
|
||
out = ''
|
||
for _ in range(rounds):
|
||
rr = call('/api/webshell/read', {'sid': sid}, tk)
|
||
if not rr.get('ok'):
|
||
break
|
||
out += (rr.get('data') or {}).get('data') or ''
|
||
if out:
|
||
break
|
||
time.sleep(wait)
|
||
return out
|
||
|
||
# 先读掉登录时的提示符 / motd
|
||
drain(rounds=3, wait=0.2)
|
||
|
||
# ---- 1. 单条命令有回显 ----
|
||
call('/api/webshell/write', {'sid': sid, 'data': 'echo HELLO_PTY_1\r'}, tk)
|
||
out = drain()
|
||
chk('echo 命令有输出', 'HELLO_PTY_1' in out, '→ %r' % out[:160])
|
||
|
||
# ---- 2. 中文不被切坏 ----
|
||
call('/api/webshell/write', {'sid': sid, 'data': 'echo 中文回显测试\r'}, tk)
|
||
out = drain()
|
||
chk('中文正常回显(无 U+FFFD)',
|
||
'中文回显测试' in out and '\ufffd' not in out, '→ %r' % out[:160])
|
||
|
||
# ---- 3. 会话仍活着(交互式:shell 没有随命令结束而退出)----
|
||
rr = call('/api/webshell/read', {'sid': sid}, tk)
|
||
alive = (rr.get('data') or {}).get('alive')
|
||
chk('会话在命令结束后仍存活', alive is True, 'alive=%s' % alive)
|
||
|
||
# ---- 4. 第二条命令仍可执行(证明是持续会话,不是一次性 exec)----
|
||
call('/api/webshell/write', {'sid': sid, 'data': 'echo SECOND_CMD_OK\r'}, tk)
|
||
out = drain()
|
||
chk('同一会话能继续执行第二条命令', 'SECOND_CMD_OK' in out, '→ %r' % out[:160])
|
||
|
||
# ---- 5. 交互式程序(top -b 一帧 + Ctrl+C 中断)----
|
||
call('/api/webshell/write', {'sid': sid, 'data': 'sleep 30\r'}, tk)
|
||
time.sleep(0.6)
|
||
call('/api/webshell/write', {'sid': sid, 'data': '\x03'}, tk) # Ctrl+C
|
||
time.sleep(0.6)
|
||
out = drain(rounds=4, wait=0.3)
|
||
chk('Ctrl+C 能中断前台命令', 'sleep 30' in out or out == '', '→ %r' % out[:120])
|
||
rr = call('/api/webshell/read', {'sid': sid}, tk)
|
||
chk('中断后 shell 仍在(没被打死)', (rr.get('data') or {}).get('alive') is True)
|
||
|
||
# ---- 6. 会话列表里能看到它 ----
|
||
rr = call('/api/webshell/sessions', None, tk, method='GET')
|
||
lst = rr.get('data') if rr.get('ok') else []
|
||
chk('会话列表包含当前会话',
|
||
any(isinstance(x, dict) and x.get('sid') == sid for x in (lst or [])) or
|
||
any(x == sid for x in (lst or [])), '→ %s' % json.dumps(lst)[:160])
|
||
|
||
# ---- 7. 断开 ----
|
||
rr = call('/api/webshell/disconnect', {'sid': sid}, tk)
|
||
chk('断开成功', rr.get('ok'), rr.get('msg_cn'))
|
||
rr = call('/api/webshell/read', {'sid': sid}, tk)
|
||
chk('断开后会话确实不存在', (not rr.get('ok')) or rr.get('code') == 'NOSESS',
|
||
'→ %s' % rr.get('code'))
|
||
|
||
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
|
||
return 1 if fails else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|