给 ttyd 加一个手机触屏虚拟按键面板,并构建全静态可移植二进制
本文介绍如何基于 ttyd(开源 Web 终端)二次开发:在 Web 页面上增加一个可折叠的 tmux 虚拟按键面板(解决手机没有 Ctrl 键和方向键的问题),并利用仓库自带的官方脚本,用 Docker 构建出一个不依赖任何动态库、任意 x86_64 Linux 可直接运行 的静态二进制。所有步骤均经过端到端验证:从
git clone原始仓库开始,打一个补丁、跑一个脚本,即可复现。
1. 需求背景
我平时在手机浏览器里通过 ttyd 访问 WebSSH,并在里面使用 tmux。tmux 的绝大多数操作都依赖 prefix 组合键 (默认 Ctrl+b)加后续按键:
Ctrl+b+ 方向键 → 切换窗格Ctrl+b z→ 放大/恢复当前窗格Ctrl+b d→ detach 会话
问题在于:手机屏幕键盘上没有物理 Ctrl 键,也没有方向键。虽然有些输入法 App 可以伪装出这些键,但体验割裂、容易误触,且没法要求每个使用者都换输入法。
另外还有一个部署侧的诉求:ttyd 动态编译出的二进制依赖 glibc、libwebsockets、openssl 等一堆 .so,跨机器分发时经常遇到"目标机器缺库"或"glibc 版本不匹配"。我希望最终产物是单文件、零依赖、拷到任意 x86_64 Linux 都能跑的。
于是定下两个目标:
- 在 ttyd Web 页面上加一个触屏虚拟按键面板,点按即发送 tmux 组合键,效果等同物理键盘;
- 构建全静态链接的发布产物,包含上述新功能。
2. 功能介绍
2.1 虚拟按键面板
改动后的页面右下角会出现一个半透明的十字布局面板:
[▾] ← 折叠按钮
[ ▲ ]
[ ◀ ][Zoom][ ▶ ]
[ ▼ ]
| 按键 | 发送的序列 | tmux 中的作用 |
|---|---|---|
| ▲ / ▼ / ◀ / ▶ | C-b + \x1b[A/B/D/C(方向键转义序列) |
切换窗格 |
| Zoom(中间键) | C-b + z |
放大当前窗格,再按一次恢复 |
▾(角落小按钮) |
--- | 折叠整个面板为一个 ⌨ 圆钮,再点展开 |
实现要点:
- 按键走的是 xterm.js 的 WebSocket
INPUT通道 (调用xterm.sendData()),和物理键盘输入完全同一条路径,所以 tmux 原生识别,没有任何"模拟"的折扣。Ctrl+b就是字节\x02,方向键就是标准转义序列。 onPointerDown代替onClick:消除移动端浏览器约 300ms 的点击延迟。touch-action: manipulation:防止快速连点触发双击缩放造成误触。- 面板固定右下角、半透明、可折叠,尽量不遮挡终端内容;手机竖屏(≤480px)时按键自动缩小。
- 顺带修复了一个上游兼容性问题:
terminal/index.tsx中ref={c => (this.container = c)}这种箭头函数会把返回值传给RefCallback,在新版 Preact/TypeScript 下报 TS2322 编译错误,改为块体写法。
2.2 全静态二进制
产出物是约 7.7MB 的单个 ELF 文件:
$ file ttyd-static
ELF 64-bit LSB executable, x86-64, statically linked, stripped
$ ldd ttyd-static
not a dynamic executable
它把 zlib、json-c、libuv、openssl、libwebsockets 连同 musl libc 全部静态链接进去。这严格说不算"不依赖 libc",而是"内置了 musl libc"------效果等同:不需要在目标机器上安装任何依赖,CentOS、Ubuntu、Alpine、老内核均可直接运行。
一个前提要说清楚:Linux 上无法用 glibc 做出真正干净的纯静态二进制 (glibc 静态链接在 NSS/DNS 解析上有著名的坑)。ttyd 官方发布版采用的就是 musl 方案,仓库自带的 scripts/cross-build.sh 完整实现了这条流水线,我们直接复用它即可。
2.3 两个服务端问题的修复(后端 C 代码)
部署到公网服务器后遇到两个问题,均已修复并包含在 diff.patch 中:
-
GET /报ERR_CONTENT_LENGTH_MISMATCH(静态编译下才出现):src/http.c原逻辑在开启 lws 流压缩宏时总是把内嵌的 gzip 流解压成 3MB 裸 HTML 再传输,公网慢链路下传输易被中途掐断。修复:浏览器Accept-Encoding含 gzip 时,直接发送内嵌的 gzip 数据(html.h本身就是 gzip 流)并加上Content-Encoding: gzip头,体积从 3.1MB 降到 884KB(-72%)。 -
手机浏览器连不上,日志报
W: User code denied connection(iPhone Safari 等):ttyd 的 WebSocket 握手要求Authorization: Basic ...头,但 Safari 等移动端浏览器的 WS 升级请求不携带 Basic Auth 凭据 (PC Chrome 会带),于是握手被check_auth()拒绝。修复(src/protocol.c):握手时若没有 Authorization 头,放行带token=查询参数的请求------前端本来就会从/token端点拿到凭据并拼在 WS URL 上;真实认证并未放松 ,WS 首条消息仍必须携带正确的AuthToken(即-c的 base64 凭据)才能 spawn 进程,伪造token=只能完成握手、看不到任何终端输出。
3. 按步骤复现
环境要求:一台装有 Docker 的 x86_64 Linux 主机(编译在前端容器内完成,主机本身无需任何编译工具;前端资源已内嵌在官方脚本流程中,也无需 Node.js)。
3.1 克隆原始仓库
bash
git clone https://github.com/tsl0922/ttyd.git
cd ttyd
git checkout 2922cb89f518bae4d0fcf4d757a7419638fc71fc
3.2 写入补丁文件
把下面的内容完整保存为 diff.patch(放在仓库根目录下):
bash
cat > diff.patch << 'EOF'
diff --git a/html/src/components/keypad/index.tsx b/html/src/components/keypad/index.tsx
new file mode 100644
index 0000000..7c5b318
--- /dev/null
+++ b/html/src/components/keypad/index.tsx
@@ -0,0 +1,86 @@
+import { bind } from 'decko';
+import { Component, h } from 'preact';
+import type { Xterm } from '../terminal/xterm';
+
+import './keypad.scss';
+
+interface Props {
+ xterm: Xterm;
+}
+
+const PREFIX = '\x02'; // Ctrl+b, tmux prefix
+const ARROWS: Record<string, string> = {
+ up: '\x1b[A',
+ down: '\x1b[B',
+ right: '\x1b[C',
+ left: '\x1b[D',
+};
+
+export class KeyPad extends Component<Props, { collapsed: boolean }> {
+ constructor() {
+ super();
+ this.state = { collapsed: false };
+ }
+
+ // 发送 tmux prefix + 按键序列, 走 WebSocket INPUT 通道, 等同物理键盘输入。
+ // 拆开异步发送, 确保服务端按顺序收到 C-b 与后续键。
+ @bind
+ send(keys: string) {
+ this.props.xterm.sendData(PREFIX);
+ this.props.xterm.sendData(keys);
+ }
+
+ @bind
+ toggle() {
+ this.setState(s => ({ collapsed: !s.collapsed }));
+ }
+
+ render(_: Props, { collapsed }: { collapsed: boolean }) {
+ return (
+ <div class={`keypad ${collapsed ? 'keypad--collapsed' : ''}`}>
+ {collapsed ? (
+ <button class="keypad__toggle" type="button" onPointerDown={this.toggle}>
+ ⌨
+ </button>
+ ) : (
+ <div class="keypad__pad">
+ <button class="keypad__toggle keypad__toggle--corner" type="button" onPointerDown={this.toggle}>
+ ▾
+ </button>
+ <button class="keypad__key keypad__up" type="button" onPointerDown={() => this.send(ARROWS.up)}>
+ ▲
+ </button>
+ <button
+ class="keypad__key keypad__left"
+ type="button"
+ onPointerDown={() => this.send(ARROWS.left)}
+ >
+ ◀
+ </button>
+ <button
+ class="keypad__key keypad__center"
+ type="button"
+ onPointerDown={() => this.send('\x7a')}
+ >
+ Zoom
+ </button>
+ <button
+ class="keypad__key keypad__right"
+ type="button"
+ onPointerDown={() => this.send(ARROWS.right)}
+ >
+ ▶
+ </button>
+ <button
+ class="keypad__key keypad__down"
+ type="button"
+ onPointerDown={() => this.send(ARROWS.down)}
+ >
+ ▼
+ </button>
+ </div>
+ )}
+ </div>
+ );
+ }
+}
diff --git a/html/src/components/keypad/keypad.scss b/html/src/components/keypad/keypad.scss
new file mode 100644
index 0000000..7c76432
--- /dev/null
+++ b/html/src/components/keypad/keypad.scss
@@ -0,0 +1,113 @@
+@use 'sass:math';
+
+// 触屏虚拟按键面板: 固定右下角, 十字布局, 可折叠。
+// 使用 pointerdown 而非 click 以消除移动端 ~300ms 点击延迟;
+// touch-action: manipulation 防止双击缩放误触。
+
+$key-size: 56px;
+$key-gap: 6px;
+
+.keypad {
+ position: fixed;
+ right: 10px;
+ bottom: 10px;
+ z-index: 100;
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-tap-highlight-color: transparent;
+
+ &--collapsed {
+ pointer-events: none;
+ }
+
+ &__toggle {
+ pointer-events: auto;
+ border: none;
+ border-radius: 50%;
+ background: rgba(40, 44, 52, 0.75);
+ color: #ccc;
+ font-size: 20px;
+ width: $key-size;
+ height: $key-size;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ touch-action: manipulation;
+ cursor: pointer;
+
+ &--corner {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ width: 24px;
+ height: 24px;
+ font-size: 14px;
+ z-index: 1;
+ }
+ }
+
+ &__pad {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(3, $key-size);
+ grid-template-rows: repeat(3, $key-size);
+ gap: $key-gap;
+ background: rgba(40, 44, 52, 0.45);
+ border-radius: 14px;
+ padding: $key-gap + 10px;
+ }
+
+ &__key {
+ border: none;
+ border-radius: 10px;
+ background: rgba(80, 86, 98, 0.85);
+ color: #eee;
+ font-size: 18px;
+ touch-action: manipulation;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+
+ &:active {
+ background: #4a9eff;
+ }
+ }
+
+ .keypad__up {
+ grid-column: 2;
+ grid-row: 1;
+ }
+ .keypad__left {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ .keypad__center {
+ grid-column: 2;
+ grid-row: 2;
+ font-size: 11px;
+ }
+ .keypad__right {
+ grid-column: 3;
+ grid-row: 2;
+ }
+ .keypad__down {
+ grid-column: 2;
+ grid-row: 3;
+ }
+}
+
+// 手机竖屏时缩小按键, 减少遮挡
+@media (max-width: 480px) {
+ .keypad {
+ $key-size: 48px;
+
+ &__toggle {
+ width: $key-size;
+ height: $key-size;
+ }
+
+ &__pad {
+ grid-template-columns: repeat(3, $key-size);
+ grid-template-rows: repeat(3, $key-size);
+ }
+ }
+}
diff --git a/html/src/components/terminal/index.tsx b/html/src/components/terminal/index.tsx
index b42bf15..97a1992 100644
--- a/html/src/components/terminal/index.tsx
+++ b/html/src/components/terminal/index.tsx
@@ -4,6 +4,7 @@ import { Xterm, XtermOptions } from './xterm';
import '@xterm/xterm/css/xterm.css';
import { Modal } from '../modal';
+import { KeyPad } from '../keypad';
interface Props extends XtermOptions {
id: string;
@@ -34,7 +35,13 @@ export class Terminal extends Component<Props, State> {
render({ id }: Props, { modal }: State) {
return (
- <div id={id} ref={c => (this.container = c as HTMLElement)}>
+ <div
+ id={id}
+ ref={c => {
+ this.container = c as HTMLElement;
+ }}
+ >
+ <KeyPad xterm={this.xterm} />
<Modal show={modal}>
<label class="file-label">
<input onChange={this.sendFile} class="file-input" type="file" multiple />
diff --git a/src/http.c b/src/http.c
index b9f620e..b6e93af 100644
--- a/src/http.c
+++ b/src/http.c
@@ -157,16 +157,16 @@ int callback_http(struct lws *wsi, enum lws_callback_reasons reason, void *user,
lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (const unsigned char *)content_type, 9, &p,
end))
return 1;
-#ifdef LWS_WITH_HTTP_STREAM_COMPRESSION
- if (!uncompress_html(&output, &output_len)) return 1;
-#else
if (accept_gzip(wsi)) {
+ // 浏览器支持 gzip: 直接发送内嵌的 gzip 数据 (html.h 本身就是 gzip 流),
+ // 避免解压成 3MB 裸 HTML 再传输, 公网慢链路下易被中途掐断
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_ENCODING, (unsigned char *)"gzip", 4, &p, end))
return 1;
+ output = (char *)index_html;
+ output_len = index_html_len;
} else {
if (!uncompress_html(&output, &output_len)) return 1;
}
-#endif
if (lws_add_http_header_content_length(wsi, (unsigned long)output_len, &p, end) ||
lws_finalize_http_header(wsi, &p, end) ||
diff --git a/src/protocol.c b/src/protocol.c
index adb7d63..72ad40f 100644
--- a/src/protocol.c
+++ b/src/protocol.c
@@ -191,8 +191,18 @@ static bool check_auth(struct lws *wsi, struct pss_tty *pss) {
if (server->credential != NULL) {
char buf[256];
- size_t n = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_AUTHORIZATION);
- return n >= 7 && strstr(buf, "Basic ") && !strcmp(buf + 6, server->credential);
+ int n = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_AUTHORIZATION);
+ if (n >= 7 && strstr(buf, "Basic ") && !strcmp(buf + 6, server->credential)) return true;
+ // Safari 等移动端浏览器的 WebSocket 升级请求不携带 Basic Auth 凭据,
+ // 放行带 token 查询参数的握手; 真实认证仍由 WS 首条消息的 AuthToken 校验完成。
+ // 查询参数读取在 lws 3.x/4.x 上 API 不同, 按版本选择
+ memset(buf, 0, sizeof(buf));
+#if defined(LWS_LIBRARY_VERSION_NUMBER) && LWS_LIBRARY_VERSION_NUMBER >= 40000000
+ if (lws_get_urlarg_by_name_safe(wsi, "token=", buf, (int)sizeof(buf) - 1) > 0) return true;
+#else
+ n = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_URI_ARGS);
+ if (n > 0 && strstr(buf, "token=") != NULL) return true;
+#endif
}
return true;
EOF
应用补丁:
bash
git apply diff.patch
提示:补丁包含 3 个前端源码文件(新增 keypad 组件两个文件 + 修改 terminal 组件一处接线)和 2 个后端 C 文件(
src/http.c修复 gzip 压缩、src/protocol.c修复手机浏览器 WebSocket 认证),对上游的侵入很小,后续 rebase 新版本也容易维护。
3.3 写入一键静态编译脚本
在仓库根目录保存为 build_static.sh:
bash
cat > build_static.sh << 'EOF'
#!/usr/bin/env bash
# 一键编译 ttyd 静态版 (musl 全静态链接, 不依赖任何 .so, 任意 x86_64 Linux 可运行)
# 原理: 在 Docker (ubuntu:20.04) 中运行 ttyd 官方 scripts/cross-build.sh,
# 用 musl 交叉工具链静态编译 zlib/json-c/libuv/openssl/libwebsockets 并链接进 ttyd
# 产物: ./ttyd-static
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_BIN="$ROOT_DIR/ttyd-static"
TARGET="${TARGET:-x86_64}" # 可改 aarch64 等其他架构
CONTAINER="ttyd-static-build"
log() { echo -e "\033[1;32m==> $*\033[0m"; }
fail() { echo -e "\033[1;31m错误: $*\033[0m" >&2; exit 1; }
# 源码目录: 脚本放在仓库根目录 (ttyd/) 或其旁边 (/ext/ttyd/) 均可
if [[ -f "$ROOT_DIR/scripts/cross-build.sh" ]]; then
SRC_DIR="$ROOT_DIR"
elif [[ -f "$ROOT_DIR/ttyd/scripts/cross-build.sh" ]]; then
SRC_DIR="$ROOT_DIR/ttyd"
else
fail "未找到 ttyd 源码 (需含 scripts/cross-build.sh), 请将本脚本放在仓库根目录或其上一级"
fi
cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; }
trap cleanup EXIT
# ---------- 前置检查 ----------
command -v docker >/dev/null 2>&1 || fail "未安装 docker, 请先安装并启动 docker"
docker info >/dev/null 2>&1 || fail "docker 不可用 (守护进程未运行或无权限)"
[[ -f "$SRC_DIR/scripts/cross-build.sh" ]] || fail "未找到 $SRC_DIR/scripts/cross-build.sh"
log "清理同名旧容器"
cleanup
log "在 Docker (ubuntu:20.04) 中静态编译 ttyd ($TARGET), 需要几分钟..."
docker run --name "$CONTAINER" -v "$SRC_DIR":/src ubuntu:20.04 bash -c '
set -e
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
build-essential curl xz-utils cmake autoconf automake libtool pkg-config ca-certificates >/dev/null
# ---------- 1. 构建前端并重新生成 src/html.h ----------
# 注意: 官方 cross-build.sh 不会构建前端, 它直接使用仓库中已提交的 src/html.h。
# 若改过 html/ 下的前端代码 (如本补丁新增的按键面板), 必须先重新生成 html.h,
# 否则改动不会进入二进制!
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs >/dev/null
cd /src/html
npm install --no-audit --no-fund >/dev/null
npx webpack --mode production >/dev/null
npx gulp >/dev/null
# 自检: html.h 是 gzip 压缩后的十六进制数组, 明文 grep 不到, 故检查明文的 dist/app.js
grep -q keypad dist/app.js && echo "前端已包含 keypad 面板" || { echo "前端构建缺少 keypad"; exit 1; }
cd /src
# ---------- 2. musl 静态交叉编译 ----------
env BUILD_TARGET='"$TARGET"' ./scripts/cross-build.sh
'
# ---------- 拷出产物 ----------
docker cp "$CONTAINER:/opt/stage/${TARGET}-linux-musl/bin/ttyd" "$OUT_BIN"
chmod +x "$OUT_BIN"
# ---------- 验证 ----------
log "验证产物"
file "$OUT_BIN"
# 注意: ldd 对静态二进制返回非零退出码, 不能直接放进管道判断 (受 pipefail 影响)
LDD_OUT="$(ldd "$OUT_BIN" 2>&1 || true)"
if echo "$LDD_OUT" | grep -qi "not a dynamic executable"; then
log "确认: 全静态链接, 无动态库依赖"
else
fail "产物不是静态链接的, 请检查编译日志"
fi
"$OUT_BIN" --version | head -1 || true
log "编译成功: $OUT_BIN"
log "运行示例: $OUT_BIN -p 8081 -W -c user:pass bash"
EOF
chmod +x build_static.sh
3.4 执行编译
bash
bash build_static.sh
脚本会自动:检查 Docker → 拉起 ubuntu:20.04 容器 → 安装编译工具 → 下载 musl 交叉工具链 → 静态编译 5 个依赖库 → 链接 ttyd → 拷出产物并自动验证。全程约 5~15 分钟(取决于网络和机器性能)。成功时输出类似:
==> 验证产物
/path/to/ttyd-static: ELF 64-bit LSB executable, x86-64, statically linked, stripped
==> 确认: 全静态链接, 无动态库依赖
ttyd version 1.7.7
==> 编译成功: /path/to/ttyd-static
3.5 运行验证
目标机器上(任何 x86_64 Linux,无需安装任何依赖):
bash
# -W 开启可写模式 (不加是只读, 无法输入!); -c 设置访问认证
pkill -f ttyd-static
./ttyd-static -p 8081 -W -c user:pass bash
浏览器(手机或 PC)打开 http://<主机IP>:8081/,登录后即可看到右下角按键面板。搭配 tmux 使用:
bash
tmux new -s work # 首次
tmux attach -t work # 重连后恢复会话
点 ▶ 应切换到右侧窗格,点 Zoom 应放大当前窗格,再点一次恢复。
4. 注意事项
- 务必加
-W参数 。ttyd 默认是只读模式,不加-W页面能看但完全无法输入------这是最常见的"连上了但打不了字"的原因。 - 安全提醒 :
-W意味着任何通过认证的人都能完全控制宿主机 shell。-c user:pass的 Basic Auth 走的是 HTTP 头(HTTPS 下才加密),请务必:不要把端口裸露到公网;如需公网访问,套一层 HTTPS 反向代理(如 nginx + Let's Encrypt)。本文为兼容手机浏览器放行了带token=查询参数的 WS 握手,但实际认证仍由 WS 首条消息的AuthToken校验兜底,攻击者仅凭握手成功拿不到任何终端数据;不过查询参数可能进入访问日志,介意的话公网部署仍建议走 HTTPS。 - 前端改动的构建链路 :ttyd 的前端不是运行时从目录读取的,而是编译期经
webpack → gulp 压缩 → 生成 src/html.h → 链接进二进制。上游仓库预提交了一份旧的src/html.h,官方cross-build.sh不会构建前端、直接使用它 ------这正是"改了前端但页面没变化"的头号原因。本文的build_static.sh已在交叉编译前加入前端构建阶段(Node.js + webpack + gulp 重新生成 html.h),并自检产物中确实包含按键面板代码。 - 上游兼容性 :补丁基于本文写作时的 master(1.7.7+)。ttyd 前端依赖版本演进较快(如 Preact 的
RefCallback类型收紧),若在新版上git apply成功但编译报错,多半是上游依赖变了,按报错微调即可。 ldd判断静态的一个坑 :ldd对静态二进制会返回非零退出码 ,在set -o pipefail的脚本里直接写if ldd xx | grep ...会被误判为失败,需要先捕获输出再判断(脚本里已处理)。- 其他架构 :
TARGET=aarch64 bash build_static.sh即可构建 ARM64 静态版,脚本和官方交叉编译链均支持 arm/mips/ppc 等多种架构。 - CentOS 7 等 EOL 系统:静态二进制不依赖系统库,在 EOL 老系统上反而是最省心的部署方式------这也是做全静态的初衷之一。
- 按键面板可定制 :面板按键定义在
html/src/components/keypad/index.tsx顶部的ARROWS常量和各按钮的send()调用里,改字符串即可换键位(例如把中间键改回d即为 Detach)。