Files
drouter/backend/drouter-shelld.py
liuzhuohua f2c7fc7ae8 fix(shelld): 恢复被覆盖掉的 MAX_SESSIONS 定义(真机 NameError 崩溃)
1.0.5 插入 MAX_BUF 说明块时把 "MAX_SESSIONS = 8" 整行顶掉,
systemd 一拉 shelld 就 NameError 退出、auto-restart 死循环 ——
AST 抽片段的测试注入了自己的常量,抓不到模块顶层顺序问题。

- 恢复 MAX_SESSIONS = 8(_SESS_SEM 之前)
- t-fefix 新增第 12 节:全部守护模块顶层「先用后定义」静态扫描
  (按语句顺序模拟执行,不进入函数体,剔除推导式变量)
2026-10-02 11:35:42 +08:00

631 lines
24 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 -*-
"""
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
# 单个会话的输出缓冲上限。
# 过去完全无上限:在终端里跑 `yes`、`journalctl -f`、或者任何持续打印的程序,
# 然后浏览器不读(或标签卡住),读线程会一直往 bytearray 里堆 ——
# 一个会话就能把 4 GB 内存吃干净,OOM 之后连面板一起挂。
MAX_BUF = 8 * 1024 * 1024
# 会话名额用信号量来**占**。过去的写法是「拿到锁看一眼没满 → 释放锁 → 慢慢创建
# PTY 和子进程 → 再拿锁登记」,中间的窗口里并发请求会各自通过检查,
# 结果实际会话数远超 MAX_SESSIONS(PTY、进程、fd 一起失控)。
_SESS_SEM = threading.BoundedSemaphore(MAX_SESSIONS)
READ_CHUNK = 65536
# 单次 read 最多回传多少字节:多了前端渲染会卡,少了要跑很多轮
MAX_READ = 262144
# 长轮询:前端带 wait 时,没有数据最多在这里挂起多久 / 每次探一下的间隔
READ_HOLD_MAX = 5.0
READ_HOLD_STEP = 0.02
# 必须是 RLock 而不是 Lock:connect 需要「检查是否满员 → 满了就回收 → 再检查」
# 这个序列,天然会在持锁时调用到内部也要拿锁的 _reap_idle()。
# 用普通 Lock 会把自己锁死(同线程二次 acquire 永久阻塞)—— 表现为
# 「连到第 9 个会话时永久卡住」,而不是干净地报「已达上限」。
_lock = threading.RLock()
_SESSIONS = {}
# 会话编号的自增序列。不能只用时间戳:同一秒内连两次会生成相同 sid,
# 后一个会话把前一个顶掉,表现为「刚开的终端突然不动了」。
_seq = itertools.count(1)
def ensure_ptmx():
"""确保 /dev/ptmx 可用;缺了就补回符号链接。
为什么需要这个:
pty.openpty() 走的是 /dev/ptmx。标准 Debian 上 /dev/ptmx 是指向
/dev/pts/ptmx 的符号链接,由 devtmpfs + udev 在开机时建立。
但在容器/虚拟化环境(LXC、某些 PVE 配置)里,/dev 可能被重新挂载,
这个符号链接会**丢失**。此时 /dev/pts/ptmx 仍然是好的,只是没有
那个入口 —— 表现就是 openpty() 抛 "out of pty devices",
而 sysctl kernel.pty.nr 显示 0(明明一个都没用),特别迷惑。
修法就是补一条符号链接。这里做成幂等 + 自愈,避免用户要 SSH 进去
手工处理(路由器出问题时恰恰进不去)。
返回 (ok, detail)。
"""
ptmx = '/dev/ptmx'
if os.path.exists(ptmx):
return True, 'ok'
try:
# /dev/pts/ptmx 存在就优先链它(devpts 实例里的那一份)
if os.path.exists('/dev/pts/ptmx'):
os.symlink('pts/ptmx', ptmx)
return True, 'symlink created'
# 退路:直接 mknod 一个 5,2(老式 devfs 行为,现代内核仍认)
os.mknod(ptmx, 0o666 | 0o020000, os.makedev(5, 2))
return True, 'device node created'
except OSError as e:
return False, str(e)
def _pty_diagnose():
"""PTY 建立失败时,给出一句人能看懂的原因,而不是原始 errno 天书。"""
diag = []
if not os.path.exists('/dev/ptmx'):
diag.append('/dev/ptmx 缺失')
try:
mount = open('/proc/mounts', encoding='utf-8').read()
if ' /dev/pts ' not in mount:
diag.append('/dev/pts 未挂载 devpts')
except Exception:
pass
try:
nr = open('/proc/sys/kernel/pty/nr').read().strip()
mx = open('/proc/sys/kernel/pty/max').read().strip()
if nr == mx:
diag.append('PTY 配额已满(%s/%s)' % (nr, mx))
except Exception:
pass
live = len(_SESSIONS)
if live >= MAX_SESSIONS:
diag.append('本服务会话已达上限(%d)' % live)
return ';'.join(diag) if diag else '未知原因'
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
def _open_pty():
"""申请一对 PTY。首次失败时先尝试自愈 /dev/ptmx,再重试一次。
pty.openpty() 抛的 OSError 文案就是一句 "out of pty devices",
对用户毫无指导意义 —— 明明一个 PTY 都没用,却说"用完了"。
根因八成是 /dev/ptmx 这个入口没了。先补入口再试,还不行就把
真实原因(见 _pty_diagnose)包进异常里往上抛。
"""
try:
return pty.openpty()
except OSError as first:
ok, detail = ensure_ptmx()
if ok:
try:
m, s = pty.openpty()
log('warn', 'PTY_HEAL', '/dev/ptmx 缺失,已自动补回后恢复',
{'detail': detail})
return m, s
except OSError:
pass
raise OSError('%s(诊断:%s)' % (first, _pty_diagnose()))
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_mono = time.monotonic()
self.alive = True
self.buf = bytearray()
self.buf_lock = threading.Lock()
self.dropped = 0 # 因缓冲上限被丢弃的字节数(宁丢oldest也不 OOM)
# 先占名额再建会话:占不到就是真的满了
if not _SESS_SEM.acquire(blocking=False):
raise RuntimeError('终端会话已达上限(%d),请先断开不用的会话'
% MAX_SESSIONS)
self._reserved = True
self.master, slave = _open_pty()
try:
_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)
except BaseException:
# 关键:Popen 失败(cwd 不存在、进程数超限、内存不足……)时,
# master/slave **两个** fd 都得关。早先这里直接抛出去,
# 每次失败泄漏两个 PTY,反复重试会把 /dev/pts 配额耗尽,
# 症状是「后来连 ssh 都开不了终端」。
try:
os.close(slave)
except Exception:
pass
try:
os.close(self.master)
except Exception:
pass
self._reserved = False
_SESS_SEM.release()
raise
os.close(slave)
self.th = threading.Thread(target=self._reader, daemon=True)
self.th.start()
@property
def last(self):
"""最后一次活动的**单调**时刻(秒)。
用单调时钟是因为 NTP 校时 / 机器休眠会把系统时间来回拨:
回拨时所有会话看起来「刚刚用过」永远不回收,前跳时又会一次性全过期。
"""
return self._last_mono
@last.setter
def last(self, _v):
# 老的调用点写的是 `s.last = time.time()`,这里统一换成单调时钟
self._last_mono = time.monotonic()
def _append(self, data):
"""把 PTY 输出放进缓冲,超过 MAX_BUF 就丢最旧的那一截。"""
with self.buf_lock:
over = len(self.buf) + len(data) - MAX_BUF
if over > 0:
del self.buf[:over]
self.dropped += over
self.buf.extend(data)
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
self._append(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
# 读线程由 PTY 关闭触发退出(EIO);join 一下是为了把 fd 复用窗口收窄,
# 顺手把会话名额还回去(否则被回收的会话会永久占着名额)。
try:
if getattr(self, 'th', None) is not None:
self.th.join(0.5)
except Exception:
pass
if getattr(self, '_reserved', False):
self._reserved = False
try:
_SESS_SEM.release()
except Exception:
pass
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():
"""回收超时会话;顺带清掉已退出的僵尸子进程。返回回收的会话数。
锁内只做「登记摘除」,真正的 close() 放到锁外做 ——
close() 要等子进程退出(SIGHUP 后最多轮询 3 秒),持锁做会把
所有并发请求堵死:表现为「9 个会话时新连接超时、老终端也卡住」。
"""
# 闲置/僵尸会话的回收判据必须用单调时钟(理由见 Session.last 的注释)
now = time.monotonic()
victims = []
with _lock:
for sid, s in list(_SESSIONS.items()):
if now - s.last > IDLE_TIMEOUT:
log('info', 'SHELL_IDLE', '终端会话闲置超时,已自动回收:%s' % sid)
del _SESSIONS[sid]
victims.append(s)
elif s.proc.poll() is not None and not s.alive:
# 用户敲了 exit:保留一小段时间让前端把最后的输出读完
if now - s.last > 60:
del _SESSIONS[sid]
victims.append(s)
for s in victims:
s.close()
return len(victims)
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':
# 满员时先回收一轮闲置会话再决定是否拒绝。
# 注意这里不能把 _reap_idle() 放在 with _lock 里面:它要等子进程
# 退出(最长 3 秒),持锁调用会把所有并发 read/write 一起堵死。
with _lock:
full = len(_SESSIONS) >= MAX_SESSIONS
if full:
_reap_idle()
with _lock:
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 RuntimeError as e:
# Session 构造里占不到名额会抛这个 —— 真正的并发上限在这里生效
log('warn', 'SHELL_BUSY', '终端会话已满:%s' % e, {'user': user})
return {'ok': False, 'code': 'BUSY', 'msg_cn': str(e)}
except Exception as e:
# 把日志也写一份 —— 用户看到界面报错时的同一个信息必须留在
# 服务端日志里,否则事后排查只能靠猜。
log('error', 'PTY_FAIL', '创建终端失败:%s' % e, {'user': user})
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.monotonic() - v.last), # last 是单调秒
'alive': v.alive, 'dropped': v.dropped}
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
# 启动即自愈 /dev/ptmx。这个守护的唯一职责就是开 PTY,如果入口都没有,
# 后面每次连接都会失败 —— 与其让用户点一次踩一次坑,不如开机就修好。
ok, detail = ensure_ptmx()
if not ok:
log('error', 'PTY_UNAVAILABLE',
'PTY 入口不可用,Web 终端将无法使用',
{'detail': detail, 'diag': _pty_diagnose()})
else:
if detail != 'ok':
log('warn', 'PTY_HEAL', '/dev/ptmx 缺失,启动时已自动补回', {'detail': detail})
srv = Server(SOCK_PATH, Handler)
os.chmod(SOCK_PATH, 0o600)
# 真正的周期回收线程。
# 过去 _reap_idle() 只在「新建会话且发现已满」时才被调用 —— 也就是说
# 只要没人连到第 9 个,那些突然断线(浏览器崩溃、手机切走)留下的
# root shell 就会一直挂着,连同 master fd、Session 对象和若干 bytearray。
def _reaper_loop():
while True:
time.sleep(60)
try:
n = _reap_idle()
if n:
log('info', 'SHELL_REAP', '后台回收了 %d 个闲置终端会话' % n)
except Exception:
pass
threading.Thread(target=_reaper_loop, daemon=True).start()
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())