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

137 lines
5.0 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 -*-
"""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())