diff --git a/.gitignore b/.gitignore index 47b3568..a970865 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ desktop.ini # 截图画廊的本地预览页(一次性产物,别进库) _preview-shots.html +_preview-*.html # ── 凭据(绝不能进库)────────────────────────────────── *.pem diff --git a/_dev/t-pty.py b/_dev/t-pty.py new file mode 100644 index 0000000..91846a9 --- /dev/null +++ b/_dev/t-pty.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Web 终端 PTY 可用性回归测试。 + +背景(真实故障): + 用户点 Web 终端,任何账号都报「创建终端失败:out of pty devices」。 + sysctl kernel.pty.nr 显示 0 —— 一个 PTY 都没用,却说"用完了"。 + 根因是 /dev/ptmx 这个入口符号链接丢失(容器/虚拟化环境里 /dev 被 + 重新挂载时会丢),而 /dev/pts/ptmx 本身是好的。 + +这里钉住三件事: + 1. shelld 启动时会自愈 /dev/ptmx(ensure_ptmx 幂等) + 2. _open_pty 首次失败会先修入口再重试,不是直接抛错 + 3. 失败时的诊断信息包含「/dev/ptmx」这种能定位问题的关键词, + 而不是只有一句 errno 天书 + +纯逻辑测试:把源码里的函数抽出来在隔离命名空间跑,用替身模拟 +「ptmx 缺失 / 可用」两种情况,不碰真实 /dev。 +""" +import ast +import os +import sys + +SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "backend", "drouter-shelld.py") + +_ck = [0, 0] + + +def ck(name, cond, extra=''): + _ck[1] += 1 + if cond: + _ck[0] += 1 + print(" [OK] %s" % name) + else: + print(" [!!] %s %s" % (name, extra)) + + +def load_src(): + with open(SRC, encoding='utf-8') as f: + return f.read() + + +def extract_funcs(src, names): + """从模块源码里抽出指定函数,编译进一个干净命名空间。 + + 只抽 FunctionDef —— 不抽模块级赋值。模块级的东西(SOCK_PATH、 + _SESSIONS、_lock…)在替身环境里没有意义,而且抽进来会覆盖我们 + 注入的测试替身,导致读到真实路径。 + """ + tree = ast.parse(src) + ns = {} + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name in names: + mod = ast.Module(body=[node], type_ignores=[]) + exec(compile(mod, SRC, 'exec'), ns) + return ns + + +def main(): + src = load_src() + + print("一、源码必须定义了自愈与诊断函数") + for fn in ('ensure_ptmx', '_pty_diagnose', '_open_pty'): + ck("定义了 %s()" % fn, ('def %s(' % fn) in src) + # Session 必须经由 _open_pty 申请 PTY,而不是直接 pty.openpty() —— + # 直连的话就没有自愈和诊断,故障会原样抛给用户 + sess_src = src.split('class Session')[1].split('\ndef ')[0] if 'class Session' in src else '' + ck("Session 不再直接调 pty.openpty()", 'pty.openpty()' not in sess_src) + ck("Session 经由 _open_pty() 申请", '_open_pty()' in sess_src) + + print("\n二、ensure_ptmx 的三种分支") + ns = extract_funcs(src, ['ensure_ptmx']) + ensure_ptmx = ns['ensure_ptmx'] + + # 分支 A:入口已存在 → 直接返回 ok,不做任何动作 + calls = [] + + class FakeOs: + path = type('p', (), {'exists': staticmethod(lambda p: True)})() + + @staticmethod + def symlink(a, b): + calls.append(('symlink', a, b)) + + @staticmethod + def mknod(*a): + calls.append(('mknod', a)) + + real_os = ns['ensure_ptmx'].__globals__.get('os') + ns['ensure_ptmx'].__globals__['os'] = FakeOs + ok, detail = ensure_ptmx() + ck("入口存在时返回 (True,'ok')", (ok, detail) == (True, 'ok'), str((ok, detail))) + ck("入口存在时不创建任何东西", calls == [], str(calls)) + + # 分支 B:入口缺失但 /dev/pts/ptmx 在 → 补符号链接 + calls = [] + + class FakeOs2: + path = type('p', (), {'exists': staticmethod( + lambda p: p == '/dev/pts/ptmx')})() + + @staticmethod + def makedev(a, b): + return 0 + + @staticmethod + def symlink(a, b): + calls.append((a, b)) + return None + + @staticmethod + def mknod(*a): + calls.append(('mknod', a)) + + ns['ensure_ptmx'].__globals__['os'] = FakeOs2 + ok, detail = ensure_ptmx() + ck("缺失时返回 True", ok is True, str((ok, detail))) + ck("缺失时建立 pts/ptmx 符号链接", + calls == [('pts/ptmx', '/dev/ptmx')], str(calls)) + + # 分支 C:什么都建不了 → 返回 False + 原因(不抛异常) + class FakeOs3: + path = type('p', (), {'exists': staticmethod(lambda p: False)})() + + @staticmethod + def makedev(a, b): + return 0 + + @staticmethod + def symlink(a, b): + raise OSError(30, 'Read-only file system') + + @staticmethod + def mknod(*a): + raise OSError(30, 'Read-only file system') + + ns['ensure_ptmx'].__globals__['os'] = FakeOs3 + ok, detail = ensure_ptmx() + ck("无力修复时返回 False", ok is False, str((ok, detail))) + ck("返回的 detail 非空(便于日志)", bool(detail), repr(detail)) + + print("\n三、_open_pty 失败时必须先自愈再重试") + nsf = extract_funcs(src, ['_open_pty']) + _open_pty = nsf['_open_pty'] + g = _open_pty.__globals__ + + seq = {'n': 0} + healed = {'v': False} + + class FakePty: + @staticmethod + def openpty(): + seq['n'] += 1 + if seq['n'] == 1: + raise OSError(5, 'out of pty devices') + return (7, 8) # 第二次成功 + + def fake_ensure(): + healed['v'] = True + return True, 'symlink created' + + g['pty'] = FakePty + g['ensure_ptmx'] = fake_ensure + g['log'] = lambda *a, **k: None + g['_pty_diagnose'] = lambda: '替身' + + r = _open_pty() + ck("首败后自愈并重试成功", r == (7, 8), str(r)) + ck("重试前确实调用了 self-heal", healed['v'] is True) + ck("openpty 被调用了两次", seq['n'] == 2, str(seq['n'])) + + print("\n四、彻底失败时的诊断信息要能定位问题") + nsf2 = extract_funcs(src, ['_open_pty']) + _open_pty2 = nsf2['_open_pty'] + g2 = _open_pty2.__globals__ + + class AlwaysFailPty: + @staticmethod + def openpty(): + raise OSError(5, 'out of pty devices') + + g2['pty'] = AlwaysFailPty + g2['ensure_ptmx'] = lambda: (False, 'Read-only file system') + g2['log'] = lambda *a, **k: None + # 诊断函数:模拟 /dev/ptmx 缺失的情形 + g2['_pty_diagnose'] = lambda: '/dev/ptmx 缺失' + + try: + _open_pty2() + ck("无救时抛异常", False, '竟然没抛') + except OSError as e: + msg = str(e) + ck("异常里保留了原始 errno 文案", 'out of pty devices' in msg, msg) + ck("异常里带上了可定位的诊断", '/dev/ptmx' in msg, msg) + + print("\n五、诊断函数覆盖关键检查点") + nsd = extract_funcs(src, ['_pty_diagnose']) + dsrc = src.split('def _pty_diagnose')[1].split('\ndef ')[0] + for kw in ('/dev/ptmx', '/dev/pts', 'pty/nr', 'pty/max'): + ck("诊断检查了 %s" % kw, kw in dsrc) + + print("\n六、启动路径也会自愈(不是只在连接时)") + main_src = src.split('def main()')[1] if 'def main()' in src else '' + ck("main() 里调用了 ensure_ptmx", 'ensure_ptmx()' in main_src) + ck("main() 里对失败情况写日志", 'PTY_UNAVAILABLE' in main_src) + ck("自愈成功也写日志(便于追查)", 'PTY_HEAL' in main_src) + + print("\n七、前端给了可照抄的修复命令") + app = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'web', 'app.js') + ajs = open(app, encoding='utf-8').read() + ck("前端识别 PTY_FAIL", "'PTY_FAIL'" in ajs or 'PTY_FAIL' in ajs) + ck("前端提示 out of pty devices 的含义", 'out of pty devices' in ajs) + ck("前端给出修复命令", 'ln -sf pts/ptmx /dev/ptmx' in ajs) + ck("前端给出重启命令", 'systemctl restart drouter-shelld' in ajs) + + print("\n八、会话回收不得自锁(满员时整个终端服务会冻死)") + # 背景:connect 需要「看是否满员 → 满了就回收 → 再看一次」这个序列, + # 天然而必然地会在持锁时调到内部也要拿锁的 _reap_idle()。 + # 用普通 Lock 会把自己永久锁死(同线程二次 acquire),表现为 + # 「连到第 9 个会话时彻底卡住」——而且因为锁一直不放, + # 其他会话的 read/write 也一起堵住,整个 Web 终端报废。 + ck("_lock 用 RLock(可重入)", '_lock = threading.RLock()' in src) + # 只查模块级 _lock 的赋值。不能写成 `'_lock = threading.Lock()' not in src` —— + # Session.__init__ 里的 `self.buf_lock = threading.Lock()` 尾部正好命中 + # 这个子串,会误报。那个是每会话独立的输出缓冲锁,非重入是对的。 + ck("模块级 _lock 不是普通 Lock", + not __import__('re').search(r'(?m)^_lock\s*=\s*threading\.Lock\(\)', src)) + + def blocked_span(lines, i): + """返回从 lines[i](一个 `xxx:` 块头)开始、该缩进块结束的下标。""" + ind = len(lines[i]) - len(lines[i].lstrip()) + j = i + 1 + while j < len(lines): + s = lines[j] + if not s.strip(): + j += 1 + continue + if (len(s) - len(s.lstrip())) <= ind: + break + j += 1 + return j + + def calls_inside_lock(text, needle): + """text 里是否存在「在被 with _lock: 包住的缩进块内」调用 needle。""" + lines = text.splitlines() + for i, l in enumerate(lines): + if l.strip() == 'with _lock:': + end = blocked_span(lines, i) + if needle in "\n".join(lines[i:end]): + return True + return False + + reap_src = src.split('def _reap_idle')[1].split('\ndef ')[0] + ck("_reap_idle 不同时持锁调 close()(close 最长等子进程 3 秒)", + not calls_inside_lock(reap_src, '.close()')) + + conn_src = src.split("if op == 'connect':")[1].split("if op == 'read':")[0] + ck("connect 不持锁调 _reap_idle()", not calls_inside_lock(conn_src, '_reap_idle()')) + ck("connect 仍会在满员时尝试回收", '_reap_idle()' in conn_src, + "回收被删掉就退化成「每次都直接拒绝」") + + # 行为验证:真的丢一个闲置会话进去,看它是否被摘除并 close + print("\n九、_reap_idle() 行为正确(摘除 + 关闭 + 返回条数)") + ns = {'threading': __import__('threading'), 'time': __import__('time'), + 'IDLE_TIMEOUT': 60, 'MAX_SESSIONS': 8, 'log': lambda *a, **k: None} + live = {} + ns['_SESSIONS'] = live + ns['_lock'] = __import__('threading').RLock() + # 注意 reap_src 是 "def _reap_idle" 之后的部分,拼回去才能编译 + exec(compile('def _reap_idle' + reap_src, '', 'exec'), ns) + + class _P(object): + def poll(self): + return None + + closed = [] + + class _S(object): + alive = True + + def __init__(self, last): + self.last = last + self.proc = _P() + + def close(self): + closed.append(self) + + now = ns['time'].time() + live['old'] = _S(now - 999) # 闲置超时 → 该被回收 + live['new'] = _S(now) # 刚活动过 → 该留下 + n = ns['_reap_idle']() + ck("闲置会话已从 _SESSIONS 摘除", 'old' not in live, str(list(live))) + ck("活跃会话被保留", 'new' in live, str(list(live))) + ck("闲置会话被 close()", len(closed) == 1, str(len(closed))) + ck("返回回收条数", n == 1, str(n)) + ck("空表调用返回 0 且不抛", ns['_reap_idle']() == 0) + + print("\n" + "=" * 56) + print("结果:%d 项,失败 %d 项" % (_ck[1], _ck[1] - _ck[0])) + return 1 if _ck[0] != _ck[1] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/_dev/t-webshell.py b/_dev/t-webshell.py index 0ac7ab7..baa3e35 100644 --- a/_dev/t-webshell.py +++ b/_dev/t-webshell.py @@ -194,5 +194,43 @@ chk('Ctrl+C 走控制码', "k.charCodeAt(0) - 96" in APP_JS) chk('有选中文字时 Ctrl+C 不中断命令', "k === 'c' && wsSelection()" in APP_JS) chk('粘贴不会被当成控制码吞掉', "k === 'v') return;" in APP_JS) +# ========================================================================= +# 5. 前端终端外观:无滚动条 / 跟随最新 / 清屏按钮 +# ========================================================================= +# 需求:右侧不要那根滚动条,长回显自动跟随显示,另给一个小的清屏按钮。 +# 做法是「隐藏滚动条 + 继续自动滚到底」,而不是彻底禁掉滚动 —— +# 禁掉的话用户就再也翻不了历史了,属于把功能改没了。 +chk('隐藏了 WebKit 滚动条', '::-webkit-scrollbar' in APP_CSS and 'display:none' in APP_CSS) +chk('隐藏了 Firefox 滚动条', 'scrollbar-width:none' in APP_CSS) +chk('滚动能力保留(不是把 overflow 关掉)', + 'overflow-y:auto' in APP_CSS and 'overflow-y:hidden' not in APP_CSS) +chk('终端高度随视口自适应', 'clamp(' in APP_CSS and 'vh' in APP_CSS) +chk('栏内小按钮样式存在', '.term-btn{' in APP_CSS) +chk('标题栏有清屏按钮', 'id="ws-clearbar"' in APP_JS) +chk('标题栏有回到底部按钮', 'id="ws-tobottom"' in APP_JS) +chk('定义了 wsSyncTailBtn', 'function wsSyncTailBtn(' in APP_JS) +chk('滚动时同步尾部按钮', "addEventListener('scroll', wsSyncTailBtn" in APP_JS) + +# 阈值只能有一份:wsAutoScroll(要不要继续跟随)和 wsSyncTailBtn(要不要显示 +# 按钮)若各写各的,就会出现「按钮没出现、输出也不跟着滚了」的死角。 +chk('跟随阈值只有一个定义', APP_JS.count('const WS_TAIL_GAP') == 1) +# pick() 走 ast,只能喂 Python 源码(app.js 里带 em dash,会解析失败)。 +# 这里要的是「函数体」这段文本,直接按偏移切片即可。 +_i = APP_JS.find('function wsAutoScroll(') +chk('wsAutoScroll 存在', _i >= 0) +_autos_src = APP_JS[_i:_i + 700] if _i >= 0 else '' +chk('wsAutoScroll 用同一个阈值', 'WS_TAIL_GAP' in _autos_src and '< 40' not in _autos_src) + +# 历史遗留:旧的一套终端样式(.tdot/.tt/.term-in/.tk-*)已被完全取代, +# 留在文件里会让人改错地方。加了新样式后也不要把它带回来。 +# 匹配带 `{` 的选择器而不是裸类名 —— 上面那段说明注释里就写着这些名字, +# 用裸子串会命中注释,得到「明明删干净了却报失败」的假警报。 +for dead in ('.tdot{', ' .tt{', '.term-in{', '.tk-dir{', '.tk-cmd{', '.tk-prompt{'): + chk('已清掉失效样式 %s' % dead.rstrip('{').strip(), dead not in APP_CSS) +# 同一个选择器只允许出现一次。之前 .term-wrap / .term-body 各有两条声明 +# (后一条只是补一个属性),改样式时极易只改到其中一条。 +for sel in ('.term-wrap{', '.term-body{', '.term-bar{'): + chk('%s 只定义一次' % sel.rstrip('{'), APP_CSS.count(sel) == 1) + print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails)) sys.exit(1 if fails else 0) diff --git a/backend/drouter-shelld.py b/backend/drouter-shelld.py index 6880008..d9d89ae 100644 --- a/backend/drouter-shelld.py +++ b/backend/drouter-shelld.py @@ -64,13 +64,73 @@ MAX_READ = 262144 READ_HOLD_MAX = 5.0 READ_HOLD_STEP = 0.02 -_lock = threading.Lock() +# 必须是 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) @@ -85,6 +145,29 @@ def log(level, code, msg_cn, detail=None): 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 + 子进程 + 输出缓冲 + 读线程。""" @@ -97,7 +180,7 @@ class Session(object): self.buf = bytearray() self.buf_lock = threading.Lock() - self.master, slave = pty.openpty() + self.master, slave = _open_pty() _set_winsize(self.master, rows, cols) env = dict(os.environ) @@ -235,19 +318,28 @@ def _set_winsize(fd, rows, cols): def _reap_idle(): - """回收超时会话;顺带清掉已退出的僵尸子进程。""" + """回收超时会话;顺带清掉已退出的僵尸子进程。返回回收的会话数。 + + 锁内只做「登记摘除」,真正的 close() 放到锁外做 —— + close() 要等子进程退出(SIGHUP 后最多轮询 3 秒),持锁做会把 + 所有并发请求堵死:表现为「9 个会话时新连接超时、老终端也卡住」。 + """ now = time.time() + victims = [] 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] + victims.append(s) elif s.proc.poll() is not None and not s.alive: # 用户敲了 exit:保留一小段时间让前端把最后的输出读完 if now - s.last > 60: - s.close() del _SESSIONS[sid] + victims.append(s) + for s in victims: + s.close() + return len(victims) def _pick_shell(user): @@ -263,9 +355,14 @@ def handle(req): 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: - _reap_idle() if len(_SESSIONS) >= MAX_SESSIONS: return {'ok': False, 'code': 'BUSY', 'msg_cn': '终端会话已达上限(%d),请先断开不用的会话' % MAX_SESSIONS} @@ -279,6 +376,9 @@ def handle(req): try: s = Session(sid, user, shell, cols, rows, req.get('cwd') or '/root') except Exception as e: + # 把日志也写一份 —— 用户看到界面报错时的同一个信息必须留在 + # 服务端日志里,否则事后排查只能靠猜。 + log('error', 'PTY_FAIL', '创建终端失败:%s' % e, {'user': user}) return {'ok': False, 'code': 'PTY_FAIL', 'msg_cn': '创建终端失败:%s' % e} with _lock: @@ -406,6 +506,18 @@ def main(): 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) log('info', 'SHELLD_UP', 'Web 终端守护已启动:%s' % SOCK_PATH) diff --git a/devtools/preview-webshell.py b/devtools/preview-webshell.py new file mode 100644 index 0000000..8a99f3c --- /dev/null +++ b/devtools/preview-webshell.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +"""生成 Web 终端外观的离线预览页(用真实 app.css,不手抄样式)。 + +用途:终端样式(滚动条 / 自适应高度 / 栏内按钮)改了之后,想确认效果但 +不方便在浏览器里连真机时用。它把 viewWebShell() 里的那段终端 DOM 原样抠出来, +填上假的回显内容,套上真的 web/app.css,产出一个能直接打开的 HTML。 + +刻意不手抄样式和 DOM:手抄的预览页改完样式就过期了,还会给出「看起来对了」 +的假信心。这里两样都从源码里取,所以预览和线上永远一致。 + +用法: + python3 devtools/preview-webshell.py # 写到 _preview-webshell.html + python3 devtools/preview-webshell.py 输出路径 +""" +import io +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) + +# 造一段"长回显":既要够长能把容器撑到滚动,又要像真的 shell 输出。 +FAKE_SB = [ + "root@debian-primaryrouter:~# ", + "Linux debian-primaryrouter 6.1.0-18-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.90-1 x86_64", + "", + "The programs included with the Debian GNU/Linux system are free software;", + "the exact distribution terms for each program are described in the", + "individual files in /usr/share/doc/*/copyright.", + "", + "root@debian-primaryrouter:~# uname -a", + "Linux debian-primaryrouter 6.1.0-18-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.90-1 x86_64 GNU/Linux", + "root@debian-primaryrouter:~# free -h", + " total used free shared buff/cache available", + "Mem: 3.8Gi 401Mi 1.9Gi 1.0Mi 1.7Gi 3.3Gi", + "Swap: 2.0Gi 0B 2.0Gi", + "root@debian-primaryrouter:~# ls -la /etc/drouter/", + "total 84", + "drwxr-xr-x 3 root root 4096 Sep 30 21:04 .", + "drwxr-xr-x 92 root root 4096 Sep 30 20:11 ..", + "-rw-r--r-- 1 root root 1182 Sep 30 21:04 dcfg.json", + "-rw-r--r-- 1 root root 412 Sep 30 20:11 nft_v4.conf", + "-rw-r--r-- 1 root root 186 Sep 30 20:11 nft_v6.conf", + "-rw------- 1 root root 64 Sep 30 21:02 web-port", + "root@debian-primaryrouter:~# systemctl status drouter-shelld --no-pager", + "\u25cf drouter-shelld.service - drouter Web 终端 PTY 会话守护", + " Loaded: loaded (/etc/systemd/system/drouter-shelld.service; enabled)", + " Active: active (running) since Wed 2026-10-01 01:24:00 CST", + " Main PID: 301298 (python3)", + " Tasks: 5 (limit: 4608)", + " Memory: 13.8M", + " CPU: 143ms", + "root@debian-primaryrouter:~# nft list ruleset", + "table inet drouter_fw {", + " chain input {", + " type filter hook input priority filter; policy drop;", + " ct state established,related accept", + " iif lo accept", + " tcp dport 8443 accept", + " }", + "}", +] + + +def load(rel): + with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f: + return f.read() + + +def term_markup(app_js): + """从 viewWebShell() 里抠出 .term-wrap 那段 DOM,原样使用。""" + start = app_js.index('
') + # term-wrap 结束于紧随其后的
之后的空行 + ,用 textarea 结尾定位最稳 + tail = '\n ' + end = app_js.index(tail, start) + len(tail) + return app_js[start:end] + + +def main(): + app_js = load(os.path.join('web', 'app.js')) + app_css = load(os.path.join('web', 'app.css')) + html_markup = term_markup(app_js) + + # 注意这里查的是 HTML 属性写法 id="ws-term",不是 CSS 选择器 #ws-term + if 'id="ws-term"' not in html_markup or 'ws-clearbar' not in html_markup: + print('预览生成失败:从 viewWebShell() 里没抠到预期的终端 DOM') + return 1 + + def esc(s): + return (s.replace('&', '&').replace('<', '<').replace('>', '>')) + + sb_html = '\n'.join('
%s
' % esc(l) + for l in FAKE_SB) + # 屏幕区补满行,视觉上就是"回显已经把可视区填满了" + scr_rows = ['root@debian-primaryrouter:~# cat /proc/sys/kernel/pty/nr', + '0', 'root@debian-primaryrouter:~# _'] + scr_html = '\n'.join( + '
%s
' % esc(l) for l in scr_rows) + scr_html += '\n' + '\n'.join('
' + for _ in range(20)) + + html_markup = html_markup.replace( + '
', + '
\n%s\n
' % sb_html, 1) + html_markup = html_markup.replace( + '
', + '
\n%s\n
' % scr_html, 1) + + out = """ + + + + +Web 终端外观预览(真实 app.css) + + + + +
+
+

