什么时候你会需要「浏览器里的终端」
对个人站长来说,总有那么几个场景:你在同事的电脑上、在只带了一个浏览器的公共机器上、或者干脆在手机上,突然需要登进服务器看一眼日志、重启一个服务、改一行配置。装 SSH 客户端不方便,网页控制台又是云厂商那种卡顿的 VNC。ttyd 解决的正是这个问题——它把任意命令行程序(本质上是 bash / tmux)通过 WebSocket 暴露成一个网页终端,你打开浏览器就能得到一个完整、流畅、响应迅速的 shell。
ttyd 用 C 写成,单二进制、依赖极少(只需要 libwebsockets 和 libuv),内存占用比 Node 系的 web 终端(如 wetty)低一个数量级,几十兆之内就能跑起来。它支持中文、支持复制粘贴、支持多客户端共享同一会话(配合 tmux 时非常有用),也支持只读模式。这篇文章从部署讲到 Nginx 反代,再到最重要的安全加固——因为把一个 shell 挂到公网上,安全做不好就是给服务器开后门。
最简部署:先在本机 127.0.0.1 上跑起来
永远不要一上来就 -p 80 直接对外。正确的顺序是:先绑定到本机回环地址验证,再通过反向代理暴露,最后加认证。安装:
# Debian/Ubuntu 仓库里的版本(够用,但可能偏旧)
sudo apt-get update && sudo apt-get install -y ttyd
# 或用官方最新静态二进制
curl -fsSL -o /usr/local/bin/ttyd \
https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64
chmod +x /usr/local/bin/ttyd
ttyd --version先做一个「只读、只绑定本机」的冒烟测试,确认二进制能跑、网页能开:
# 绑定 127.0.0.1:7681,只允许看,不给交互
ttyd -p 7681 -i lo -R bash
# 另开一个终端验证端口
ss -ltnp | grep 7681
curl -sI http://127.0.0.1:7681/ | head -1这时候用 SSH 端口转发在自己电脑上看一眼效果:ssh -L 7681:127.0.0.1:7681 you@server,然后浏览器开 http://localhost:7681。确认界面正常、中文不乱码、能输入命令(-R 会让它变只读,去掉即可交互),再进入下一步。
用 systemd 常驻,并通过开关控制启动
不要让 ttyd 裸跑在 shell 里,用 systemd 管起来。关键点:绑定到回环、指定工作目录、限制资源、开启只读默认。真正对外的那一层交给 Nginx,ttyd 自己只监听 127.0.0.1。
# /etc/systemd/system/ttyd.service
[Unit]
Description=ttyd web terminal
After=network.target
[Service]
Type=simple
# 只绑定回环;用 tmux 让多客户端共享/断线不丢会话
ExecStart=/usr/local/bin/ttyd \
--interface 127.0.0.1 \
--port 7681 \
--writable \
--max-clients 3 \
tmux new -A -s webterm
Restart=on-failure
RestartSec=3
# 降权运行:专用低权限用户,绝不用 root
User=webterm
Group=webterm
WorkingDirectory=/home/webterm
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target注意 tmux new -A -s webterm 这个命令:-A 表示「会话已存在就 attach,不存在就新建」,-s webterm 是会话名。这样一来,你从浏览器连上去、平板再连一次,看到的是同一个 tmux 会话,而不是两个互相独立的 shell;网络断了重连,之前跑的命令和输出还在。这是网页终端体验好坏的分水岭。
# 创建专用低权限用户
sudo useradd -r -m -s /bin/bash webterm
sudo systemctl daemon-reload
sudo systemctl enable --now ttyd
sudo systemctl status ttyd --no-pager特别提醒:--writable 给的是交互式 shell,不是只读预览。这意味着任何能连上的人都能以 webterm 用户的身份执行命令。所以下面的认证和网络层加固不是可选项。
Nginx 反代:路径、WebSocket 升级与超时
ttyd 内部用的是 WebSocket,Nginx 反代时最容易踩的坑是忘了传递 Upgrade 头,结果是页面能打开但一片空白、连不上终端。完整配置如下:
# /etc/nginx/conf.d/ttyd.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name term.example.com;
ssl_certificate /etc/letsencrypt/live/term.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/term.example.com/privkey.pem;
# 强烈建议:整个站点只允许特定 IP 访问
allow 203.0.113.10; # 你的固定出口 IP
allow 198.51.100.0/24; # 办公室网段
deny all;
location / {
proxy_pass http://127.0.0.1:7681;
proxy_http_version 1.1;
# 这两行是 WebSocket 的关键,缺了就连不上
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 终端长连接不能被 Nginx 掐断
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}四个要点逐一说清楚:
map $http_upgrade $connection_upgrade:把它放在http {}块里(放在server{}里会语法报错)。它负责在客户端发起 WebSocket 握手时把Connection: upgrade透传给后端。proxy_http_version 1.1:WebSocket 升级依赖 HTTP/1.1,默认的 1.0 不支持,必须显式写。proxy_read_timeout:默认 60 秒,终端一旦 60 秒没数据就会被断开。改成 3600s 让长连接稳定存活。proxy_buffering off:终端输出要实时到达浏览器,开启缓冲会造成明显延迟。终端和普通网站不同,它的响应是对「输入」逐字流式回显的,任何一层缓冲都会把这种即时感破坏掉——你按下回车后命令输出要停顿一两秒才出现,用起来非常难受,所以这个开关在网页终端场景里必须关。
还有一个容易被忽略的细节是 proxy_set_header Host $host; 这一行。ttyd 内部在生成 WebSocket 握手地址时会参考 Host 头,如果反代时 Host 被改成了 127.0.0.1,某些版本的前端就会把 WebSocket 连到错误的目标上,表现为页面打开、界面正常、但终端永远「正在连接」。保留原始 Host 能避开这个隐蔽问题。
配好之后 nginx -t && systemctl reload nginx,从允许的 IP 打开 https://term.example.com 验证。allow/deny 是这里第一道、也是最有效的一道防线——把攻击面从「全世界」直接缩小到「你的几个 IP」。
认证加固:三层防护,缺一不可
把 shell 挂上公网,认证是生死线。推荐叠三层,任何一层被绕过都还有下一层兜底。
第一层:Nginx 基础认证(Basic Auth)。
sudo apt-get install -y apache2-utils
# 用户名 webadmin,密码交互式输入(不要写进命令历史)
sudo htpasswd -c /etc/nginx/.htpasswd webadmin
# 在 location / 里加上:
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;Basic Auth 的密码是 base64 而非加密传输,所以必须配合 HTTPS(上面已经配了)。它的意义在于:即使有人猜到你的 URL,没有密码也连不上 WebSocket。
第二层:ttyd 自带凭据。ttyd 支持 --credential user:pass,让后端自己也校验一次。这样即便 Nginx 配置被改动、认证被摘掉,ttyd 仍然拒绝未授权连接:
# 在 ExecStart 里加(注意:密码会出现在 systemd unit 中,注意文件权限)
--credential webadmin:一个足够长的随机密码第三层:只读降级。若你的场景只是「看日志」,就不要开 --writable,用 -R 只读模式。只读模式下即便被攻破,攻击者也只能看,不能执行命令。很多人给 ttyd 开通写权限只是为了查日志,这是没必要的暴露。
此外还有两个容易忽略的点:(1)低权限用户 webterm 不应在 sudoers 里,并且它的 shell 环境要尽量干净,不要 source 到含有敏感 token 的 rc;(2)ttyd 能访问的文件取决于 webterm 用户的权限,所以它天然看不到只有 root 能读的 /etc/shadow、网站私钥等——这个权限隔离本身就是一层保护,别图省事用 root 跑。
安全审计:开完 ttyd 一定要做的几件事
部署完成后,做一次自查,把每一项都落实:
- 端口暴露检查:
ss -ltnp | grep 7681应显示127.0.0.1:7681,绝不能是0.0.0.0:7681。是后者说明--interface没生效或被防火墙之外的路径暴露了。 - 防火墙:用
ufw或nftables只放行 443,7681 应对公网完全关闭。sudo ufw status verbose确认。 - 访问日志:给 location 单独配日志,
access_log /var/log/nginx/ttyd.access.log;,一旦有异常连接能追到 IP。 - 失败封锁:给这个 server 块加 fail2ban 规则,连续多次 401 就封 IP,抵御撞库。
- 会话隔离:给 ttyd 加
--max-clients 2,避免有人疯狂开连接耗尽资源。 - 审计更危险的操作:如果
webterm有 sudo 权限,配合auditd记录其执行过的命令。
# 快速验证清单
ss -ltnp | grep 7681 # 必须是 127.0.0.1
sudo ufw status verbose # 7681 应无公网规则
curl -sI https://term.example.com | head -1 # 应 401(未认证)
curl -s -o /dev/null -w '%{http_code}\n' \
-u webadmin:wrongpw https://term.example.com # 应 401和其他方案的取舍:ttyd vs wetty vs SSH 隧道 vs 云控制台
要不要上 ttyd,取决于你的约束。wetty 基于 Node.js,配置更复杂、内存占用更高,没有明显优势;纯 SSH 隧道(ssh -L)不需要在服务器上开任何额外端口,安全性最高,但前提是你能用 SSH 客户端,手机上体验较差;云厂商控制台(VNC/串口)不依赖系统里的任何服务,是最后的救命稻草,但卡顿且复制粘贴困难。ttyd 的独特价值在于「只要有浏览器就能用、并且能做得相对安全」——尤其适合手机上临时看一眼日志、或面对一台无法装 SSH 客户端的机器。
要强调的是,ttyd 不能替代 SSH。它是「临时 / 应急 / 移动端」通道,日常运维仍然应该走 SSH + 密钥。安全地使用 ttyd 的公式是:绑定回环 + Nginx TLS + IP 白名单 + 双层认证 + 低权限用户 + 只读优先,六项齐全才推荐对外。少任何一项,都是在公网上给服务器留一个可能通向 root 的入口。
小结
ttyd 用极小的体积(单二进制、几十兆内存)把服务器终端搬进了浏览器,配合 tmux 还能做到「多端共享、断线不丢会话」。部署只需三步——本机冒烟、systemd 常驻、Nginx 反代——但真正决定成败的是安全加固:WebSocket 反代的两个头、read_timeout 的调整、认证三层叠加、以及最重要的「永远绑定回环、永远别用 root」。把这套流程走通,你就在不牺牲安全的前提下,给自己多了一个随手可用的运维入口。