一套跑在 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
430 lines
15 KiB
Python
430 lines
15 KiB
Python
#!/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())
|