Files
drouter/backend/drouter-shelld.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

430 lines
15 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 -*-
"""
drouter-shelld —— Web 终端的 PTY 会话守护(#4 重做版)
为什么必须有这个守护:
Web 后端每次调 helper 都是「新起一个 sudo python3 进程,跑完就退出」,
PTY 会话是内核里的文件描述符,进程一退出 fd 就没了。所以终端必须由一个
常驻进程持有,helper 只当转发客户端。
之前的实现是「一条命令 + subprocess.run + 回传输出」,于是:
* 输入不是实时的(要敲完回车再整条发);
* 没有回显(靠前端自己画 "$ cmd",看着像有、其实不是终端给的);
* 交互式程序(top / vi / python / 密码提示)根本没法用。
改成真 PTY 后,回显、Ctrl+C、方向键历史、全屏程序都由内核终端行规程负责,
前端只负责把字节流显示出来。
安全边界:
* Unix socket 位于 /run/drouter/shell.sock,root 拥有、权限 0600,
只有 root(即被 sudoers 放行的 helper)能连;低权 drouter 用户连不上。
* 会话空闲超过 IDLE_TIMEOUT 自动回收,避免忘记断开留下 root shell。
* 每次连接 / 断开都写结构化日志,便于事后审计。
"""
import errno
import fcntl
import itertools
import json
import os
import pty
import select
import signal
import socketserver
import struct
import subprocess
import sys
import termios
import threading
import time
from datetime import datetime
_SBIN_DIRS = ('/usr/local/sbin', '/usr/sbin', '/sbin')
def _ensure_sbin_path():
cur = os.environ.get('PATH') or ''
parts = [p for p in cur.split(os.pathsep) if p]
for d in _SBIN_DIRS:
if d not in parts and os.path.isdir(d):
parts.insert(0, d)
os.environ['PATH'] = os.pathsep.join(parts)
_ensure_sbin_path()
SOCK_PATH = '/run/drouter/shell.sock'
LOG_DIR = '/var/log/drouter'
IDLE_TIMEOUT = 30 * 60 # 30 分钟无操作自动回收
MAX_SESSIONS = 8 # 4GB 小机器上别开一堆 shell
READ_CHUNK = 65536
# 单次 read 最多回传多少字节:多了前端渲染会卡,少了要跑很多轮
MAX_READ = 262144
# 长轮询:前端带 wait 时,没有数据最多在这里挂起多久 / 每次探一下的间隔
READ_HOLD_MAX = 5.0
READ_HOLD_STEP = 0.02
_lock = threading.Lock()
_SESSIONS = {}
# 会话编号的自增序列。不能只用时间戳:同一秒内连两次会生成相同 sid,
# 后一个会话把前一个顶掉,表现为「刚开的终端突然不动了」。
_seq = itertools.count(1)
def log(level, code, msg_cn, detail=None):
try:
os.makedirs(LOG_DIR, exist_ok=True)
rec = {'ts': datetime.now().isoformat(timespec='seconds'),
'level': level, 'module': 'shelld', 'code': code,
'msg_cn': msg_cn, 'detail': detail or ''}
line = json.dumps(rec, ensure_ascii=False)
for fn in ('all.jsonl', 'shelld.jsonl'):
with open(os.path.join(LOG_DIR, fn), 'a', encoding='utf-8') as f:
f.write(line + '\n')
except Exception:
pass
class Session(object):
"""一个 PTY 会话:master fd + 子进程 + 输出缓冲 + 读线程。"""
def __init__(self, sid, user, shell, cols, rows, cwd):
self.sid = sid
self.user = user
self.created = time.time()
self.last = time.time()
self.alive = True
self.buf = bytearray()
self.buf_lock = threading.Lock()
self.master, slave = pty.openpty()
_set_winsize(self.master, rows, cols)
env = dict(os.environ)
env['TERM'] = 'xterm-256color'
env['COLUMNS'] = str(cols)
env['LINES'] = str(rows)
# 关掉 bash 的“多行命令用临时文件编辑”提示,避免 readline 在
# 非标准终端下把界面搞乱
env['BASH_SILENCE_DEPRECATION_WARNING'] = '1'
# start_new_session=True 让子进程成为会话首进程;
# 打开 slave 端时内核会把这个 pty 设为它的控制终端,
# 这样 Ctrl+C 才会真的向前台进程组发 SIGINT。
self.proc = subprocess.Popen(
[shell, '-i'], stdin=slave, stdout=slave, stderr=slave,
start_new_session=True, cwd=cwd or '/root', env=env,
preexec_fn=None, close_fds=True)
os.close(slave)
self.th = threading.Thread(target=self._reader, daemon=True)
self.th.start()
def _reader(self):
"""把 PTY 输出搬进缓冲区。子进程退出后 EIO 会跳出循环。"""
while True:
try:
r, _, _ = select.select([self.master], [], [], 0.5)
if not r:
if self.proc.poll() is not None:
break
continue
data = os.read(self.master, READ_CHUNK)
except OSError as e:
# EIO = 子进程退出后 slave 全关;这是正常结束,不是错误
if e.errno in (errno.EIO,):
break
if e.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
continue
break
except Exception:
break
if not data:
break
with self.buf_lock:
self.buf.extend(data)
self.alive = False
def drain(self, limit=MAX_READ):
with self.buf_lock:
if not self.buf:
return b''
raw = bytes(self.buf[:limit])
# 结尾可能正好切在半个汉字上(UTF-8 一个汉字 3 字节,分帧时会被拆开)。
# 直接解码会得到 U+FFFD(黑菱形问号),中文输出里出现一次就很扎眼。
# 所以把结尾不完整的那段留在缓冲区里,等下一帧凑齐再发。
safe, _tail = _utf8_tail(raw)
if not safe:
return b''
del self.buf[:len(safe)]
return safe
def write(self, data):
if not self.alive and self.proc.poll() is not None:
return False
try:
os.write(self.master, data)
return True
except OSError:
return False
def resize(self, cols, rows):
if self.master is None:
return False
_set_winsize(self.master, rows, cols)
try:
os.kill(self.proc.pid, signal.SIGWINCH)
except Exception:
pass
return True
def close(self):
try:
if self.proc.poll() is None:
# 先给子进程组发 SIGHUP,让它正常退出(bash 会写 history)
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGHUP)
except Exception:
self.proc.terminate()
for _ in range(30):
if self.proc.poll() is not None:
break
time.sleep(0.1)
if self.proc.poll() is None:
self.proc.kill()
except Exception:
pass
try:
os.close(self.master)
except Exception:
pass
self.alive = False
def _utf8_tail(data):
"""把 data 拆成(可安全解码的部分, 结尾处不完整的 UTF-8 序列)。
从末尾往前找起始字节,判断它声明的长度够不够;不够就说明这一帧被切开了。
"""
n = len(data)
for k in range(1, min(4, n) + 1):
i = n - k
b = data[i]
if b < 0x80:
break # 结尾是 ASCII,整段可解
if 0xC0 <= b <= 0xDF:
need = 2
elif 0xE0 <= b <= 0xEF:
need = 3
elif 0xF0 <= b <= 0xF7:
need = 4
else:
continue # 续字节,继续往前找起始字节
if k < need:
return data[:i], data[i:] # 起始字节在,后面不完整
return data, b'' # 序列完整,整段可解
return data, b''
def _set_winsize(fd, rows, cols):
try:
fcntl.ioctl(fd, termios.TIOCSWINSZ,
struct.pack('HHHH', rows or 24, cols or 80, 0, 0))
except Exception:
pass
def _reap_idle():
"""回收超时会话;顺带清掉已退出的僵尸子进程。"""
now = time.time()
with _lock:
for sid, s in list(_SESSIONS.items()):
if now - s.last > IDLE_TIMEOUT:
log('info', 'SHELL_IDLE', '终端会话闲置超时,已自动回收:%s' % sid)
s.close()
del _SESSIONS[sid]
elif s.proc.poll() is not None and not s.alive:
# 用户敲了 exit:保留一小段时间让前端把最后的输出读完
if now - s.last > 60:
s.close()
del _SESSIONS[sid]
def _pick_shell(user):
"""挑一个存在的登录 shell,缺 bash 时退回 sh。"""
for s in ('/bin/bash', '/bin/sh'):
if os.path.isfile(s):
return s
return '/bin/sh'
def handle(req):
op = str(req.get('op') or '')
sid = req.get('sid') or ''
if op == 'connect':
with _lock:
if len(_SESSIONS) >= MAX_SESSIONS:
_reap_idle()
if len(_SESSIONS) >= MAX_SESSIONS:
return {'ok': False, 'code': 'BUSY',
'msg_cn': '终端会话已达上限(%d),请先断开不用的会话' % MAX_SESSIONS}
user = str(req.get('user') or 'root')
shell = _pick_shell(user)
cols = int(req.get('cols') or 100)
rows = int(req.get('rows') or 30)
cols = max(20, min(cols, 400))
rows = max(5, min(rows, 200))
sid = 'sh%d-%d' % (int(time.time()), next(_seq))
try:
s = Session(sid, user, shell, cols, rows, req.get('cwd') or '/root')
except Exception as e:
return {'ok': False, 'code': 'PTY_FAIL',
'msg_cn': '创建终端失败:%s' % e}
with _lock:
_SESSIONS[sid] = s
log('warn', 'SHELL_OPEN', 'Web 终端会话已建立(用户 %s)' % user,
{'sid': sid, 'shell': shell, 'cols': cols, 'rows': rows})
return {'ok': True, 'msg_cn': '终端已连接',
'data': {'sid': sid, 'shell': shell, 'cols': cols, 'rows': rows}}
if op == 'read':
with _lock:
s = _SESSIONS.get(sid)
if not s:
return {'ok': False, 'code': 'NOSESS', 'msg_cn': '会话不存在或已过期'}
s.last = time.time()
data = s.drain()
# 长轮询:前端可以带 wait(秒),没数据时在这里等到有数据或超时再返回。
# 以前前端是固定 60ms 空转轮询,一半的请求都空手而归,而且输出最快也要
# 等下一个轮询点才看得到;长轮询让「命令一有输出」立刻就有响应。
wait = 0.0
try:
wait = float(req.get('wait') or 0)
except Exception:
wait = 0.0
wait = max(0.0, min(wait, READ_HOLD_MAX))
if not data and wait > 0:
deadline = time.time() + wait
while time.time() < deadline:
time.sleep(READ_HOLD_STEP)
data = s.drain()
if data:
break
if s.proc.poll() is not None and not s.alive:
break
exited = s.proc.poll()
# latin-1 逐字节还原,保证 ANSI 控制序列不被 UTF-8 解码破坏;
# 真正显示成文字由前端按 UTF-8 再解码(多字节字符会跨帧,前端用拼接处理)
return {'ok': True, 'data': {
'data': data.decode('utf-8', errors='replace'),
'alive': s.alive or exited is None,
'exit': exited}}
if op == 'write':
with _lock:
s = _SESSIONS.get(sid)
if not s:
return {'ok': False, 'code': 'NOSESS', 'msg_cn': '会话不存在或已过期'}
s.last = time.time()
raw = req.get('data') or ''
if isinstance(raw, str):
raw = raw.encode('utf-8', errors='replace')
okw = s.write(raw)
return {'ok': okw, 'msg_cn': '' if okw else '写入失败(终端可能已退出)'}
if op == 'resize':
with _lock:
s = _SESSIONS.get(sid)
if not s:
return {'ok': False, 'code': 'NOSESS', 'msg_cn': '会话不存在或已过期'}
s.last = time.time()
cols = max(20, min(int(req.get('cols') or 100), 400))
rows = max(5, min(int(req.get('rows') or 30), 200))
s.resize(cols, rows)
return {'ok': True}
if op == 'disconnect':
with _lock:
s = _SESSIONS.pop(sid, None)
if s:
s.close()
log('info', 'SHELL_CLOSE', 'Web 终端会话已关闭', {'sid': sid})
return {'ok': True, 'msg_cn': '已断开'}
if op == 'sessions':
with _lock:
return {'ok': True, 'data': [
{'sid': k, 'age': int(time.time() - v.created),
'idle': int(time.time() - v.last),
'alive': v.alive}
for k, v in _SESSIONS.items()]}
return {'ok': False, 'code': 'BAD_OP', 'msg_cn': '未知操作:%s' % op}
class Handler(socketserver.StreamRequestHandler):
timeout = 30
def handle(self):
try:
line = self.rfile.readline()
if not line:
return
req = json.loads(line.decode('utf-8'))
except Exception:
return
try:
resp = handle(req)
except Exception as e:
resp = {'ok': False, 'code': 'ERR', 'msg_cn': '终端守护内部错误:%s' % e}
try:
self.wfile.write((json.dumps(resp, ensure_ascii=False) + '\n')
.encode('utf-8'))
self.wfile.flush()
except Exception:
pass
class Server(socketserver.ThreadingUnixStreamServer):
daemon_threads = True
allow_reuse_address = True
def main():
if os.path.exists(SOCK_PATH):
os.unlink(SOCK_PATH)
d = os.path.dirname(SOCK_PATH)
os.makedirs(d, exist_ok=True)
# 目录必须 0755:/run/drouter 是 shelld 与 helpd 共用的,
# drouter-web(以 drouter 用户运行)要能进这个目录去连 helper.sock。
# 之前靠 systemd 的 RuntimeDirectory 建目录,结果 helpd 一重启 systemd 就把
# 整个目录重建,把这里的 shell.sock 连带删掉 —— shelld 显示 active,
# socket 却没了,终端直接报「守护未运行」。所以改成守护自己建。
try:
os.chmod(d, 0o755)
except Exception:
pass
os.umask(0o177) # 保证 socket 创建出来就是 0600
srv = Server(SOCK_PATH, Handler)
os.chmod(SOCK_PATH, 0o600)
log('info', 'SHELLD_UP', 'Web 终端守护已启动:%s' % SOCK_PATH)
try:
srv.serve_forever(poll_interval=0.5)
except KeyboardInterrupt:
pass
finally:
with _lock:
for s in _SESSIONS.values():
s.close()
_SESSIONS.clear()
try:
os.unlink(SOCK_PATH)
except OSError:
pass
return 0
if __name__ == '__main__':
sys.exit(main())