feat: 网页远程终端控制 / Browser-based remote terminal control - #92
Conversation
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.
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.
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>
|
已合入 main:a46042e(squash,保留原作者署名)。 因为分支与 #94 / #96 / #95 合并后产生冲突,无法在 GitHub 上直接 merge,改为在本地解冲突后合入,因此这个 PR 不会自动标记为 merged。 解冲突:只有 合并前另外处理了三件事:
另外为 ghostty 本地验证:build 通过、 感谢这个 PR,crypto 部分(challenge-response + HKDF 双向密钥 + 与 JS 客户端的向量对拍测试)写得很扎实。 |
概要 / Summary
依赖 / Dependency
ghosttysubmodule 指向该 PR 的提交81f8da2;合并本 PR 前应先合并 Ghostty PR,并在其提交 SHA 发生变化时更新 submodule 指针。安全边界 / Security
challenge,浏览器回HMAC-SHA256(token, challenge)证明持有密钥,token 全程不上线。失败计数按来源地址共享,60 秒无失败后过期,重连无法重置指数退避。AES-256-GCM加密(会话密钥由HKDF(token, challenge)派生,收发方向分密钥、计数器 nonce 防重放)。被动嗅探者只能看到challenge/proof,看不到 token、终端内容或项目路径。web-remote.js/crypto.mjs本身经明文 HTTP 下发,因此 E2E 加密仅防被动嗅探;主动 MITM 可替换客户端代码并窃取访问密钥或终端输入。**设置页在选择 LAN/NIC 时会显式显示此限制。crypto.subtle在http://局域网IP下不可用而内嵌纯 JS 加密模块(@noble/hashes+@noble/ciphers,MIT)。验证 / Validation
proof/c2s/s2c三把密钥逐字节一致;并用 RFC 5869 HKDF 测试向量确认 noble 符合标准。node --check对crypto.mjs与web-remote.js均通过。WebRemoteProtocolTests:覆盖 tokenKey/proof/HKDF 方向密钥/AES-GCM seal-open/计数器 nonce/常数时间比较,并钉死一组跨语言向量。WebRemoteServerIntegrationTests15/15:真实 HTTP/WebSocket 覆盖动态端口、挑战-应答、同一来源跨连接共享指数退避、连接 admission policy,以及正确 proof 后收到加密的authenticated。snapshot/snapshot。3024-day已下发明暗属性、Chrome 配色和 16 色 ANSI 调色板。xcodebuild test:294 tests,0 failures。chenbstack/ghostty#1的框架构建产物才能编译运行(tee v2 符号);crypto 正确性已由上面的跨语言往返独立验证,不依赖该框架。