Skip to content
SmartXiaoMingPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

webmux

一个自托管的 Web 终端:会话活在 tmux 里,不在浏览器标签页里。

关掉网页、断网、换台设备、甚至重启 webmux 自己 —— 命令照跑,输出一条不丢。 重新打开,接着看。

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
  • 页面上永远不出现绝对路径或根目录名

手机:真的能用

手机上的 webmux

软键盘没有 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,两者就不再冲突。 如果反代必须在另一台机器上,就用防火墙把这个端口只放给反代的地址, 启动时也会有警告提醒你。

做成 systemd 服务

一条命令:

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 pnpm

2. 想好用哪个账号。 就用你自己(推荐,也是 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)。一个只在打开某个目录时才暴露问题的沙箱, 比没有沙箱更糟。

用 nvm / asdf / fnm 装 Node 的话

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
   │                                            ↑
   └── 关掉/断网/刷新都行              进程一直在这里跑
  1. seq 序号 —— 会话的输出是一条字节流,客户端记住自己渲染到第几个字节。
  2. 环形缓冲 —— 服务端保留最近 1 MB 输出。短暂断网重连时,只补发缺失的那一段, 屏幕上完全看不出断过。
  3. 无头终端镜像 —— 服务端用 @xterm/headless 维护一份终端的完整状态(含滚动历史 和颜色)。客户端离开太久、缺口已被淘汰时,直接把这份状态序列化成 ANSI 序列发过去, 一次重绘恢复整个屏幕。

为什么用 tmux 而不是自己管 PTY:kill 掉 pty 只终止 tmux 客户端 进程;tmux server 是独立 daemon,它持有会话与子进程。所以不只关网页不影响,连 webmux 服务端重启都不影响 —— 这是相对自研 PTY 管理器的关键优势。


一些刻意的设计取舍

  • 会话树只有一层。在会话上点 + 会在它当前所在的目录开一条新 shell,缩进挂在它下面; 但从一个子会话再开,新的会与那个子会话同级,而不是继续往下缩进。理由是这个动作太顺手了 —— 「在当前 shell 里再开一个」会变成习惯 —— 而没有这条规则,几天的使用就会长出一段 288px 侧栏根本显示不下的阶梯。父会话被杀掉时子会话自动回到顶层,因为树只是展示方式, 一个谁也看不见的会话比一个位置不完美的会话糟得多。
  • 侧栏顺序是「收藏目录 / 会话 / 已打开目录」,且三者都可折叠。收藏目录默认折叠: 它是一个在被星标之前一直是空的快捷列表,而展开着的空盒子是侧栏里最贵的那种空间。 标题上的计数 (n) 就是为此存在的 —— 折叠不该等于看不见。折叠状态每次加载都回到默认, 没有持久化:这三个区块的取舍按场景变化,不值得为它再加一份本机状态。
  • 文件列表默认不显示隐藏文件。一个装满 .git、.next、编辑器状态的目录会把真正要找的 那几个文件淹掉;工具栏的眼睛图标随时能打开(图标变暗表示"当前隐藏中")。
  • 会话从文件页创建:在「文件」里进入想要的目录,点工具条上的终端图标, 就在那个目录开一条 shell。侧栏没有「新建」按钮 —— 一条终端只有开在你想要的目录里才有用, 而在侧栏建要么得替你猜一个目录、要么让你选两次。 文件页给出的目录只可能来自文件 API,也就是只可能是路径牢笼内的。 但会话树让情况变了:从会话上点 + 时,新会话的目录是父会话当前所在的目录 —— 那是 shell cd 到过的任何地方,可能已经被删掉。所以服务端现在会校验这个路径是不是 一个真实存在的目录,不是就回落到 $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 与 Python zipfile.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。

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages