ttyd 网页终端敲不了回车:从 WebSocket 协议层定位,到自研 100 行网关
起因:想给 “假云服务器” 配一个网页 Shell
我在家里搭了一套仿云厂商的 “假云服务器” 玩具:Mac 当图形工作站,一台 ThinkPad(Arch Linux)当宿主机,Docker 用 macvlan 网络让每个容器直接占一个局域网空闲 IP(192.168.50.100~103),再写了个仿 ECS 的网页控制台。点 “创建实例” 就是真的 docker run,点 “销毁” 就是 docker rm -f,乐趣就在于买一台、玩一下、释放掉。
既然是模仿云服务器,每台实例就得能 “连上去敲命令”。我要求:网页控制台的实例列表里点一下 “打开 Shell”,新标签页直接进终端,不用输 SSH 密码,敲 ls、mkdir、touch 就走人。
初版:每台容器里塞一个 ttyd
第一版方案很省事:用 ttyd 的官方静态二进制(1.7.7,GitHub release 的 ttyd.x86_64),COPY 进四个发行版镜像(Ubuntu / Debian / CentOS / Arch),启动命令 ttyd -p 7681 bash。控制台页面的 “打开 Shell” 按钮直接 window.open("http://192.168.50.103:7681")。
打开页面,一切看起来都很正常:黑色的终端背景,[root@arch-1 /]# 提示符完整显示,鼠标点击也有光标。我以为搞定了。
症状:能看见提示符,但键盘完全没反应
实际用的时候傻眼了:鼠标点了、光标有了,敲字母没反应,按回车也没反应。换中文输入法、英文输入法都一样。唯一正常的迹象是 —— 中文输入法的候选框能弹出来,说明键盘事件是进了页面的,只是字符进不了终端。
直觉排错走了一遍:
-
是不是焦点没抢到?点一下再敲,不行
-
是不是输入法拦截了?切英文,不行
-
是不是浏览器问题?换浏览器没意义,问题在两台机器上一致
-
是不是 ttyd 进程挂了?页面能显示提示符,进程活着
这些都排掉之后,问题就不在前端了。ttyd 的本质是一条链路:浏览器 <-> WebSocket <-> ttyd 进程 <-> PTY <-> bash。既然页面能拿到提示符,说明 “输出方向”(bash → 页面)是通的;敲字没反应,说明 “输入方向”(页面 → bash)断了。但断在哪一环,前端看不见,得直接捅 WebSocket。
定位:别猜前端了,直接抓 WebSocket 帧
ttyd 的网页终端就是浏览器和 ttyd 之间跑 WebSocket。我用 Python 手写了一个最小 WebSocket 客户端:先做握手(Sec-WebSocket-Key 拼上固定的 GUID 算 SHA1,得到 Sec-WebSocket-Accept),然后发 resize 帧、发 input 帧,把服务端每一条响应都打出来。
测试结果很干脆:
-
握手:
101 Switching Protocols,成功 -
连接建立后:0 字节 —— 连屏幕缓冲都不重放
-
发
{"type":"input","data":"echo hi\n"}之后:只回了一个 WebSocket Ping 帧(0x89 0x00),没有任何终端输出
对照实验在宿主机上做:用 pty.openpty() 加 sudo docker exec -it arch-1 bash 裸测,提示符、回显全部正常。
结论清晰了:bash 没问题、PTY 没问题,问题在 ttyd 这个静态二进制身上 —— 它收到了 input 帧,但没有写进 PTY。四台容器表现完全一致,不是偶发,是输入方向整体损坏。闭源静态二进制没法逆向,直接换自己可控的方案。
自研:WebSocket + PTY + docker exec
反正控制台后端本来就是我自己写的 Python 服务,不如直接把终端能力做进去,架构变成:
浏览器(xterm.js) <-> WebSocket <-> Python 网关 <-> PTY <-> docker exec -it \<name> bash
网关三个端点:
-
GET /term:终端页面(xterm.js 渲染) -
GET /static/xterm.js、/static/xterm.css:xterm.js 4.19 从 jsDelivr 下载下来自托管,局域网访问不依赖外网 -
GET /api/ws?name=容器名:WebSocket 升级,然后双向转发
转发核心是一个线程,把 WebSocket 和 PTY 用 select 双向泵:
class TermClient(threading.Thread):
  """把 WebSocket 连接桥接到 docker exec -it \<name> bash 的 PTY。"""
  def \_\_init\_\_(self, conn, name):
  super().\_\_init\_\_(daemon=True)
  self.conn = conn
  self.name = name
  def run(self):
  master, slave = pty.openpty()
  proc = subprocess.Popen(
  \["sudo", "docker", "exec", "-it", self.name, "bash"],
  stdin=slave, stdout=slave, stderr=slave, close\_fds=True,
  preexec\_fn=os.setsid,
  )
  os.close(slave)
  while True:
  r, \_w, \_x = select.select(\[master, self.conn], \[], \[], 1.0)
  if master in r: # bash 有输出 -> 发 WebSocket 帧
  data = os.read(master, 8192)
  if not data:
  break
  ws\_send\_frame(self.conn, data)
  if self.conn in r: # 浏览器来了帧 -> 解析写进 PTY
  op, payload = ws\_recv\_frame(self.conn)
  if op is None or op == 8: # 关闭帧
  break
  if op == 1:
  msg = json.loads(payload.decode("utf-8", "replace"))
  if msg.get("type") == "input":
  os.write(master, msg.get("data", "").encode("utf-8"))
  elif msg.get("type") == "resize":
  fcntl.ioctl(master, termios.TIOCSWINSZ,
  struct.pack("HHHH", msg\["rows"], msg\["cols"], 0, 0))
