Skip to content

feat: 网页远程终端控制 / Browser-based remote terminal control - #92

Closed
defia wants to merge 18 commits into
chenbstack:mainfrom
defia:feat/web-remote-control
Closed

feat: 网页远程终端控制 / Browser-based remote terminal control#92
defia wants to merge 18 commits into
chenbstack:mainfrom
defia:feat/web-remote-control

Conversation

@defia

@defia defia commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

概要 / Summary

  • 新增 Glint 内置网页远程终端,无需单独安装移动 App;支持浏览器实时画面、输入同步与快捷键。
  • 支持在网页中切换 Workspace/终端、新建项目及在指定 Workspace 下新建终端;移动端使用紧凑侧栏。
  • 浏览器宽度驱动 PTY 行列数,断开后恢复本地布局;Glint 顶部显示当前网页受控状态。
  • 访问密钥和 HTTP/WebSocket 端口跨重启保持不变;设置页会显式提示端口冲突,并可一键重置密钥与端口。重置会断开旧网页会话并使旧链接失效。
  • 网页与终端流量端到端加密(挑战-应答 + AES-256-GCM);访问密钥不再明文上线,被动嗅探无法还原内容。
  • 网页和设置支持中英文;网页同步当前 Glint 图标、主题、明暗外观及 16 色 ANSI 调色板。
  • 内置 xterm.js、fit addon、Nerd Symbols 字体及网页加密模块(noble);随 App 分发对应许可证,并额外包含 Nerd Fonts v3.4.0 完整 MIT/OFL 根许可证。

依赖 / Dependency

  • 依赖 chenbstack/ghostty#1 提供向后兼容的 PTY tee v2 API,用于在同一解析顺序下生成终端快照并过滤已经包含在快照中的输出字节。
  • 当前 ghostty submodule 指向该 PR 的提交 81f8da2;合并本 PR 前应先合并 Ghostty PR,并在其提交 SHA 发生变化时更新 submodule 指针。

安全边界 / Security

  • 默认关闭,仅在用户启用后监听本机端口。
  • 鉴权为挑战-应答:Mac 发随机 challenge,浏览器回 HMAC-SHA256(token, challenge) 证明持有密钥,token 全程不上线。失败计数按来源地址共享,60 秒无失败后过期,重连无法重置指数退避。
  • 最多保留 32 个 WebSocket 客户端,其中最多 8 个可处于未认证状态;未在 30 秒内完成认证的连接会自动断开。
  • 握手通过后所有双向帧用 AES-256-GCM 加密(会话密钥由 HKDF(token, challenge) 派生,收发方向分密钥、计数器 nonce 防重放)。被动嗅探者只能看到 challenge/proof,看不到 token、终端内容或项目路径。
  • 面向可信局域网/VPN。**非 TLS 页面及 web-remote.js / crypto.mjs 本身经明文 HTTP 下发,因此 E2E 加密仅防被动嗅探;主动 MITM 可替换客户端代码并窃取访问密钥或终端输入。**设置页在选择 LAN/NIC 时会显式显示此限制。
  • 非 loopback 监听不通过 mDNS/Bonjour 广播服务名和端口;用户通过设置页复制访问 URL。
  • 无前向安全性(会话密钥由长期 token 派生),不替代 TLS/云中继。浏览器侧因 crypto.subtlehttp://局域网IP 下不可用而内嵌纯 JS 加密模块(@noble/hashes + @noble/ciphers,MIT)。
  • 支持多个已认证浏览器共享同一终端画面与输入;最近调整尺寸的浏览器决定 PTY 行列数,连接断开后回退到仍在线浏览器的最近尺寸。