Web 终端外观预览

+ 样式直接取自 web/app.css, + DOM 取自 viewWebShell() —— 不手抄,故与线上一致 +
+ +
+ 看这三处:
+ ① 黑色终端区右侧没有滚动条了(长短回显都一样)
+ ② 标题栏右侧多了 清屏 一个小按钮(滚上去还会多出一个 + ↓ 最新)
+ ③ 终端高度随窗口高度自适应(clamp(260px, 54vh, 560px)), + 内容多了内部自动跟随最新输出,不靠拖滚动条 +
+ +%s +
+ + +""" % (app_css, html_markup) + + dst = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, '_preview-webshell.html') + with io.open(dst, 'w', encoding='utf-8', newline='\n') as f: + f.write(out) + print('已生成预览页:%s' % dst) + print(' 真实 CSS %d 字节 / 终端 DOM %d 字节' % (len(app_css), len(html_markup))) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/devtools/sync.sh b/devtools/sync.sh index e79a47a..a5b2e61 100644 --- a/devtools/sync.sh +++ b/devtools/sync.sh @@ -47,6 +47,10 @@ PY "$PYBIN" _dev/t-nat-detail.py || { echo "❌ NAT 详情渲染预检失败"; exit 1; } # Web 终端(真 PTY):helper/shelld/web 三端的协议契约 + 超时分级 "$PYBIN" _dev/t-webshell.py || { echo "❌ Web 终端契约预检失败"; exit 1; } +# PTY 可用性:/dev/ptmx 丢失时内核只报一句「out of pty devices」, +# 而 kernel.pty.nr 是 0(一个都没用),排查起来极其费时。 +# 这里钉住「启动自愈 + 失败重试 + 可定位的诊断信息」三件事。 +"$PYBIN" _dev/t-pty.py || { echo "❌ PTY 可用性预检失败"; exit 1; } # 终端模拟器行为:ANSI 光标 / 颜色 / 换行 / 分帧中文,在 node 里真跑一遍前端代码 "$NODEBIN" _dev/t-webterm.js || { echo "❌ 终端模拟器预检失败"; exit 1; } # 依赖自检清单:一键安装绝不能把 xfce4 / samba / docker 这类可选大件一起装上 diff --git a/web/app.css b/web/app.css index 11f84cd..4792f28 100644 --- a/web/app.css +++ b/web/app.css @@ -328,26 +328,11 @@ svg[role=img]{display:block;margin:0 auto} .lang-tabs button.on{background:var(--grad);color:#fff;border-color:transparent;font-weight:600} /* ---------- Web 终端 ---------- */ -.term-wrap{border:1px solid var(--line);border-radius:11px;overflow:hidden;background:#0d1526; - display:flex;flex-direction:column;margin-bottom:14px} -.term-bar{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#16233a; - border-bottom:1px solid #22314d;font-size:12.5px;color:#9db0cc} -.term-bar .tdot{width:10px;height:10px;border-radius:50%;background:#31405c;flex:0 0 10px} -.term-bar .tdot.r{background:#e05c4c} .term-bar .tdot.y{background:#d9a400} .term-bar .tdot.g{background:#3fa15c} -.term-bar .tt{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.term-body{height:430px;overflow:auto;padding:10px 12px; - font-family:ui-monospace,Consolas,"Cascadia Code",monospace;font-size:12.5px;line-height:1.62; - color:#cdd9ea;white-space:pre-wrap;word-break:break-all} -.term-in{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#111c2f; - border-top:1px solid #22314d} -.term-in .ps1{color:#4ea1ff;font-family:ui-monospace,Consolas,monospace;font-size:12.5px;flex:0 0 auto} -.term-in input{background:transparent;border:none;color:#e6edf7;padding:2px 0;font-size:12.5px; - font-family:ui-monospace,Consolas,monospace} -.term-in input:focus{box-shadow:none;border:none} -.tk-dir{color:#4ea1ff} .tk-file{color:#cdd9ea} .tk-link{color:#5fd0c8} -.tk-cmd{color:#8ce99a} .tk-err{color:#f87171} .tk-warn{color:#fbbf24} .tk-num{color:#f0a35e} -.tk-str{color:#e5c07b} .tk-var{color:#c792ea} .tk-cmt{color:#6b7f9c} .tk-prompt{color:#4ea1ff} -.tk-key{color:#7ec2ff} +/* 历史上这里有过一套 .term-wrap/.term-bar/.term-body/.term-in/.tk-* 的旧定义, + 已被下方「真 PTY 终端」那套(.term-dot/.term-title/.term-body.term-screen) + 完全取代:旧的一组属性全部被后者覆盖,.tdot/.tt/.term-in/.ps1/.tk-* + 在 app.js 里 0 引用。留着只会让人误以为改这里有作用,故删除。 + 新增终端样式请一律加到下方「真 PTY 终端」段。 */ /* ---------- 文件管理器 ---------- */ .fm-toolbar{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:12px} @@ -471,15 +456,31 @@ svg[role=img]{display:block;margin:0 auto} /* ---------- Web 终端(新结构:term-bar/term-dot/term-title/term-body/term-input/term-prompt) ---------- */ .term-wrap{border:1px solid #22314d;border-radius:11px;overflow:hidden;background:#0d1526; - display:flex;flex-direction:column;margin-bottom:14px} + display:flex;flex-direction:column;margin-bottom:14px;position:relative} .term-bar{display:flex;align-items:center;gap:9px;padding:8px 12px;background:#16233a; border-bottom:1px solid #22314d;font-size:12.5px;color:#9db0cc} .term-dot{width:10px;height:10px;border-radius:50%;background:#31405c;flex:0 0 10px} .term-dot.r{background:#e05c4c} .term-dot.y{background:#d9a400} .term-dot.g{background:#3fa15c} .term-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.term-body{height:420px;overflow:auto;margin:0;padding:10px 12px; +/* 栏内小按钮(清屏 / 回到底部)。刻意做得比正文小、低对比: + 这些是辅助动作,不该和「连接 / 断开」抢注意力。 */ +.term-btn{flex:0 0 auto;padding:2px 9px;font-size:11.5px;line-height:1.6;cursor:pointer; + border:1px solid #2b3c5c;border-radius:6px;background:#1b2a44;color:#9db0cc; + font-family:inherit;transition:background .15s,color .15s,border-color .15s} +.term-btn:hover{background:#233754;color:#dce7f7;border-color:#3a5080} +.term-btn:active{background:#16233a} +.term-btn[hidden]{display:none} +.term-body{height:clamp(260px,54vh,560px);overflow-y:auto;overflow-x:hidden; + margin:0;padding:10px 12px; font-family:ui-monospace,Consolas,"Cascadia Code",monospace;font-size:12.5px;line-height:1.62; - color:#cdd9ea;white-space:pre-wrap;word-break:break-all;background:transparent;border:none;border-radius:0} + color:#cdd9ea;white-space:pre-wrap;word-break:break-all;background:transparent;border:none;border-radius:0; + /* 去掉右侧滚动条:长回显靠「自动跟随底部」呈现,不靠拖条。 + 滚动能力保留(滚轮 / PgUp / Ctrl+Home 仍可用),只是不画那根条。 */ + scrollbar-width:none; /* Firefox */ + -ms-overflow-style:none; /* 旧 Edge / IE */ + /* 滚到边界时不要把滚动传给外层页面,否则在终端里滚到底会把整页卷走 */ + overscroll-behavior:contain} +.term-body::-webkit-scrollbar{width:0;height:0;display:none} /* Chrome / Edge / Safari */ .term-input{display:flex;align-items:center;gap:8px;margin-top:10px} .term-prompt{color:var(--pri);font-family:ui-monospace,Consolas,monospace;font-size:14px;flex:0 0 auto} .term-input input{flex:1;font-size:12.5px;font-family:ui-monospace,Consolas,monospace} @@ -487,7 +488,6 @@ svg[role=img]{display:block;margin:0 auto} /* ---------- 真 PTY 终端(#4 重做):网格化渲染 ---------- */ /* 终端按「行」渲染:每行一个 .tl,字符对齐靠等宽字体 + 固定行高。 这里必须 white-space:pre —— pre-wrap 会把超宽行折回来,行列就对不上了。 */ -.term-wrap{position:relative} .term-screen{white-space:pre;word-break:normal;overflow-x:hidden} .tl{white-space:pre;height:1.62em;line-height:1.62;min-height:1.62em} /* 光标方块:只在当前行当前列画一个 */ diff --git a/web/app.js b/web/app.js index 603cf96..764ffef 100644 --- a/web/app.js +++ b/web/app.js @@ -5880,6 +5880,10 @@ async function runDepInstall(keys) { const WS_MAX_SB = 600; // 回滚区最多保留多少行 const WS_WRITE_CHUNK = 1024; // 单次写入字节上限(粘贴长文本时分片发) +/* 距底部多少像素内算「在底部」。wsAutoScroll(决定要不要继续跟随) + wsSyncTailBtn(决定要不要显示「↓ 最新」按钮)必须用同一个阈值 —— + 两处不一致会出现「按钮没出现,输出也不跟着滚了」这种迷惑状态。 */ +const WS_TAIL_GAP = 40; const WS = { sid: null, cols: 0, rows: 0, @@ -6253,9 +6257,10 @@ function wsAutoScroll(force) { const max = Number(el.scrollHeight) || 0; const top = Number(el.scrollTop) || 0; const h = Number(el.clientHeight) || max; - if (force || (max - top - h) < 40) { + if (force || (max - top - h) < WS_TAIL_GAP) { try { el.scrollTop = max; } catch (e) { /* 少数环境是只读的,忽略 */ } } + wsSyncTailBtn(); } function wsNote(text) { @@ -6263,12 +6268,19 @@ function wsNote(text) { wsRequestRender(true); } +/* 清屏:只清本地这份 ANSI 状态,不向服务端发任何东西 —— 与在终端里敲 + clear / Ctrl+L 等价。会话、进程、历史都保留,服务端 PTY 完全不知情。 + (服务端只推原始字节流,客户端自己解释,所以本地清屏是安全的: + 全屏程序如 top/vi 下一次刷新会整屏重绘。) */ function wsClearScreen() { wsGridInit(WS.cols || 100, WS.rows || 26); WS.carry = ''; const sb = $('#ws-scroll'); if (sb) sb.innerHTML = ''; wsRenderScreen(); + const el = $('#ws-term'); + if (el) { try { el.scrollTop = 0; } catch (e) { /* 只读环境忽略 */ } } + wsSyncTailBtn(); } /* ---------------------------- 尺寸测量与同步 ---------------------------- */ @@ -6445,6 +6457,14 @@ async function wsConnect() { if (st) st.innerHTML = '连接失败'; wsNote('连接失败:' + (r.msg_cn || '未知错误')); if (r.code === 'NO_SHELLD') wsNote('请确认终端守护在运行:systemctl start drouter-shelld'); + // PTY_FAIL 里最坑的一种是 /dev/ptmx 入口丢失:明明一个终端都没开, + // 内核却报「out of pty devices」。守护会尝试自愈;还失败的话给条 + // 能照着敲的命令,别让用户对着 errno 干瞪眼。 + if (r.code === 'PTY_FAIL') { + wsNote('若提示 out of pty devices,通常是 /dev/ptmx 缺失(容器/虚拟化环境常见)。'); + wsNote('排查:ls -l /dev/ptmx 修复:ln -sf pts/ptmx /dev/ptmx'); + wsNote('然后重启守护:systemctl restart drouter-shelld'); + } toast(r.msg_cn || '连接失败', 'err', 5000); return; } @@ -6531,7 +6551,7 @@ async function viewWebShell() { - + @@ -6548,6 +6568,10 @@ async function viewWebShell() {
未连接 + + —
@@ -6557,7 +6581,10 @@ async function viewWebShell() {
-

点击终端区域即可开始输入;窗口大小变化时会自动同步给服务端。

+

点击终端区域即可开始输入;窗口大小变化时会自动同步给服务端。 + 长回显会自动跟随最新输出(右侧不显示滚动条)——想翻看历史用滚轮或 + PgUp,翻上去之后标题栏会出现「↓ 最新」按钮一键回到底部。 + 清屏只清本地显示,会话和正在跑的程序都不受影响。

@@ -6630,8 +6657,31 @@ function wsInit() { if (c) c.onclick = wsConnect; if (d) d.onclick = () => wsDisconnect(false); if ($('#ws-clear')) $('#ws-clear').onclick = wsClearScreen; + if ($('#ws-clearbar')) $('#ws-clearbar').onclick = wsClearScreen; if ($('#ws-copy')) $('#ws-copy').onclick = wsCopyAll; + // 隐藏滚动条后,用户滚上去就没有「拖回底部」这个操作了, + // 所以给一个显式的「↓ 最新」按钮,只在离开底部时才出现。 + const tb = $('#ws-tobottom'); + if (tb) tb.onclick = () => wsAutoScroll(true); + const box = $('#ws-term'); + if (box && box.addEventListener) box.addEventListener('scroll', wsSyncTailBtn, { passive: true }); wsBindInput(); + wsSyncTailBtn(); +} + +/* 距底部 WS_TAIL_GAP 内算「在底部」:此时不需要按钮,且新输出继续自动跟随。 */ +function wsSyncTailBtn() { + const box = $('#ws-term'), tb = $('#ws-tobottom'); + if (!box || !tb) return; + const max = Number(box.scrollHeight) || 0; + const top = Number(box.scrollTop) || 0; + const h = Number(box.clientHeight) || max; + const atBottom = (max - top - h) < WS_TAIL_GAP; + if (atBottom) { + if (!tb.hidden) { tb.hidden = true; } + } else if (tb.hidden) { + tb.hidden = false; + } } function wsBindInput() {