前端 term.html 用 xterm.js:onData 发 {"type":"input"},onResize 发 {"type":"resize"},断了自动重连。到这里,链路本身已经通了,但部署上去发现一个更隐蔽的坑。
最大的坑:WebSocket 升级后连接被 HTTP 服务器回收
第一版网关写完,WebSocket 测试脚本一跑:握手 101 成功,然后 0 字节,连接两秒内被服务端关闭。症状和 ttyd 一模一样,但这次代码是我自己写的,可以查。
先怀疑 BaseHTTPRequestHandler 在 do_GET 返回后会自动 finish() 关闭 socket。于是 override finish(),WebSocket 升级后跳过关闭。重测,还是被关。
再往下挖,真凶浮出来:ThreadingHTTPServer(ThreadingMixIn)的线程在处理完一个请求之后,会执行 shutdown_request 把 socket 关掉 —— 这一步发生在 Handler.finish() 之外,override finish() 根本拦不住。
最终方案最朴素:do_GET 里升级成功后,启动 TermClient 线程,然后 join() 阻塞住。handler 一直不返回,服务端就一直不会回收这条连接;连接的生命周期完全交给终端会话 —— 会话结束,handler 才返回,连接才被正常收尾:
  self.send\_response(101)
  self.send\_header("Upgrade", "websocket")
  self.send\_header("Connection", "Upgrade")
  self.send\_header("Sec-WebSocket-Accept", accept)
  self.end\_headers()
  self.wfile.flush()
  # 阻塞等待终端会话结束:handler 不返回,服务端才不会回收这条连接
  tc = TermClient(self.connection, name)
  tc.start()
  tc.join()
验证:协议层通了,真实浏览器也通了
-
协议层:连接建立后立刻收到
[root@arch-1 /]#提示符;发echo命令有回显 -
真实浏览器:用 Playwright 驱动 Chromium 无头打开
/term?name=arch-1,等提示符出现,然后模拟键盘输入ls回车 —— 目录列表正常输出;再敲mkdir /tmp/playwright-ok && echo PW_TEST_OK_123,回显正常,控制台零报错
之前 ttyd 死活敲不进字的场景,在真实浏览器的键盘事件下完全正常,问题闭环。
给以后的 AI 铺路
-
网页终端类问题按层拆,别停在 “焦点 / 输入法” 表面:浏览器事件层 -> WebSocket 传输层 -> 服务端转发层 -> PTY -> 进程,逐层用最小测试钉死
-
手写 WebSocket 客户端是排查 WS 服务最快的工具:输出方向通、输入方向断,基本可以断定服务端收到了但不消费
-
用标准库
http.server做 WebSocket 升级,连接生命周期是隐藏杀手:Handler.finish()和ThreadingMixIn的shutdown_request都会关 socket,overridefinish()拦不住后者;“线程 + join () 阻塞 handler” 是最省事的解法 -
验证必须做在用户真实看到的层:协议层通了不等于 UI 层通,真实浏览器打字测试通过才算解决
-
容器内 bash 裸测正常,问题就必然在中间层;闭源静态二进制(ttyd 这类)行为损坏时别逆向,换自己可控的方案更快
附:可复用的 WebSocket 测试脚本骨架
排查时用的最小客户端,改一下地址和路径就能复用到任何 WS 服务:
import socket, base64, os, struct
HOST, PORT, PATH = "192.168.50.1", 8080, "/api/ws?name=arch-1"
def send\_text(sock, payload):
  mask = os.urandom(4)
  n = len(payload)
  header = bytearray(\[0x81])
  if n < 126: header.append(0x80 | n)
  elif n < 65536:
  header.append(0x80 | 126); header += struct.pack(">H", n)
  sock.sendall(bytes(header) + mask + bytes(b ^ mask\[i % 4] for i, b in enumerate(payload)))
sock = socket.create\_connection((HOST, PORT), timeout=5)
key = base64.b64encode(os.urandom(16)).decode()
req = ("GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\n"
  "Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
  "Sec-WebSocket-Version: 13\r\n\r\n" % (PATH, HOST, PORT, key))
sock.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp: resp += sock.recv(4096)
print("handshake:", resp.decode(errors="replace").split("\r\n")\[0])
send\_text(sock, b'{"type":"input","data":"echo hi\\\n"}')
sock.settimeout(3)
try:
  print("response:", sock.recv(65536))
except socket.timeout:
  print("response: (timeout, nothing came back)")
用这套脚本测 ttyd 时,它只回一个 Ping 帧;测自研网关时,收到的是 bash 的完整回显。一个脚本,两种结局。
本文采用木兰开放作品许可协议(Mulan-OWL)署名 - 专利许可,第 1 版授权。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)