验证 / Validation

  • 跨语言往返(已实跑):Swift 的 CryptoKit 派生密钥 + GCM 加密,noble JS 解出正确明文;proof / c2s / s2c 三把密钥逐字节一致;并用 RFC 5869 HKDF 测试向量确认 noble 符合标准。
  • node --checkcrypto.mjsweb-remote.js 均通过。
  • WebRemoteProtocolTests:覆盖 tokenKey/proof/HKDF 方向密钥/AES-GCM seal-open/计数器 nonce/常数时间比较,并钉死一组跨语言向量。
  • WebRemoteServerIntegrationTests 15/15:真实 HTTP/WebSocket 覆盖动态端口、挑战-应答、同一来源跨连接共享指数退避、连接 admission policy,以及正确 proof 后收到加密的 authenticated
  • 两个真实 WebSocket 客户端同时认证并选择同一终端:snapshot / snapshot
  • 当前 Glint 浅色主题 3024-day 已下发明暗属性、Chrome 配色和 16 色 ANSI 调色板。
  • 完整 xcodebuild test:294 tests,0 failures。
  • 注:Swift 单元/集成测试需用 chenbstack/ghostty#1 的框架构建产物才能编译运行(tee v2 符号);crypto 正确性已由上面的跨语言往返独立验证,不依赖该框架。

