起因:想给 “假云服务器” 配一个网页 Shell

我在家里搭了一套仿云厂商的 “假云服务器” 玩具:Mac 当图形工作站,一台 ThinkPad(Arch Linux)当宿主机,Docker 用 macvlan 网络让每个容器直接占一个局域网空闲 IP(192.168.50.100~103),再写了个仿 ECS 的网页控制台。点 “创建实例” 就是真的 docker run,点 “销毁” 就是 docker rm -f,乐趣就在于买一台、玩一下、释放掉。

既然是模仿云服务器,每台实例就得能 “连上去敲命令”。我要求:网页控制台的实例列表里点一下 “打开 Shell”,新标签页直接进终端,不用输 SSH 密码,敲 lsmkdirtouch 就走人。

初版:每台容器里塞一个 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):

&#x20; """把 WebSocket 连接桥接到 docker exec -it \<name> bash 的 PTY。"""

&#x20; def \_\_init\_\_(self, conn, name):

&#x20;   super().\_\_init\_\_(daemon=True)

&#x20;   self.conn = conn

&#x20;   self.name = name

&#x20; def run(self):

&#x20;   master, slave = pty.openpty()

&#x20;   proc = subprocess.Popen(

&#x20;     \["sudo", "docker", "exec", "-it", self.name, "bash"],

&#x20;     stdin=slave, stdout=slave, stderr=slave, close\_fds=True,

&#x20;     preexec\_fn=os.setsid,

&#x20;   )

&#x20;   os.close(slave)

&#x20;   while True:

&#x20;     r, \_w, \_x = select.select(\[master, self.conn], \[], \[], 1.0)

&#x20;     if master in r:                      # bash 有输出 -> 发 WebSocket 帧

&#x20;       data = os.read(master, 8192)

&#x20;       if not data:

&#x20;         break

&#x20;       ws\_send\_frame(self.conn, data)

&#x20;     if self.conn in r:                   # 浏览器来了帧 -> 解析写进 PTY

&#x20;       op, payload = ws\_recv\_frame(self.conn)

&#x20;       if op is None or op == 8:          # 关闭帧

&#x20;         break

&#x20;       if op == 1:

&#x20;         msg = json.loads(payload.decode("utf-8", "replace"))

&#x20;         if msg.get("type") == "input":

&#x20;           os.write(master, msg.get("data", "").encode("utf-8"))

&#x20;         elif msg.get("type") == "resize":

&#x20;           fcntl.ioctl(master, termios.TIOCSWINSZ,

&#x20;             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 一模一样,但这次代码是我自己写的,可以查。

先怀疑 BaseHTTPRequestHandlerdo_GET 返回后会自动 finish() 关闭 socket。于是 override finish(),WebSocket 升级后跳过关闭。重测,还是被关。

再往下挖,真凶浮出来:ThreadingHTTPServerThreadingMixIn)的线程在处理完一个请求之后,会执行 shutdown_request 把 socket 关掉 —— 这一步发生在 Handler.finish() 之外,override finish() 根本拦不住。

最终方案最朴素:do_GET 里升级成功后,启动 TermClient 线程,然后 join() 阻塞住。handler 一直不返回,服务端就一直不会回收这条连接;连接的生命周期完全交给终端会话 —— 会话结束,handler 才返回,连接才被正常收尾:

&#x20;     self.send\_response(101)

&#x20;     self.send\_header("Upgrade", "websocket")

&#x20;     self.send\_header("Connection", "Upgrade")

&#x20;     self.send\_header("Sec-WebSocket-Accept", accept)

&#x20;     self.end\_headers()

&#x20;     self.wfile.flush()

&#x20;     # 阻塞等待终端会话结束:handler 不返回,服务端才不会回收这条连接

&#x20;     tc = TermClient(self.connection, name)

&#x20;     tc.start()

&#x20;     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()ThreadingMixInshutdown_request 都会关 socket,override finish() 拦不住后者;“线程 + 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):

&#x20;   mask = os.urandom(4)

&#x20;   n = len(payload)

&#x20;   header = bytearray(\[0x81])

&#x20;   if n < 126: header.append(0x80 | n)

&#x20;   elif n < 65536:

&#x20;       header.append(0x80 | 126); header += struct.pack(">H", n)

&#x20;   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"

&#x20;      "Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"

&#x20;      "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:

&#x20;   print("response:", sock.recv(65536))

except socket.timeout:

&#x20;   print("response: (timeout, nothing came back)")

用这套脚本测 ttyd 时,它只回一个 Ping 帧;测自研网关时,收到的是 bash 的完整回显。一个脚本,两种结局。

本文采用木兰开放作品许可协议(Mulan-OWL)署名 - 专利许可,第 1 版授权。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