一个自托管的 Web 终端:会话活在 tmux 里,不在浏览器标签页里。
关掉网页、断网、换台设备、甚至重启 webmux 自己 —— 命令照跑,输出一条不丢。 重新打开,接着看。
✅ 适合,如果你:
- 有一台常开的机器(家里的 Mac mini / NAS / 云服务器),想随时从任何设备的浏览器连上去
- 被 SSH 客户端烦过:换台设备就要重新配密钥,断个网就丢一屏输出,
tmux又要多记一套快捷键 - 想让跑了一半的东西活过下班 —— 长任务、编译、训练、
tail -f,合上笔记本明天接着看 - 顺手还想管管机器上的文件:传上去、拖下来、在线看一眼、打包下载
- 想给别人一个下载链接,但不想给对方一个账号
❌ 不适合,如果你:
- 只需要一个本地终端 —— 那你不需要 webmux
- 想要开箱即用的多用户 / 权限体系 —— webmux 是单用户的,用户的机器就是它的信任边界,这是刻意的
- 不想自己管 TLS 和反向代理 —— webmux 不内置 TLS,公网部署要自己套一层(见部署)
- 需要 Windows 被控端 —— 依赖 tmux,暂不支持
一句话判断:如果你会用
tmux但又嫌它麻烦,这就是给你做的。
这是整个项目的核心。大多数 Web 终端在"浏览器断开"这件事上偷懒:刷新丢一屏历史,断网重连花屏。 webmux 把它当成要正面解决的问题 —— 短暂断网只补发缺失的那几 KB,屏幕上完全看不出断过; 离开太久则直接把服务端保存的完整终端状态(含滚动历史和颜色)重绘回来。
配合 tmux,最终效果是:合上笔记本,第二天打开网页,昨天那条命令还在跑。
- 多会话并存,侧栏一眼看清每个会话此刻在哪个目录(在会话里
cd之后侧栏跟着变) - 会话可以开成树:在任意会话上点
+,就在它当前所在的目录再开一条 shell, 缩进挂在它下面。从子会话再开,则与子会话同级 —— 缩进永远只有一层(见设计取舍)。 会话默认以所在目录命名,重名自动加序号,随时可以改名 - 多设备同时连同一会话,尺寸自动协商
- 关掉 webmux 服务端也不影响 —— tmux 是独立守护进程,重启后自动接管原有会话
- 浏览、上传(断点续传,大文件传一半断了能接着传;文件和整个文件夹,选目录或直接拖进来)、下载、重命名、移动、删除
- 点击文件是预览而不是下载:图片、PDF、音视频就地打开(视频能拖进度条),
文本和源码用等宽字体直接看。
.html/.svg也归在"文本"里 —— 它们被当作text/plain发出去并配nosniff,所以"看一个网页文件的源码"既安全又有用 - 打包下载走流式 zip,全程不落盘。动手写第一个字节之前会先把整棵树扫一遍 —— 这是"能给出诚实错误"的前提:不可读的文件会在还能返回 403 的时候就说出来, 太大的树会被真实状态码拒绝
- 解压的目标目录永远是本次请求新建的(已存在就报错),因此失败回滚就是删掉一个 我们自己从头创建的路径
- 收藏目录、最近打开,一点就跳回去
- 整个文件 API 跑在路径牢笼里:只能落在你配置的根内,不跟随指向根外的符号链接,数据目录永远不可达
把一个文件或目录,通过一条无需登录的 URL 给出去。可以设有效期、下载次数上限、限速、密码, 随时撤销。
- 链接只显示一次 —— 服务端只存 token 的 sha256,拿到数据库文件也换不出可用链接
- 分享页是服务端渲染的纯 HTML,不加载 SPA、不跑任何脚本,配
default-src 'none'的 CSP - 页面上永远不出现绝对路径或根目录名
软键盘没有 Esc、没有 Tab、没有方向键、没有 Ctrl —— 一个终端在手机上因此基本没法用。
所以有那条快捷键条:粘滞的 Ctrl / Alt(点一下再按字母就是组合键)、Esc / Tab / 方向键 / | ~ - / / ^C。
而且可以定制:把你最常用的命令做成按键,点一下就发送。存在服务端,所以桌面上配好,手机上直接用。
关掉网页让命令继续跑,本来是件好事 —— 直到你开始惦记它到底跑完没有。 所以在命令结束的地方说一声就行:
make -j8 && webmux notify "构建成功" || webmux notify --level error "构建失败"浏览器里立刻弹提示,未读数挂在标题上;点一下通知,直接跳回那条终端。 手机锁屏、页面关掉也能收到 —— 但那一步需要 HTTPS,见「部署到服务器」。
不装 CLI 也可以,直接 curl:纯文本正文就是通知内容,不用拼 JSON。
curl -sS -H 'Content-Type: text/plain' \
-H "X-Webmux-Token: $(cat ~/.local/share/webmux/notify-token)" \
-d '构建完成' http://127.0.0.1:8866/api/notify想让长命令自动通知,在 ~/.bashrc 末尾加上这段(可选,webmux 本身不做 shell 集成):
# 失败的命令立刻通知;超过 10 秒的命令跑完通知。正文里带命令本身和耗时。
_webmux_preexec() {
[ -n "$COMP_LINE" ] && return # 正在补全
[ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return # 见下面的注释
[ "${BASH_COMMAND#_webmux_precmd}" != "$BASH_COMMAND" ] && return
_webmux_t0=$EPOCHSECONDS
}
_webmux_precmd() {
local code=$? took
[ -n "$_webmux_t0" ] || return
took=$(( EPOCHSECONDS - _webmux_t0 )); _webmux_t0=
{ [ "$code" -ne 0 ] || [ "$took" -ge 10 ]; } || return
webmux notify --level "$([ "$code" -eq 0 ] && echo info || echo error)" \
--title "$([ "$code" -eq 0 ] && echo '命令完成' || echo "命令失败 $code")" \
--body "$(history 1 | sed 's/^ *[0-9]* *//') · ${took}s" &
}
trap '_webmux_preexec' DEBUG
PROMPT_COMMAND="_webmux_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"两个不显然的地方:时间必须在 DEBUG 陷阱里记(PROMPT_COMMAND 里记到的是"提示符出现的时间",
不是命令开始的时间),而 DEBUG 在 PROMPT_COMMAND 自己执行时也会触发,所以那两行 return 不能省。
需要 bash 5 的 EPOCHSECONDS;zsh 用 preexec/precmd 写更直接。
这个入口只接受本机调用:来源必须是回环地址、必须带令牌、且拒绝任何浏览器发来的请求。 它不是一条从公网打进来的路。
- 深浅主题,默认跟随系统,终端配色跟着一起换
- 字号可调,手机和桌面各有各的默认值(这个存在浏览器本地,因为两个设备本来就该不一样)
- PWA,装到主屏后是独立窗口,离线有兜底页
- 分享密码、登录限流、审计日志
./start.sh # 预检 → 装依赖 → 按需构建前端 → 启动,默认 http://localhost:8866装成系统服务(服务器上的推荐姿势,一条命令):
curl -fsSL https://raw.githubusercontent.com/SmartXiaoMing/webmux/main/install.sh | sudo bash它会检查并装好依赖(tmux 走发行版仓库;Node 太旧时解开官方压缩包放进自己的安装目录,
不动系统里的 node,也不加第三方 apt 源)、建服务用户、把源码放到 /opt/webmux、
构建前端、写好 systemd 单元并启动。幂等,重跑就是升级;--uninstall 卸载。
详见部署到服务器。
start.sh 只是把下面三步串起来并加上检查:Node 版本、tmux 是否存在、依赖是否需要安装
(node_modules 缺失,或 lockfile 比它新)、前端是否需要重建(dist 缺失,或源码比它新)。
它不是必需的 —— 手动那三步同样可以:
pnpm install
pnpm build # 构建前端(服务端不需要构建,用 tsx 直接跑 TS)
pnpm start # 启动服务| 要求 | |
|---|---|
| Node.js | 22 或更高 |
| tmux | 任意近期版本(brew install tmux / apt install tmux) |
| 系统 | Linux / macOS(被控端) |
首次打开会要求设置密码 —— 没有默认口令,不设置就无法使用。
better-sqlite3锁定在11.8.1,请不要顺手升上去。 12 及以后版本改为把预编译二进制直接打包进 npm 包,而那些二进制是在较新的发行版上编译的:13.0.3要求 GLIBC 2.34,而 Ubuntu 20.04 只有 2.31,装上会报version 'GLIBC_2.34' not found。11.8.1走prebuild-install,产物的最高要求是 GLIBC 2.29,20.04 满足。(两级版本要求的差异是实测二进制里的符号版本,不是猜的。) 如果你的发行版够新,去掉这个锁定不会有问题 —— 但那应当是一个明确的决定,而不是 一次自动升级的副作用。
开发模式(前端热更新,API 自动代理到后端):
pnpm dev
# 前端 http://localhost:5173,后端 127.0.0.1:8866默认监听 0.0.0.0(可用 WEBMUX_HOST 改回 127.0.0.1)。这是为了容器端口映射和
异机反代开箱可用,代价是开箱即暴露 —— 它给的可是一个 shell。所以启动时会打两条警告:
一条说没有 TLS,一条说在设密码之前任何人都能抢注这个实例。两条都在你把它放到可信网络
(或前面有 TLS / VPN)之后自然消失。
放到公网请务必套一层 TLS 反代。Caddy 最省事(自动 HTTPS,WebSocket 自动处理):
term.example.com {
reverse_proxy 127.0.0.1:8866
}Nginx 需要显式处理 WebSocket 升级:
location / {
proxy_pass http://127.0.0.1:8866;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_buffering off; # 否则终端输出会被缓冲,交互变得一卡一卡
proxy_read_timeout 3600s;
}更稳妥的做法是别直接暴露:用 WireGuard / Tailscale / Cloudflare Access 挡在前面。
手机推送要求 HTTPS,这不是 webmux 的选择。 Web Push 只在安全上下文里存在:
用 http://192.168.x.x:8866 打开时,浏览器里根本没有 pushManager 这个东西,
通知面板会直接显示"需要 HTTPS"。所以想要"页面关掉、手机锁屏也能收到",
前面得有一个带证书的入口(上面两种反代都行,Tailscale 的 https:// 名字也可以)。
iOS 还多一条:16.4 起才支持,而且必须先把页面添加到主屏幕。
页面内通知不受这些限制 —— 只要页面开着,明文 HTTP 也能弹。
服务端自己不提供 TLS,这是有意的:证书续期、密码套件、HTTP/2 这些交给成熟的反代
比在这里重做一遍可靠得多。代价是监听非回环地址时启动会打警告 ——
因为它无法区分「可信内网」和「公网」,而密码和终端内容在网络上是明文的。
那条警告不需要(也无法)关掉;把地址设回 127.0.0.1、或者确认前面确实有 TLS / VPN,
它就不再出现。
同理,套了 TLS 反代就必须开 WEBMUX_TRUST_PROXY=true。不开的后果有两层,
而且都不会报错:req.protocol 仍是 http,于是会话 cookie 不会带 Secure 标志
(同一个域名的明文请求也会把它带上),并且限流会把所有客户端算成同一个 IP
(req.ip 是反代的地址)。登录时如果检测到请求里有 X-Forwarded-Proto 却没开
trustProxy,日志里会有一条警告说明这件事。
但 trustProxy 和「直接可访问」不能同时成立。 开了它,Fastify 就会相信连上来的
任何人发的 X-Forwarded-For;而每次伪造一个新地址,按 IP 的登录限流就永远不触发
(实测:开 trustProxy 后 8 次错误密码配 8 个伪造地址全部返回 401,本该在第 6 次变成
429),攻击者只剩 scrypt 本身的成本挡着。所以推荐的做法是让反代连回环地址
(reverse_proxy 127.0.0.1:8866),webmux 绑定 127.0.0.1,两者就不再冲突。
如果反代必须在另一台机器上,就用防火墙把这个端口只放给反代的地址,
启动时也会有警告提醒你。
一条命令:
curl -fsSL https://raw.githubusercontent.com/SmartXiaoMing/webmux/main/install.sh | sudo bash已经 clone 下来的话,在仓库里跑 sudo ./install.sh 也一样。
它默认以「你」的身份运行服务(sudo 的那个调用者),不是建一个专用账号 ——
这正是重点:你在浏览器里拿到的就是你自己的 shell,同样的家目录、dotfiles、
SSH 密钥、项目文件。用一个专用账号会把你丢进一个空的家目录,然后让你逐个授权
那些你本来就该直接能访问的东西。
文件页的根目录默认就是你的家目录,并且会被显式写进
~/.local/share/webmux/config.json(而不是依赖隐式默认值)。要改:
sudo ./install.sh --root /srv/data --root /home/me # 两个根,可重复常用参数:--prefix /opt/webmux、--user <用户名>、--root <目录>(可重复)、
--port 8866、--data-dir <目录>、--no-deps(只检查依赖)、--uninstall。
脚本是幂等的 —— 重跑就是就地升级,且不会覆盖你已经改过的 config.json。
管道运行时参数要写在 bash -s -- 之后(否则 bash 会把它们当成自己的参数):
curl -fsSL https://raw.githubusercontent.com/SmartXiaoMing/webmux/main/install.sh \
| sudo bash -s -- --port 9000 --prefix /srv/webmux它具体做了什么,以及不想用脚本时的手动步骤:
仓库里带了一份可直接用的单元文件 deploy/webmux.service。完整流程:
1. 装依赖 —— Node 22+、tmux、pnpm:
# Debian / Ubuntu
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs tmux git
sudo corepack enable pnpm2. 想好用哪个账号。 就用你自己(推荐,也是 install.sh 的默认值)—— 浏览器里开出来的就是你的 shell。专用账号只在你确实想隔离时才有意义, 而且它必须有真实存在的家目录,否则数据目录建不出来、服务直接起不来。
3. 拉代码并以那个身份跑一次(装依赖、构建前端、设好密码):
sudo -u "$USER" git clone https://github.com/SmartXiaoMing/webmux /opt/webmux
cd /opt/webmux && sudo -u "$USER" ./start.sh看到 listening on http://… 就说明没问题,Ctrl-C 停掉。
4. 安装并启动:
sudo cp deploy/webmux.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now webmux代码不在 /opt/webmux、或想换个账号时,改单元文件里的
User= / Group= / WorkingDirectory= / ExecStart=。
Group= 要写该用户的主组(id -gn <用户>),不是同名组 ——
两者在不少系统上并不一样,而 User=x 配一个不存在的 Group=x 是个起不来的单元。
5. 看它活着没有:
systemctl status webmux
journalctl -u webmux -f # 实时日志6. 环境变量(可选)写进 /etc/webmux.env,单元文件会读它:
WEBMUX_HOST=127.0.0.1
WEBMUX_PORT=8866
# 前面有 TLS 反代时打开,见上面的说明
WEBMUX_TRUST_PROXY=true(单元文件里 EnvironmentFile=-/etc/webmux.env 的那个 - 表示文件不存在也能启动。)
7. 升级:
# 用 install.sh 装的:重跑一次即可(它会重新拉取、构建、重启)
curl -fsSL https://raw.githubusercontent.com/SmartXiaoMing/webmux/main/install.sh | sudo bash
# 手动装的:拉代码后重启,start.sh 会在启动时按需重装依赖、重建前端
sudo -u webmux git -C /opt/webmux pull
sudo systemctl restart webmux重启服务不会丢会话 —— 这正是整套设计的重点。systemctl restart webmux
之后,tmux 服务端与里面的进程原封不动,新的 webmux 进程启动时把它们接管回来。
改之前请先读那里的注释:
KillMode=process(默认是control-group)。tmux 服务端是被 webmux 启动的, 因而落在同一个 cgroup 里;用默认值的话,systemctl restart webmux会把整个 cgroup 信号一遍 —— tmux 连同里面所有 shell 一起被杀掉,正好毁掉 webmux 存在的理由。- 不要加
PrivateTmp=yes(任何 drop-in 里也别加)。tmux 的 socket 在/tmp, 而私有/tmp每次调用都是新的命名空间:重启后的 webmux 会在新/tmp里找不到 socket, 于是认为所有会话都消失了 —— 而它们其实还在跑,只是从此既连不上也杀不掉。
出于同样的理由,这个单元没有启用更强的沙箱(ProtectSystem=strict、
ProtectHome、ReadWritePaths 之类)。它们看起来都很值得加,但每一个都得按你的安装
重新推导一遍:服务要碰 tmux 的 socket、数据目录、以及每一个配置的文件根
(可能是 /var/log、外挂卷、NFS)。一个只在打开某个目录时才暴露问题的沙箱,
比没有沙箱更糟。
systemd 给服务的 PATH 很短,不包含这些工具装的 Node。症状是 start.sh 报
"找不到 node",而你在自己 shell 里敲 node -v 一切正常。在单元文件里补一行:
Environment=PATH=/home/webmux/.nvm/versions/node/v22.23.1/bin:/usr/local/bin:/usr/bin:/bin三层合并,后者覆盖前者:内置默认值 ← 配置文件 ← 环境变量。
配置文件默认在 <数据目录>/config.json,或由 WEBMUX_CONFIG 指定。
{
"host": "0.0.0.0",
"port": 8866,
"shell": "/bin/zsh",
"maxSessions": 32,
"scrollbackLines": 10000,
"ringBufferBytes": 1048576,
"sessionTtlHours": 168,
"trustProxy": false,
"allowedOrigins": [],
"files": {
"roots": [
{ "name": "home", "path": "/home/mi" },
{ "name": "logs", "path": "/var/log", "readonly": true }
],
"uploadTtlHours": 24,
"maxUploadBytes": null
},
"shares": {
"enabled": true,
"defaultTtlHours": 168,
"maxActive": 100,
"defaultMaxDownloads": null,
"defaultRateLimitBytesPerSec": 0,
"unlockTtlHours": 12,
"unlockFailures": 5,
"unlockWindowMinutes": 15,
"maxConcurrentVerifications": 2,
"globalVerifyPerMinute": 60,
"retentionDays": 30,
"inlinePreview": true
},
"notify": {
"enabled": true,
"retentionDays": 30,
"maxStored": 500,
"perMinute": 30,
"allowFrom": [],
"push": { "enabled": true, "subject": "mailto:webmux@localhost" }
}
}| 环境变量 | 说明 |
|---|---|
WEBMUX_HOST |
监听地址,默认 0.0.0.0(所有网卡)。只想本机访问就设 127.0.0.1 |
WEBMUX_PORT |
端口,默认 8866 |
WEBMUX_DATA_DIR |
数据目录(SQLite、配置)。默认 macOS 为 ~/Library/Application Support/webmux,Linux 为 ~/.local/share/webmux |
WEBMUX_SHELL |
新会话使用的 shell,默认取 $SHELL |
WEBMUX_TRUST_PROXY |
置于反向代理后时设为 true,否则登录限流会看到代理的 IP |
WEBMUX_LOG_LEVEL |
debug / info / warn / error |
WEBMUX_SHARES_ENABLED |
设为 false 关闭分享功能(/s/* 一律 404) |
WEBMUX_SHARES_DEFAULT_TTL_HOURS |
新建链接的默认有效期,默认 168(7 天) |
WEBMUX_SHARES_MAX_ACTIVE |
同时有效的链接上限,默认 100 |
WEBMUX_SHARES_DEFAULT_RATE_LIMIT |
默认限速(字节/秒),0 = 不限 |
WEBMUX_NOTIFY_ENABLED |
设为 false 关闭通知(/api/notify 一律 404) |
WEBMUX_NOTIFY_PUSH_ENABLED |
设为 false 只保留页面内通知,不发手机推送 |
WEBMUX_NOTIFY_PER_MINUTE |
通知入口的限流(条/分钟),默认 30 |
files 这一节只走配置文件 —— 根目录是一组带名字的路径,塞进环境变量只会变成
一段没人想读的 JSON 字符串。
| 字段 | 说明 |
|---|---|
files.roots[].name |
显示名,也是 UI 里标识一个根的键。重名会直接启动失败 |
files.roots[].path |
白名单根目录,启动时 realpath 一次 |
files.roots[].readonly |
只读根:UI 隐藏所有写操作,服务端也拒绝 |
files.uploadTtlHours |
传到一半就不管了的上传暂存区保留多久,默认 24 |
files.maxUploadBytes |
单个上传的大小上限,默认不限 |
不配置 files 时,可浏览的根就是 $HOME。 这不是权限边界 —— 你本来就有这台
机器的 shell —— 而是一道防误操作的护栏:API 里的一个 bug 不该比它该碰到的地方伸得更远。
真正被硬性挡住的是数据目录(SQLite 与审计日志所在处),它永远不可浏览,因为
webmux.db 里存着令牌签名密钥。
路径牢笼的规则只有两条:路径必须落在某个根内(按路径段比较,/home/mi2 不算在
/home/mi 里),以及不跟随指向根外的符号链接 —— 包括悬空的那种。删除和重命名
作用于符号链接本身而非它的目标,和 rm / mv 一致。删除目录必须显式带上
recursive,且任何已配置的根都删不掉。
| 字段 | 说明 |
|---|---|
notify.enabled |
关掉后 /api/notify 一律 404,前端也不再建事件通道 |
notify.retentionDays |
已读通知保留多久,默认 30。未读的不受这一条约束 —— 出门一个月回来,"构建失败"不该已经消失 |
notify.maxStored |
最多存多少条,默认 500(已读、未读各自计数),防止一个写错的脚本把库撑大 |
notify.perMinute |
入口限流,默认 30。0 = 不限,不建议:这是唯一挡住死循环脚本的东西 |
notify.allowFrom |
额外放行的来源(CIDR,如 172.17.0.0/16),默认只认回环。见下 |
notify.push.enabled |
关掉后服务端不发 Web Push,页面内通知不受影响 |
notify.push.subject |
VAPID 的 sub 声明,推送服务出问题时的联系人;不会真的发信 |
为什么要 allowFrom:本机调用是从源地址判断的,有两种情况它不成立 —— 绑到某个具体
的非回环地址(WEBMUX_HOST=192.168.1.5)时,同机脚本连过去源地址就是 192.168.1.5;
容器里跑 webmux、脚本在宿主机上时,源地址是桥网关。这两种情况用 allowFrom 显式放行,
放行之后仍然需要令牌。
反代之后要清楚一件事:nginx 从 127.0.0.1 连过来时,"本机"这个判断就不再区分内外了
(所以还会检查 X-Forwarded-For 的每一跳)。开了 trustProxy 时启动会打一条警告说明这点。
真正兜底的是令牌 —— 它只存在本机文件里,网页读不到。
这是一个远程 shell,请按对待 SSH 的标准对待它。
先说清楚传输这一层:webmux 自己不加密,它只讲明文 HTTP 和 WS。安全性由部署方式决定:
| 部署方式 | 密码与终端流量 |
|---|---|
WEBMUX_HOST=127.0.0.1(回环) |
不出本机,无需加密 |
| TLS 反代(Caddy / Nginx) | 加密,需设 WEBMUX_TRUST_PROXY=true |
| WireGuard / Tailscale | 由隧道加密 |
默认的 0.0.0.0,前面没有上述任何一层 |
明文,可被嗅探 |
默认值落在最后一行,所以启动时会打警告(见「部署到服务器」)。登录密码、会话 cookie、 以及终端里进出的一切都走这条通道;TLS 之前的部分没有任何保护。
还有一件只在开箱状态下存在的事:/api/auth/setup 只在密码已存在时才拒绝,
所以从第一次启动到你设好密码之间,谁能连上这个端口谁就能抢注这个实例。
在公网机器上那个窗口是以秒计的 —— 扫描器发现新监听端口很快。设好密码就没有这个问题了。
登录机制本身:
- 密码用 scrypt(Node 内置)哈希存储,登录失败限流 5 次 / 15 分钟 / IP
- 会话令牌是 httpOnly Cookie,改密码会使所有已签发的令牌立即失效
- 数据目录默认以 0700 创建(
~/.webmux),因为webmux.db里除了密码哈希还有 令牌签名密钥 —— 拿到那个文件就能伪造出一个有效的会话 cookie,不必知道密码。 所以别把数据目录放进共享位置,也别把它备份到不受控的地方。 - WebSocket 升级会校验
Origin—— WebSocket 不受同源策略和 CORS 约束, 少了这一步就是一个跨站 WebSocket 劫持漏洞,任何页面都能拿到你的 shell - 文件 API 走路径牢笼 —— 只能落在配置的根内,不跟随指向根外的符号链接 (含悬空链接),数据目录永远不可达。牢笼有一份独立的单测套件,见测试
- 下载一律
Content-Disposition: attachment+X-Content-Type-Options: nosniff; 文件名同时是下载 URL 的末段(/api/fs/download/<name>?path=)。有一类客户端 (安卓下载管理器、App 内置浏览器的下载桥)根本不读这个头,只按 URL 末段取名, 再按Content-Type补后缀 —— 只有?path=时你会得到一手机download.bin。 分享页同理(/s/<token>/raw/<name>,目录是<name>.zip)。服务端从不读取这一段, 发哪个文件仍然只由path决定 - 通知入口只收本机请求:来源地址必须是回环、
X-Forwarded-For的每一跳也得是回环、 必须带数据目录里那个 0600 的令牌,并且拒绝任何带Origin头的请求。 最后一条不是多余的:你浏览的任何网页都能向127.0.0.1:8866发 POST, 同源策略只拦读响应、不拦发请求。判定读的是 socket 对端地址而不是req.ip—— 开了trustProxy时后者就是X-Forwarded-For里的内容,由调用方自己填
密码不要用你的登录密码。 分享密码是要被输进一个通过 URL 抵达、可能被转发的页面的; 一旦它和实例密码是同一个秘密,一条泄漏的链接就是整台机器。webmux 不提供"使用我的登录密码" 这个选项。
如果开了密码,还得知道这件事:crypto.scrypt 的异步形式跑在 libuv 线程池上,而进程里
每一个 fs.promises 调用用的是同一批线程。所以一个免认证的密码校验接口如果不管,
洪水般的尝试不只是烧 CPU,它会把整个进程的文件操作一起拖死。因此有三层:
并发闸门(同时最多 2 个校验,超出立刻 429 而不排队)、按 IP+分享 的失败限流
(5 次 / 15 分钟)、以及每分钟全局校验预算(默认 60,设为 0 关闭)。
key 里必须带分享 ID:只用 IP 的话,在默认的 trustProxy: false 下所有人的 IP 都是反代的
地址,五个人猜错就会锁死全站所有分享。
大多数 Web 终端在这件事上偷懒:刷新丢一屏历史,断网重连花屏。webmux 靠三样东西配合解决:
浏览器 ──WebSocket──> webmux ──node-pty──> tmux server ──> 你的 shell
│ ↑
└── 关掉/断网/刷新都行 进程一直在这里跑
- seq 序号 —— 会话的输出是一条字节流,客户端记住自己渲染到第几个字节。
- 环形缓冲 —— 服务端保留最近 1 MB 输出。短暂断网重连时,只补发缺失的那一段, 屏幕上完全看不出断过。
- 无头终端镜像 —— 服务端用
@xterm/headless维护一份终端的完整状态(含滚动历史 和颜色)。客户端离开太久、缺口已被淘汰时,直接把这份状态序列化成 ANSI 序列发过去, 一次重绘恢复整个屏幕。
为什么用 tmux 而不是自己管 PTY:kill 掉 pty 只终止 tmux 客户端 进程;tmux server 是独立 daemon,它持有会话与子进程。所以不只关网页不影响,连 webmux 服务端重启都不影响 —— 这是相对自研 PTY 管理器的关键优势。
- 会话树只有一层。在会话上点
+会在它当前所在的目录开一条新 shell,缩进挂在它下面; 但从一个子会话再开,新的会与那个子会话同级,而不是继续往下缩进。理由是这个动作太顺手了 —— 「在当前 shell 里再开一个」会变成习惯 —— 而没有这条规则,几天的使用就会长出一段 288px 侧栏根本显示不下的阶梯。父会话被杀掉时子会话自动回到顶层,因为树只是展示方式, 一个谁也看不见的会话比一个位置不完美的会话糟得多。 - 侧栏顺序是「收藏目录 / 会话 / 已打开目录」,且三者都可折叠。收藏目录默认折叠:
它是一个在被星标之前一直是空的快捷列表,而展开着的空盒子是侧栏里最贵的那种空间。
标题上的计数
(n)就是为此存在的 —— 折叠不该等于看不见。折叠状态每次加载都回到默认, 没有持久化:这三个区块的取舍按场景变化,不值得为它再加一份本机状态。 - 文件列表默认不显示隐藏文件。一个装满
.git、.next、编辑器状态的目录会把真正要找的 那几个文件淹掉;工具栏的眼睛图标随时能打开(图标变暗表示"当前隐藏中")。 - 会话从文件页创建:在「文件」里进入想要的目录,点工具条上的终端图标,
就在那个目录开一条 shell。侧栏没有「新建」按钮 —— 一条终端只有开在你想要的目录里才有用,
而在侧栏建要么得替你猜一个目录、要么让你选两次。
文件页给出的目录只可能来自文件 API,也就是只可能是路径牢笼内的。
但会话树让情况变了:从会话上点
+时,新会话的目录是父会话当前所在的目录 —— 那是 shellcd到过的任何地方,可能已经被删掉。所以服务端现在会校验这个路径是不是 一个真实存在的目录,不是就回落到$HOME并记一条告警。 这条校验是被一个 bug 逼出来的:曾经以为「给 tmux 一个不存在的-c,它会静默回落到$HOME」—— 它不会。它起一个永远不打印提示符的 pane,于是终端一片空白, 而所有日志里什么都没有。测试里现在有一条专门钉住这个(断言的是 shell 能应答, 不是「渲染出了字节」—— 死掉的 pane 一样会渲染转义序列)。 - 侧栏每个会话下面显示它此刻所在的目录,而不是创建时选的那个。在会话里
cd之后侧栏跟着变: 有多个会话时,「哪个是 webmux 仓库」正是最需要一眼看出来的信息。这个值由服务端另行定时刷新, 而不是在GET /api/sessions里现查 tmux —— 那个接口同时是客户端的存活探针, 让它可能卡在 tmux 上,会让「服务端卡住」和「服务端挂了」在界面上分不出来。 路径按尾部截断:从右边截的话,同一个父目录下的两个会话会显示成一模一样, 而那个区别恰好是这一行存在的理由;完整路径留在悬停提示里。 - 终端字号存在浏览器本地而不是服务端 —— 手机和桌面本来就需要不同的字号,同步反而是错的。 而快捷键和收藏目录存在服务端,因为「在桌面配好、手机直接用」才是它们存在的意义。
- 快捷键发送的是
\r而不是\n:终端回车发的本来就是 CR,tty 再转成 LF, 而直接发 LF 在 vim、less 这类 raw 模式程序里等于 Ctrl+J,是另一个键。 编辑器按钮放在横向滚动区之外,所以手机上不滑动也点得到。 - 文件页没有「打包整个目录」的工具条按钮 —— 它离「把整个根目录打包」只有一次误点,
而那个下载会先遍历整棵树。按条目的打包在每行的「操作」菜单里,它会指明要打包什么。
工具条是纯图标(有
title与aria-label),删除仍然要两步确认 —— 收进菜单不是把它变成一键的理由。 - Service worker 只做一件事:离线兜底。它不是版本管理也不是性能手段 —— 导航走
network-first,所以在线用户永远拿到当前版本。
/api、/ws、/s一律不拦截也不缓存: 分享页的 URL 本身就是凭证,服务端刻意发了no-store,缓存它会同时废掉撤销、过期和下载上限。 - 主题首屏由
index.html里一段同步脚本上色 —— 它必须在渲染之前跑,所以不能是 React effect, 也不能是type="module"(模块是 deferred 的,那时浏览器已经画错了一帧)。 - 分享页有自己的深色调色板,不跟随应用主题:它是另一份服务端渲染的文档。
packages/
├─ shared/ 协议定义(WS 消息 + REST DTO),前后端共享,改一处两边同时报错
├─ server/ Fastify + node-pty + tmux + SQLite
│ └─ src/
│ ├─ terminal/ backend 抽象、tmux 实现、会话、环形缓冲
│ ├─ ws/ WebSocket 网关(重连协议在这里落地)
│ ├─ auth/ 密码、令牌、限流
│ ├─ fs/ 路径牢笼、目录操作、下载流、分片上传
│ ├─ fs/zip/ zip 读写与解压(纯模块,可拿内存 Buffer 直接测)
│ ├─ shares/ 分享:存储、token、公开页渲染、访问判定、校验闸门
│ └─ db/ SQLite 与迁移
└─ web/ React + xterm.js
└─ src/
├─ lib/ API 客户端、带重连的终端连接、断点续传上传器
└─ features/ 终端 / 会话列表 / 文件管理 / 分享 / 登录
packages/server/src/http/shares-public.ts 是一个刻意的例外:它不按其他模块的
registerXxxRoutes(app, ctx) 模板写,而是包成一个 prefix: '/s' 的作用域插件 ——
因为"作用域"本身就是目的:两条公开路由和表单 body 解析器都被关在这一个面里,
authenticated 的 API 永远不可能因为疏忽长出一个 urlencoded 解析器。
SessionBackend 接口(server/src/terminal/backend.ts)把所有 tmux 细节挡在里面。
将来若要支持 Windows(ConPTY)或换成自研的常驻进程,换一个实现即可,上层不动。
pnpm test # 全部packages/server/test/protocol.e2e.mjs—— 协议端到端测试。跑的是真实服务 + 真实 tmux + 真实 PTY,覆盖断线重连的每条分支(精确补发、淘汰后快照、越界序号)与 Origin 校验。packages/server/test/jail.test.mjs—— 路径牢笼的独立单测。不起服务、不碰 tmux, 直接拿夹具打解析器:..穿越、绝对路径注入、NUL 字节、符号链接逃逸与环、 悬空符号链接、反斜杠是普通字符、前缀碰撞、只读根、数据目录拒绝。 这些用例在 HTTP 层根本构造不出来,所以它必须独立存在。packages/server/test/files.e2e.mjs—— 文件 API 端到端。逐路由的 401 回归、 URL 层路径攻击、分页走到底无重无漏、Range 与 RFC 5987 中文名、 上传后与源文件逐字节比对、断点续传、以及"递归删除不穿符号链接"。packages/server/test/zip.test.mjs—— zip 读写的纯单测。CRC32 拿zlib.crc32交叉验证,增量折叠在各种分块边界下与一次性相等;攻击用例全部针对内存里的字节流构造 ——..穿越、绝对路径、反斜杠、NUL、符号链接条目、加密、Zip64、多卷、条目区间重叠、 局部头与中央目录名字不一致、以及"声明 1 KB 实际膨胀 10 MB"的炸弹。packages/server/test/zip-interop.test.mjs—— 打包功能的验收标准。 往返测试几乎没有证据价值(写和读都是我们自己的,会以同样的方式一起错), 所以这里把三个外部实现拉进来:我们产出的归档必须能被unzip -t、unzip -o与 Pythonzipfile.testzip()干净读出来,中文名与 emoji 名不许变;zip(1)产出的 (deflate 与 store 两种)我们必须能读。缺工具时会大声跳过,WEBMUX_REQUIRE_TOOLS=1把跳过变成硬失败。packages/server/test/shares.e2e.mjs—— 分享链接端到端。这个套件的写法和其他几个相反: 有意思的用例都是"完全没有 cookie"的那些,而主人那侧的路由才是该被锁住的。 覆盖:匿名访问、token 不出现在列表里、撤销 / 过期 / 源消失、maxDownloads: 1下 并发发 10 个请求只能过一个(这条在朴素的读-改-写实现上必挂)、Range 不吃配额、 密码对错、A 分享的 cookie 拿到 B 上用要 401、文件名 XSS 被转义、限速真的限住了 socket、重新生成。packages/server/test/shares.test.mjs—— 分享机制的纯单测。其中承重的一对是: 会话 token 不能解锁分享、分享 cookie 不能当会话用 —— 复用同一个签名密钥之所以安全, 全靠这两条断言,删了它们,"安全"就退化成"顺手"。packages/web/test/—— 浏览器验收测试(Playwright)。验证关页面后命令继续跑、 服务端重启后自动重连、手机视口布局可用,以及在真实浏览器里走完 浏览 → 新建 → 上传 → 下载 → 删除 → 预览 → 移动 → 打包 → 解压, 以及用一个完全没有 cookie 的浏览器上下文打开分享链接并下载。
- 不支持 Windows(依赖 tmux;需要自研 ConPTY 后端)
- 多客户端同时打开同一会话时,尺寸以最后调整者为准,另一个客户端会看到裁剪
- 重连快照默认携带最近 3000 行历史,更早的滚动内容不会恢复
- 手机上不能用长按选择终端文本,复制也就无从谈起。滚动由一层覆盖层提供(xterm 6 自己没有触摸滚动),而覆盖层一接手触摸,长按选择就没了 —— 终端是 canvas,没有文本节点, 而 iOS 只为真实文本选择显示 Copy 气泡。桌面不受影响。要做的话得配一个快捷键条上的 「复制」按钮,那是另一件事
- iOS 独立模式下状态栏样式由
apple-mobile-web-app-status-bar-style决定, 它在启动时读取一次、无法按主题切换;浅色主题下状态栏图标会落在白色头部上 - Service worker 需要安全上下文:以明文 HTTP 访问局域网 IP 时没有 SW、也没有安装提示
(
localhost/127.0.0.1例外) - 浅色主题下 ANSI 16 色是与远端程序的契约,你自己的
LS_COLORS可能是为深色调的 —— 我们开了minimumContrastRatio兜底,但没有任何一套配色能修好#0000ff在白底上的样子 - 打包与解压只支持 zip,不支持 7z / rar / tar
- 单个归档上限 4 GiB、20 万条目。Zip64 只实现了"条目数"那一半 —— 尺寸那一半要给每个条目的两种头都加扩展字段,写错一半的 Zip64 会变成 "有的工具能开有的不能开",是最糟的失败形态
- 打包会跳过符号链接(既不跟随,也不存链接本身),并在
x-webmux-skipped里报数;解压则会拒绝含符号链接条目的归档 —— 种下一个链接就是一条持久的逃逸路径 - 含反斜杠文件名的归档会被解压拒绝。POSIX 里
\是合法文件名字符,但 Windows 写入器 拿它当分隔符,..\..\etc\passwd同时是"一个无害文件名"和"四级穿越",判不出来就拒 - 打包下载不发
Content-Length/Range/ETag:目录没有稳定的版本标识, 一个会过期的验证器会让客户端拿到静默拼接坏掉的 zip - 分享链接的 token 在 URL 路径里,因此会出现在反向代理的访问日志中。这是服务端渲染 分享页的固有代价(URL fragment 做不到)。有日志访问权的人就能用这些链接 —— 唯一的缓解是日志保留策略。说出来比被发现在好
- 分享的下载次数上限是防扩散的礼貌,不是强制边界:一个故意把每个 Range 都从字节 1 开始的客户端只计一次却拿到 99.99% 的文件。真正的控制是有效期、限速和撤销
- 目录分享对访客只提供打包下载,不能单独取走某一个文件
- 上传暂存区放在数据目录所在的磁盘上。目标根在另一个文件系统时(外置盘、NFS、 Docker 挂载卷),安装时会多一次拷贝 —— 先拷到目标目录内的临时名再 rename, 为的是保住原子性
- 上传分片是 4 MB 且由服务端定死;断点续传按这个粒度对齐
- P0 PTY + WebSocket + xterm.js + 密码登录
- P1 tmux 持久化 + 会话列表 + seq/环形缓冲重连 + resize
- P2 文件管理(浏览 / 上传 / 下载 / 删除)与路径牢笼
- P3 分享下载链接(token / 过期 / 下载上限 / 限速 / 密码 / 撤销)
- P4 移动端打磨(触摸滚动、PWA、主题)+ 实时目录 + 自定义快捷键
- P5 审计面板、2FA、Docker 镜像
- P6 通知(本机回调 API、
/ws/events实时推送、Web Push 手机推送)
设计文档见 DESIGN.md。
MIT。



