给 ttyd 加一个手机触屏虚拟按键面板,并构建全静态可移植二进制

给 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 都能跑的。

于是定下两个目标:

  1. 在 ttyd Web 页面上加一个触屏虚拟按键面板,点按即发送 tmux 组合键,效果等同物理键盘;
  2. 构建全静态链接的发布产物,包含上述新功能。

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 中:

  1. 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%)。

  2. 手机浏览器连不上,日志报 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. 注意事项

  1. 务必加 -W 参数 。ttyd 默认是只读模式,不加 -W 页面能看但完全无法输入------这是最常见的"连上了但打不了字"的原因。
  2. 安全提醒 :-W 意味着任何通过认证的人都能完全控制宿主机 shell。-c user:pass 的 Basic Auth 走的是 HTTP 头(HTTPS 下才加密),请务必:不要把端口裸露到公网;如需公网访问,套一层 HTTPS 反向代理(如 nginx + Let's Encrypt)。本文为兼容手机浏览器放行了带 token= 查询参数的 WS 握手,但实际认证仍由 WS 首条消息的 AuthToken 校验兜底,攻击者仅凭握手成功拿不到任何终端数据;不过查询参数可能进入访问日志,介意的话公网部署仍建议走 HTTPS。
  3. 前端改动的构建链路 :ttyd 的前端不是运行时从目录读取的,而是编译期经 webpack → gulp 压缩 → 生成 src/html.h → 链接进二进制。上游仓库预提交了一份旧的 src/html.h,官方 cross-build.sh 不会构建前端、直接使用它 ------这正是"改了前端但页面没变化"的头号原因。本文的 build_static.sh 已在交叉编译前加入前端构建阶段(Node.js + webpack + gulp 重新生成 html.h),并自检产物中确实包含按键面板代码。
  4. 上游兼容性 :补丁基于本文写作时的 master(1.7.7+)。ttyd 前端依赖版本演进较快(如 Preact 的 RefCallback 类型收紧),若在新版上 git apply 成功但编译报错,多半是上游依赖变了,按报错微调即可。
  5. ldd 判断静态的一个坑 :ldd 对静态二进制会返回非零退出码 ,在 set -o pipefail 的脚本里直接写 if ldd xx | grep ... 会被误判为失败,需要先捕获输出再判断(脚本里已处理)。
  6. 其他架构 :TARGET=aarch64 bash build_static.sh 即可构建 ARM64 静态版,脚本和官方交叉编译链均支持 arm/mips/ppc 等多种架构。
  7. CentOS 7 等 EOL 系统:静态二进制不依赖系统库,在 EOL 老系统上反而是最省心的部署方式------这也是做全静态的初衷之一。
  8. 按键面板可定制 :面板按键定义在 html/src/components/keypad/index.tsx 顶部的 ARROWS 常量和各按钮的 send() 调用里,改字符串即可换键位(例如把中间键改回 d 即为 Detach)。
相关推荐
beckyye1 年前
WebSSH的简单实现
ssh·webssh·xterm.js·远程终端
失去的青春---夕阳下的奔跑3 年前
webssh 通过web使用终端命令
web·webssh