defia added 14 commits July 19, 2026 13:54
Allow multiple authenticated browsers to control the same pane. Track each client grid size so disconnects fall back to another active browser, and mirror the active Glint theme and ANSI palette to the web client.
Bind the HTTP/WS listeners to the user-selected local address (loopback,
a named NIC, or all interfaces) via NWParameters.requiredLocalEndpoint
(port 0; a concrete port collides with NWListener's `on:` → EINVAL),
instead of the default 0.0.0.0 wildcard. Default is loopback. Settings
exposes a "Listen on" picker (refreshable); loopback no longer advertises
mDNS. Access URLs are generated per target.

Exponential backoff on failed WebSocket authenticate (0.25s → 16s, capped),
gated by a per-connection authBackoffPending flag so a flooding attacker
can't spawn one timer per attempt.

Cap the browser's pane-not-ready retry (~40) instead of retrying forever.

Add WebRemoteServerIntegrationTests: HTTP routing + asset allowlist + HEAD,
WebSocket wrong/right-token auth, backoff timing, and the pure-function
backoff/bind curves.

HTTP/WS 监听改为绑定用户选定的本机地址(loopback / 指定网卡 / 全部接口),
不再默认通配 0.0.0.0;默认 loopback。requiredLocalEndpoint 端口必须为 0,
否则与 NWListener 的 on: 冲突报 EINVAL。设置里新增「监听网卡」选择器(可刷新),
loopback 不再广播 mDNS,访问链接按目标生成。

WebSocket 认证失败按指数退避(0.25→16s 封顶),并用 authBackoffPending 门控,
避免穷举者每次尝试都生成一个定时器。

浏览器 pane-not-ready 重试加上限(约 40 次),不再无限重试。

新增 WebRemoteServerIntegrationTests:覆盖 HTTP 路由 + 资源 allowlist + HEAD、
WebSocket 错/对 token 认证、退避时序,以及退避/绑定的纯函数曲线。
…ge / 网卡消失显式失败 + IP变化自动重绑

A user-chosen NIC that currently has no IPv4 now fails loudly ("Selected
interface is no longer available.") instead of silently falling back to a
0.0.0.0 wildcard bind — which would widen exposure, the opposite of picking
a NIC.

NWPathMonitor watches the bound NIC while the server runs. When its IPv4
changes (DHCP renewal, Wi-Fi switch, sleep/wake) the listeners rebind to
the new address after a 0.5s coalesce, refreshing the access URLs. If the
NIC vanishes entirely the rebind attempt fails via the guard above, so the
server surfaces .failed rather than staying silently unreachable. Loopback
and explicit all-interfaces skip the monitor (address-stable / agnostic).

选定的网卡当前无 IPv4 时显式报错("Selected interface is no longer available."),
不再静默回退到 0.0.0.0 通配——回退会扩大暴露面,与选择特定网卡的目的相反。

运行时用 NWPathMonitor 监视绑定网卡;IPv4 变化(DHCP 续约、切 Wi-Fi、睡眠唤醒)
→ 0.5s 防抖后重新解析绑定并刷新访问链接。网卡彻底消失 → 重绑时被上述 guard 拦下
→ .failed 显式报错(而非静默不可达)。loopback 与“全部网卡”跳过监视。
…用挑战-应答 + AES-GCM 端到端加密

The access token no longer crosses the wire. Each WebSocket connection opens
with a server-issued random challenge; the browser proves knowledge of the
token with HMAC-SHA256(token, challenge) without sending it, and both sides
derive per-direction AES-256-GCM keys via HKDF(token, challenge). Everything
after the handshake is encrypted, so a passive sniffer on the LAN sees only
the challenge and the proof — not the token, terminal content, or project
paths.

访问密钥不再明文上线。每条 WebSocket 连接由 Mac 先发一个随机 challenge,
浏览器用 HMAC-SHA256(token, challenge) 证明持有密钥(不回传 token),双方
再用 HKDF(token, challenge) 派生方向分 AES-256-GCM 密钥。握手通过后所有
双向帧加密,局域网上的被动嗅探者只能看到 challenge 与 proof,看不到 token、
终端内容或项目路径。

- WebRemoteCrypto (CryptoKit): tokenKey decode, proof, HKDF session +
  direction keys, AES-GCM seal/open frames, 12-byte counter-nonce codec,
  constant-time compare.
- Server: sends auth-challenge on connect; verifies proof (keeps exponential
  backoff); encrypts outbound (s2c) and decrypts inbound (c2s) binary frames
  with a per-direction counter nonce and replay guard.
- Client: vendored crypto.mjs (@noble/hashes + @noble/ciphers, MIT, pure JS —
  works on http://LAN where crypto.subtle is unavailable). Derives keys,
  encrypts outbound, decrypts inbound; the token stays in the browser.
- Frame layout: nonce(12) || ciphertext || tag(16); per-direction counters so
  nonces never collide under their key.
- Tests: a pinned cross-language vector locks the Swift↔noble wire format,
  plus seal/open round-trip, tamper / wrong-key / replay rejection; the
  integration tests are rewritten for the challenge-response handshake.

Threat model: defends against passive sniffing only — not forward-secret and
not a replacement for TLS. crypto.subtle is undefined on http://LAN-IP, which
is why the client vendors a pure-JS crypto bundle rather than using Web Crypto.
…n 防明文泄漏

copyWebRemoteURL wrote the full `#token=…` URL to the clipboard, leaking the
access secret in plaintext alongside the address. Route both the displayed and
the copied URL through the new WebRemoteAccessURL.redacted(from:) helper, which
drops the fragment. The key is still copyable separately from the "Access key"
row, and a browser opening the redacted URL prompts for it via the auth dialog.

之前 copyWebRemoteURL 把带 #token= 的完整链接写进剪贴板,access 密钥随地址一起明文
泄漏。显示与复制都改走新增的 WebRemoteAccessURL.redacted(from:),去掉 fragment。
密钥仍可从「Access key」单独复制,对方打开去掉 token 的链接会弹鉴权框输入。
…t / 补 URL 去 token 测试并修复 CryptoKit 导入

Add testRedactedURLStripsTokenFragmentForSafeCopying to lock in the
copy-excludes-token invariant. Also restore `import CryptoKit`, which the
SymmetricKey test helper added in 31f6d3b needs but was never committed —
without it the test target does not compile.

新增 testRedactedURLStripsTokenFragmentForSafeCopying 锁住「复制不含 token」。
同时补回 import CryptoKit —— 31f6d3b 加的 SymmetricKey 测试 helper 需要它却漏提交,
缺了它测试 target 编不过。
Ignore close events from sockets replaced by a manual reconnect, while preserving automatic reconnects for the current connection.
@defia
defia marked this pull request as ready for review July 19, 2026 13:55
defia added 4 commits July 20, 2026 13:12
Port binding failures reached the settings model but were only shown as a quiet subtitle. Promote address-in-use to a structured status with an actionable alert, and rotate the persisted HTTP/WebSocket port pair whenever credentials are reset.
Replace the hardcoded web header mark with the selected AppIconPreset asset. Send it in authenticated state updates so connected browsers refresh immediately when the icon changes.
Bundle the upstream Nerd Fonts v3.4.0 root license alongside the SymbolsOnly release license so both the MIT and SIL OFL terms accompany the font.
Stop advertising non-loopback listeners over mDNS, surface the active-MITM boundary in settings, cap client resources, and retain authentication backoff per source across reconnects.
chenbstack pushed a commit that referenced this pull request Jul 30, 2026
Merged with conflict resolution in Localizable.xcstrings, plus follow-ups
found while validating the branch against current main:

- sync GhosttyResources/.ghostty-sha with the bumped ghostty submodule,
  which CI's verify-ghostty-resources.sh would otherwise reject
- reject unencrypted frames once the WebSocket handshake completes; a frame
  too short to hold nonce+tag previously fell through to plaintext JSON
  parsing on an authenticated session
- move the web remote access key out of plaintext UserDefaults into the
  Keychain, migrating existing installs and scrubbing the cleartext copy
- publish the GhosttyKit prebuilt for ghostty 81f8da23 (xcframework-81f8da2-v1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chenbstack

Copy link
Copy Markdown
Owner

已合入 main:a46042e(squash,保留原作者署名)。

因为分支与 #94 / #96 / #95 合并后产生冲突,无法在 GitHub 上直接 merge,改为在本地解冲突后合入,因此这个 PR 不会自动标记为 merged。

解冲突:只有 Localizable.xcstrings 一处真冲突(4 个 Swift 文件 git 自动合并)。合并后 550 个 key 正好是两侧并集,双向无丢失。

合并前另外处理了三件事:

  1. GhosttyResources/.ghostty-sha 未同步 — 分支把 ghostty submodule 提到 81f8da23,但没更新这个标记文件,CI 的 verify-ghostty-resources.sh 会拒绝。实测比对过 terminfo 与 shell-integration 内容与 81f8da23 构建产物完全一致(该 commit 只改了 ghostty.h 和 3 个 zig 源),所以只更新了 SHA 标记。

  2. 握手后仍存在明文旁路WebRemoteServer.swift)— 长度检查和加密判断写在同一个 guard 里,导致已认证连接收到 ≤28 字节的帧时会跳过解密、直接当明文 JSON 执行(例如 15 字节的 {"type":"list"})。虽然利用需要主动注入 WS 帧、且能塞进 28 字节的命令有限,但它破坏了「认证后每个入站帧都经 GCM 认证」这个不变量。已改为握手后一律要求解密,补了集成测试 testShortPlaintextFrameAfterHandshakeIsRejected(回退修复后该测试确实失败,验证过有效性)。

  3. access token 明文存 UserDefaults — 应用非沙盒,plist 在 ~/Library/Preferences/,本机任意进程可读,而拿到 token 等于完全的终端控制。协议层花了很大力气让 token 不过线,存储侧却是明文。已改为 Keychain(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly),并迁移已有安装、抹掉明文副本;Keychain 写入失败时降级为进程内存储而非退回明文。

另外为 ghostty 81f8da23 发布了预编译产物 xcframework-81f8da2-v1 并登记进 scripts/ghosttykit-checksums.txt,否则 CI 的 Download GhosttyKit 一步会一直失败(这也是此前 CI 从未变绿的原因)。

本地验证:build 通过、verify-ghostty-resources.sh 通过、306 个测试全过(原 302 + 新增 4)。

感谢这个 PR,crypto 部分(challenge-response + HKDF 双向密钥 + 与 JS 客户端的向量对拍测试)写得很扎实。

@chenbstack chenbstack closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants