fix(webshell): 修复终端满员自锁 + 去掉滚动条并加清屏按钮
一、修 PTY 不可用(Web 终端报「创建终端失败:out of pty devices」)
根因是目标机上 /dev/ptmx 这个符号链接丢了(devpts 里 /dev/pts/ptmx
还在,但入口没了),于是 pty.openpty() 抛出误导性的「out of pty devices」。
- 新增 ensure_ptmx():探测 -> ln -sf pts/ptmx /dev/ptmx -> mknod 兜底
- 新增 _open_pty():首次失败先自愈再重试一次,仍失败就把真实原因
(_pty_diagnose() 的结论)包进异常,不再只留一句天书
- 服务启动时也自愈一次,开机即修,不等用户踩坑
- 前端识别 PTY_FAIL 并给出可照抄的修复命令
二、修会话满员时的死锁(连到第 9 个会话整个终端服务冻死)
connect 需要「看是否满员 -> 满了就回收 -> 再看一次」,这个序列必然
在持锁时调到内部也要拿锁的 _reap_idle()。而 _lock 是普通 Lock,
同线程二次 acquire 永久阻塞 —— 不报错、不超时,直接锁死;而且锁
一直不放,其他会话的 read/write 也一起堵住,整个 Web 终端报废。
- _lock 改为 RLock
- connect 不再把 _reap_idle() 放在 with _lock 里面
- _reap_idle() 锁内只做登记摘除,close() 移到锁外:
close() 要等子进程退出(SIGHUP 后最多轮询 3 秒),持锁做同样会
把并发请求堵死
三、终端外观(按需求)
- 去掉右侧滚动条(scrollbar-width:none + ::-webkit-scrollbar),
但保留滚动能力 —— 直接关掉 overflow 会让人再也翻不了历史
- 高度改为 clamp(260px,54vh,560px) 随视口自适应;内容多了自动跟随
最新输出。刻意不做「高度随内容增长」:wsMeasure() 用 box.h 算行数,
内容撑高容器会反过来改变 PTY 行数,形成反馈环
- 标题栏加「清屏」小按钮;滚上去离开底部时另出现「↓ 最新」——
滚动条没了,总得留个回到底部的入口
- 跟随阈值收敛成唯一的 WS_TAIL_GAP:wsAutoScroll 与 wsSyncTailBtn
各写一份会出现「按钮没出现、输出也不跟着滚了」的死角
四、顺带清理
- 删掉 app.css 里整套失效的旧终端样式(.tdot/.tt/.term-in/.tk-*,
app.js 中 0 引用),它们已被新结构完全覆盖,留着只会让人改错地方
- 合并 .term-wrap / .term-body 各自重复的第二条声明
测试:
_dev/t-pty.py 扩到 37 项,含「不得自锁」的结构检查 + _reap_idle
行为验证(摘除/关闭/返回条数);_dev/t-webshell.py 加 16 项外观契约。
两者都挂进 devtools/sync.sh 预检链。
新增 devtools/preview-webshell.py:用真实 app.css + 从 viewWebShell()
抠出的 DOM 生成离线预览页,不手抄样式以免预览与线上不一致。
真机验收(192.168.7.3):
8 个会话满员后第 9 个 0.01s 干净返回 BUSY(修复前是永久卡死);
8 个会话 0.87s 全部断开、残留 0(修复前残留 8 个且后续全卡);
/dev/pts 与 pty/nr 回落到 0,无 fd 泄漏;root shell 正常执行命令。
This commit is contained in:
1 parent
9b4ffce9a5
commit
2859705cee
8 files changed
+712
-34
No files matched your search
@@ -48,6 +48,7 @@ desktop.ini
|
||||
|
||||
# 截图画廊的本地预览页(一次性产物,别进库)
|
||||
_preview-shots.html
|
||||
_preview-*.html
|
||||
|
||||
# ── 凭据(绝不能进库)──────────────────────────────────
|
||||
*.pem
|
||||
|
||||
+307
@@ -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, '<reap>', '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())
|
||||
@@ -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)
|
||||
+119
-7
@@ -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)
|
||||
|
||||
@@ -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('<div class="term-wrap">')
|
||||
# term-wrap 结束于紧随其后的 </div> 之后的空行 + </div>,用 textarea 结尾定位最稳
|
||||
tail = '</textarea>\n </div>'
|
||||
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(' <div class="tl">%s</div>' % 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(
|
||||
' <div class="tl">%s</div>' % esc(l) for l in scr_rows)
|
||||
scr_html += '\n' + '\n'.join(' <div class="tl"> </div>'
|
||||
for _ in range(20))
|
||||
|
||||
html_markup = html_markup.replace(
|
||||
'<div id="ws-scroll"></div>',
|
||||
'<div id="ws-scroll">\n%s\n </div>' % sb_html, 1)
|
||||
html_markup = html_markup.replace(
|
||||
'<div id="ws-screen"></div>',
|
||||
'<div id="ws-screen">\n%s\n </div>' % scr_html, 1)
|
||||
|
||||
out = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Web 终端外观预览(真实 app.css)</title>
|
||||
<style>
|
||||
%s
|
||||
</style>
|
||||
<style>
|
||||
/* 仅预览页自己的外壳,不进产品代码 */
|
||||
.pv-shell{max-width:1000px;margin:0 auto;padding:22px 20px 60px}
|
||||
.pv-hd{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.pv-hd h1{margin:0;font-size:18px}
|
||||
.pv-hd .m{font-size:12.5px;color:var(--txt2);line-height:1.7}
|
||||
.pv-note{border:1px solid var(--line);border-radius:10px;background:var(--panel);
|
||||
padding:12px 15px;margin-bottom:16px;font-size:12.5px;line-height:1.85;color:var(--txt2)}
|
||||
.pv-note b{color:var(--txt)}
|
||||
.pv-k{font-family:ui-monospace,Consolas,monospace;background:#eef2f9;
|
||||
border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:11.5px}
|
||||
/* 预览页里的 .ws-capture 是隐藏输入框,绝对定位会跟着滚,这里钉住不显示 */
|
||||
.pv-shell .ws-capture{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pv-shell">
|
||||
<div class="pv-hd">
|
||||
<h1>Web 终端外观预览</h1>
|
||||
<span class="m">样式直接取自 <span class="pv-k">web/app.css</span>,
|
||||
DOM 取自 <span class="pv-k">viewWebShell()</span> —— 不手抄,故与线上一致</span>
|
||||
</div>
|
||||
|
||||
<div class="pv-note">
|
||||
<b>看这三处:</b><br>
|
||||
① 黑色终端区<b>右侧没有滚动条</b>了(长短回显都一样)<br>
|
||||
② 标题栏右侧多了 <span class="pv-k">清屏</span> 一个小按钮(滚上去还会多出一个
|
||||
<span class="pv-k">↓ 最新</span>)<br>
|
||||
③ 终端高度随窗口高度自适应(<span class="pv-k">clamp(260px, 54vh, 560px)</span>),
|
||||
内容多了内部自动跟随最新输出,不靠拖滚动条
|
||||
</div>
|
||||
|
||||
%s
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""" % (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())
|
||||
@@ -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 这类可选大件一起装上
|
||||
|
||||
+24
-24
@@ -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}
|
||||
/* 光标方块:只在当前行当前列画一个 */
|
||||
|
||||
+53
-3
@@ -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 = '<span class="tag err">连接失败</span>';
|
||||
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() {
|
||||
</select></label>
|
||||
<button id="ws-connect" class="primary">连接</button>
|
||||
<button id="ws-disconnect" class="ghost" disabled>断开</button>
|
||||
<button id="ws-clear" class="ghost small">清屏</button>
|
||||
<button id="ws-clear" class="ghost small" title="清屏:只清本地显示,不中断会话">清屏</button>
|
||||
<button id="ws-copy" class="ghost small">复制可见内容</button>
|
||||
<span class="desc" id="ws-status" style="margin:0"></span>
|
||||
</div>
|
||||
@@ -6548,6 +6568,10 @@ async function viewWebShell() {
|
||||
<div class="term-bar">
|
||||
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
|
||||
<span class="term-title" id="ws-title">未连接</span>
|
||||
<button type="button" class="term-btn" id="ws-tobottom" hidden
|
||||
title="回到底部,继续跟随最新输出">↓ 最新</button>
|
||||
<button type="button" class="term-btn" id="ws-clearbar"
|
||||
title="清屏:只清本地显示,不中断会话(等价于命令 clear)">清屏</button>
|
||||
<span class="tag gray" id="ws-size">—</span>
|
||||
</div>
|
||||
<div class="term-body term-screen" id="ws-term" tabindex="0">
|
||||
@@ -6557,7 +6581,10 @@ async function viewWebShell() {
|
||||
<textarea id="ws-key" class="ws-capture" spellcheck="false" autocomplete="off"
|
||||
autocapitalize="off" autocorrect="off" aria-label="终端输入"></textarea>
|
||||
</div>
|
||||
<p class="desc">点击终端区域即可开始输入;窗口大小变化时会自动同步给服务端。</p>
|
||||
<p class="desc">点击终端区域即可开始输入;窗口大小变化时会自动同步给服务端。
|
||||
长回显会<b>自动跟随最新输出</b>(右侧不显示滚动条)——想翻看历史用滚轮或
|
||||
<span class="mono">PgUp</span>,翻上去之后标题栏会出现「↓ 最新」按钮一键回到底部。
|
||||
清屏只清本地显示,会话和正在跑的程序都不受影响。</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="fm-card">
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user