diff --git a/Dockerfile b/Dockerfile index 8cfbd275..0ce5deb4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ COPY src-tauri/crates/nyaterm-core src-tauri/crates/nyaterm-core COPY src-tauri/crates/nyaterm-web src-tauri/crates/nyaterm-web COPY src-tauri/vendor/russh src-tauri/vendor/russh COPY src-tauri/vendor/russh-sftp src-tauri/vendor/russh-sftp +COPY src-tauri/vendor/vnc-rs src-tauri/vendor/vnc-rs RUN cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked FROM debian:bookworm-slim AS runtime diff --git a/docs/web-architecture.md b/docs/web-architecture.md index 3593d9ed..35bf2c3f 100644 --- a/docs/web-architecture.md +++ b/docs/web-architecture.md @@ -25,7 +25,7 @@ flowchart TD Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Core。没有把 vendored russh 纳入新的顶层 workspace,避免其已有 workspace dependency 解析冲突。 -共享范围包括配置类型与迁移、redb 文档、AES-GCM/主密码密钥层次、历史工具、SSH 算法/连接/密码和交互认证/通道回复/输入/resize、SFTP 初始化及原有文件属性/文本冲突工具、私钥校验和公钥派生、AI provider/model/catalog/prompt/redaction/parser/stream/history。Web 专属的会话 ownership、cookie、认证提示、HTTP 文件流和生命周期留在 Web crate。Desktop 和 Web 的会话调度循环不同,因为 Desktop 仍提供原生附加能力;没有复制原生 SSH/SFTP 全栈。 +共享范围包括配置类型与迁移、redb 文档、AES-GCM/主密码密钥层次、历史工具、SSH 算法/连接/密码和交互认证/通道回复/输入/resize、Telnet 协商/自动登录/行编辑/字符编解码、共享 TCP 代理、VNC 协议运行器/帧缓冲/帧编码、SFTP 初始化及原有文件属性/文本冲突工具、私钥校验和公钥派生、AI provider/model/catalog/prompt/redaction/parser/stream/history。Web 专属的会话 ownership、cookie、认证提示、HTTP 文件流和生命周期留在 Web crate。Desktop 和 Web 的会话调度循环不同,因为 Desktop 仍提供原生附加能力;没有复制原生 SSH/SFTP 全栈。 ## 新增与修改文件 @@ -40,12 +40,12 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co | `src-tauri/src/{config,storage,utils,core/ai,core/history,core/sftp/util.rs,...}` | 原位置使用 re-export/wrapper,保留 Desktop 调用兼容性。文件删除对应迁入 Core,非删除桌面功能。 | | `src-tauri/src/{cmd/connection.rs,cmd/ai.rs,core/ssh,core/sftp/sftp_backend/session.rs}` | 调用共享服务与协议;原生管理器继续保留。 | | `src-tauri/crates/nyaterm-web/src/{main,lib,auth,state,session,commands,sftp,ai,plugins,error}.rs` | 独立服务、显式命令 allowlist、会话和资源清理。 | -| `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap}.rs` | 真正 russh/SFTP fixture 与进程启动测试。 | +| `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap,protocols}.rs` | 真实 SSH/SFTP、Telnet、VNC、代理/跳板 fixture 与进程启动测试。 | | 各 crate Cargo.toml/Cargo.lock、`package.json`、`vite.config.ts` | 依赖、构建入口和 base path。 | | `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 | | `docs/web-deployment.md`、本文 | 操作步骤、能力、安全边界和验证报告。 | -原生 imports 只在 transport/platform 边界及被 capability 隔离的 RDP/VNC Channel、updater 和 native PluginPanel 资源转换中保留。不会在 Web startup 创建原生 Channel 或加载原生插件。 +原生 imports 只在 transport/platform 边界及被 capability 隔离的 RDP Channel 和桌面 VNC Channel 适配器、updater 和 native PluginPanel 资源转换中保留。不会在 Web startup 创建原生 Channel 或加载原生插件。 ## 调用链 @@ -61,13 +61,13 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co ## 生命周期与资源上限 -1. 先建立 SSE,再创建 SSH;创建立即返回不透明 ID,后台连接有 120 秒总超时,提示有 90 秒超时。 +1. 先建立 SSE,再创建 SSH、Telnet 或 VNC;创建立即返回不透明 ID,后台连接有 120 秒总超时,提示有 90 秒超时。 2. 每个登录最多 16 个会话、4 个 AI 请求;全局最多 128 个会话和 128 个登录;每个 session 最多 4 个 SFTP 操作。 -3. 每个会话最多一个 WS attachment;SSH output queue 为 32 × 64 KiB(2 MiB),WS write buffer 最多 256 KiB。输出按序 backpressure,最终 close 在 final bytes 后;socket send 有超时。输入 queue 为 128 条、WS 单帧/消息最大 64 KiB,浏览器发送缓冲超过 1 MiB 拒绝继续输入。 -4. WS 每 15 秒 heartbeat,45 秒没有 peer 活动断开。断线保留 30 秒 attachment lease 和有界输出;浏览器自动尝试同 session 重附着,明确 SSH reconnect 则创建新连接。lease 到期取消连接。 +3. 每个会话最多一个 WS attachment;SSH/Telnet output queue 为 32 × 64 KiB(2 MiB),WS write buffer 最多 256 KiB。输出按序 backpressure,最终 close 在 final bytes 后;socket send 有超时。输入 queue 为 128 条、WS 单帧/消息最大 64 KiB,浏览器发送缓冲超过 1 MiB 拒绝继续输入。 +4. WS 每 15 秒 heartbeat,45 秒没有 peer 活动断开。断线保留 30 秒 attachment lease 和有界输出;浏览器自动尝试同 session 重附着,明确 SSH/Telnet reconnect 则创建新连接,VNC reconnect 沿用已有 ID。lease 到期取消连接。 5. 显式 close、创建取消、logout、8 小时登录到期和 shutdown 通过 CancellationToken 终止等待、SSH、SFTP 和 AI。SSH disconnect 有超时,registry 清除后发 session change。 6. 上传中断/失败或响应被丢弃时,RAII cleanup 清除 `.part` 并关闭 SFTP;上传/下载和文件操作均受 owner 与 cancel 约束。Web 无传输暂停/重试服务。 -7. SIGTERM/Ctrl-C 取消共享 shutdown,HTTP graceful drain 最多 5 秒,再等待会话清理最多 5 秒。重启不会恢复活跃 SSH。 +7. SIGTERM/Ctrl-C 取消共享 shutdown,HTTP graceful drain 最多 5 秒,再等待会话清理最多 5 秒。重启不会恢复活跃网络连接。 ## 能力矩阵 @@ -80,12 +80,16 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co | AI Assistant | provider API/Ask streaming、脱敏、历史;无 Agent、MCP 或本地附件。 | | 插件 | Web target/capability/permission 检查框架;只允许 UI 类权限,未提供安装或执行器。 | | 本地文件、剪贴板、外部链接、子窗口 | 浏览器 File/download、Clipboard、受限链接、同源 iframe 页面替代;浏览器权限仍适用。 | -| Local Shell/PTY、Serial、Telnet、RDP/VNC | 不支持;创建入口与服务均 gate。 | +| Local Shell/PTY、Serial、RDP | 不支持;创建入口与服务均 gate。 | | tray、原生窗口、全局快捷键、桌面通知、OS credential manager、自动更新 | 不支持;Web 采用服务端加密存储和浏览器页面。 | | SCP/Zmodem、watcher、复杂本地文件集成、录制、远程监控、传输控制、同步备份 | 不支持,集中 capability/panel/settings gate。 | -| SSH proxy/jump、agent/X11/证书、启动命令、network_device、非 UTF-8 | 拒绝连接;不能静默改成直接连接或忽略这些配置。 | +| SSH agent/X11/证书、SSH 启动命令、network_device、非 UTF-8 SSH | 拒绝连接;不能静默改成直接连接或忽略这些配置。 | | OSC/CWD 自动跟踪、动态标题 | Web 暂不提供;Desktop 原逻辑保留。 | +补充能力:Telnet/VNC 使用独立 capability,启用 VNC 不会启用 RDP。Telnet 复用 terminal WebSocket,将字符串输入按连接编码转换,二进制输入保留原始字节;服务端输出统一转成 UTF-8。VNC 使用 `/api/sessions/{id}/vnc`:二进制帧与桌面共用 44 字节补丁头,JSON 承载状态、输入和剪贴板。附着时重放完整画面与状态,最多缓存两帧,溢出后从权威帧缓冲重同步,完整帧和增量帧有序发送。只读限制在服务端执行。 + +`POST /api/sessions` 的 `type` 可为 `ssh`、`telnet`、`vnc`,省略时仍为 SSH。共同注册、数量限制、登录归属、取消、注销及关停清理位于 Web 会话层;SFTP 拒绝非 SSH 会话。代理 CRUD 沿用原有 redb 与凭据加密规则。共享 TCP 支持 SOCKS5/HTTP CONNECT,Web SSH `direct-tcpip` 支持递归跳板、自身代理、循环检查和 8 层限制,凭据不出现在 URL。刷新利用现有持久 pane ID 匹配同登录的会话租期,无需数据迁移。 + ## 安全边界 这是单管理员实例,不是多租户 ACL 服务。共享保存配置和历史应只对可信管理员开放。高熵登录密码与独立 32 字节外部数据密钥必须配置;启动不能回退到 OS credential manager 或路径派生密钥。既有主密码层次保留,错误数据密钥启动失败。redb 的元数据/AI 历史不等于整个数据库加密。 @@ -98,18 +102,26 @@ session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返 已完成的验证: -- 共享 Core:248 个单元测试通过,覆盖原有配置、迁移、存储、加密、SFTP helper、AI provider/catalog。 -- 前端全量:157 个文件、990 个测试通过;Web adapter 的 5 个测试覆盖同源/base URL、CSRF、UTF-8 WS/输入/resize、native 拒绝、有界发送、既有设置 iframe 和登录 gate。 +- 共享 Core:292 个单元测试通过,覆盖原有配置、迁移、存储、加密、SFTP helper、AI provider/catalog。 +- 前端全量:158 个文件、1003 个测试通过;Web adapter 测试覆盖同源/base URL、CSRF、UTF-8 WS/输入/resize、native 拒绝、有界发送、既有设置 iframe 和登录 gate。 - Web integration:真实 russh fixture、SFTP subsystem、mock OpenAI-compatible provider;认证/CSRF/Host/CSP/SPA、owner prompts、known-host 首次确认及变更拒绝、UTF-8 input/resize、断开重附着、3 MiB 输出及 final-tail、5 MiB 上传下载、上传中断清理、凭据加密、AI stream/redaction/history、错误加密密钥拒绝。 -- Desktop `cargo check --lib` 通过。原生 Rust 测试已成功编译,但测试 exe 在运行前以 `0xc0000139` / `STATUS_ENTRYPOINT_NOT_FOUND` 退出,无法验证 Desktop 原生 SSH/SFTP 运行测试;共享部分已由 Core 和 Web 测试验证。 +- Desktop `cargo check --locked` 通过。原生 Rust 测试已成功编译,但测试 exe 在运行前以 `0xc0000139` / `STATUS_ENTRYPOINT_NOT_FOUND` 退出,无法验证 Desktop 原生 SSH/SFTP 运行测试;共享部分已由 Core 和 Web 测试验证。 - Web 进程启动测试通过:外部 `_FILE` 秘密、真实 HTTP 登录/API、子路径首页,以及更换数据密钥后拒绝重启。Axum 子路由使用带 `/` 的挂载点,裸路径重定向到目录 URL。 - 追加的 Web 安全/兼容断言通过:跨 owner / 错 Origin 的 WS upgrade 拒绝、全局编码、保存连接的加密认证、SFTP disabled、AI target-context ownership/redaction、清除历史、HTTP logout,以及等待 host-key prompt 时取消创建后的 session/prompt 清理。 - TypeScript、`pnpm lint`、Rust formatting、Desktop `cargo check --lib --locked` 通过。lint 保留未修改 CommandSuggestions 中一个既有 dependency warning;Vite 有既有 chunk-size/Browserslist 提示。 - `pnpm build` 和 `/nyaterm/` base path 的 `pnpm build:web` 已通过;输出资源和图标使用构建 base path;最后一轮两种构建均通过。 -本机没有 Docker CLI/engine 或可用浏览器。因此未执行 Docker image build、Linux 容器运行和浏览器视觉/手工交互验收;不能把 HTTP fixture/DOM 测试称为这些验证。部署步骤见 [web-deployment.md](web-deployment.md)。 +本机未安装 Docker CLI,未执行 Docker image build、Linux 容器运行和浏览器视觉/手工交互验收;不能把 HTTP fixture/DOM 测试称为这些验证。部署步骤见 [web-deployment.md](web-deployment.md)。 ## 后续扩展 -优先在 Linux CI 构建和启动 Docker 镜像,加入 Playwright 登录→保存 SSH→指纹确认→xterm→SFTP 的完整浏览器验收,并在三平台 native runner 验证 Desktop。再把更多 SSH auth/session policy 抽为注入 event sink 的服务,减少两端调度差异。随后实现更细的能力描述与本地化不可用提示、Web 插件 sandbox/runtime、SFTP overwrite/递归/取消控制、OSC/CWD 与 SSH proxy/jump。若需要多人使用,先增加用户、保存资源 ACL、AI history 隔离和审计,再放开多用户入口。 +优先在 Linux CI 构建和启动 Docker 镜像,加入 Playwright 登录→保存 SSH→指纹确认→xterm→SFTP 的完整浏览器验收,并在三平台 native runner 验证 Desktop。再把更多 SSH auth/session policy 抽为注入 event sink 的服务,减少两端调度差异。随后实现更细的能力描述与本地化不可用提示、Web 插件 sandbox/runtime、SFTP overwrite/递归/取消控制、OSC/CWD。若需要多人使用,先增加用户、保存资源 ACL、AI history 隔离和审计,再放开多用户入口。 + + +## Telnet / VNC 增量验证 + +- 新增 `nyaterm-web/tests/protocols.rs` 使用本地 TCP/RFB/代理/SSH jump fixture,覆盖保存与临时配置、GBK、原始字节、自动登录后的启动延迟、NAWS、本地行编辑、SOCKS5/HTTP 有认证及无认证连接、嵌套跳板、自身代理、路由优先级、循环、代理配置与解密失败,及 VNC 完整/增量帧、尺寸变化、键鼠、只读、剪贴板、重连、刷新重附着、跨登录隔离与注销清理。 +- Core 包含抽取后的 Telnet/编码/VNC 原有测试,以及分片 IAC、NAWS 转义和慢订阅者完整帧重同步测试。 +- Core 292 项、vendored VNC 协议库 40 项测试通过。Web 三项集成测试通过;Telnet/VNC/代理/生命周期集成测试连续 10 次通过,覆盖终端结束前最后输出的送达。 +- 前端全量 158 文件、1003 项测试通过;Web 构建、lint 和四语言格式检查通过,桌面 `cargo check --locked` 通过。Docker CLI 当前不可用,容器实构建及真实远端图形环境手工验收仍待部署环境执行。 diff --git a/docs/web-deployment.md b/docs/web-deployment.md index fef9219e..4a9f5ce3 100644 --- a/docs/web-deployment.md +++ b/docs/web-deployment.md @@ -115,8 +115,10 @@ location /nyaterm/ { 停服后备份整个数据目录,并单独备份加密密钥与登录密码。密钥丢失将无法解密凭据。不要让多个进程同时写同一个 redb volume,也不要把桌面用户数据目录直接作为服务器 volume。保存的私人密钥、密码和 AI provider 密钥使用原有 AES-GCM 存储。AI 历史和连接元数据属于实例共享数据,未作为整个数据库加密。 -支持保存/临时 SSH、密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask 和设置持久化。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。 +支持保存/临时 SSH、Telnet 和 VNC,密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask 和设置持久化。Telnet 支持原有字符编码、自动登录、本地回显、行编辑、NAWS 和 raw TCP;VNC 支持现有认证与服务器密钥确认、缩放、共享连接、只读、文本剪贴板和重连。三种协议均支持 SOCKS5、HTTP CONNECT 及代理认证、SSH 跳板(最多 8 层,使用跳板自身网络配置)。代理失败不回退到直连。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。 -Web 当前不支持本地 Shell、Serial、Telnet、RDP/VNC、代理/跳板、SSH agent/X11/证书登录、启动命令、非标准 SSH profile、非 UTF-8 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、远程监控、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。 +Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/证书登录、SSH 启动命令、非标准 SSH profile、非 UTF-8 SSH 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、远程监控、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。 -测试与架构见 [web-architecture.md](web-architecture.md)。当前环境没有 Docker 和浏览器,因此镜像实构建和浏览器视觉验收仍需在部署环境执行。 +刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。 + +测试与架构见 [web-architecture.md](web-architecture.md)。当前环境未安装 Docker CLI;镜像实构建及浏览器手工验收尚未执行,仍需在部署环境完成。更新后请重新构建镜像并重建容器,旧镜像不会自动获得协议支持。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 965a3eef..ed4a37b2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5008,8 +5008,10 @@ name = "nyaterm-core" version = "0.1.0" dependencies = [ "aes-gcm 0.10.3", + "async-http-proxy", "base64 0.22.1", "dirs", + "encoding_rs", "font-kit", "futures-util", "genai", @@ -5026,12 +5028,15 @@ dependencies = [ "sha1 0.10.7", "sha2 0.10.9", "ssh-key 0.6.7", + "strip-ansi-escapes", "thiserror 2.0.20", "time", "tokio", + "tokio-socks", "tokio-util", "tracing", "uuid", + "vnc-rs", "zeroize", ] diff --git a/src-tauri/crates/nyaterm-core/Cargo.lock b/src-tauri/crates/nyaterm-core/Cargo.lock index 8675506e..a3003c20 100644 --- a/src-tauri/crates/nyaterm-core/Cargo.lock +++ b/src-tauri/crates/nyaterm-core/Cargo.lock @@ -122,6 +122,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-http-proxy" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29faa5d4d308266048bd7505ba55484315a890102f9345b9ff4b87de64201592" +dependencies = [ + "base64 0.13.1", + "httparse", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -146,6 +168,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -252,12 +280,24 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -344,6 +384,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cmov" version = "0.5.4" @@ -680,6 +731,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "defmt" version = "1.1.1" @@ -708,7 +768,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -729,6 +789,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "pem-rfc7468 0.7.0", "zeroize", ] @@ -884,6 +945,19 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -960,6 +1034,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1768,6 +1848,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2066,6 +2160,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -2187,8 +2291,10 @@ name = "nyaterm-core" version = "0.1.0" dependencies = [ "aes-gcm 0.10.3", + "async-http-proxy", "base64 0.22.1", "dirs", + "encoding_rs", "font-kit", "futures-util", "genai", @@ -2205,13 +2311,16 @@ dependencies = [ "sha1 0.10.7", "sha2 0.10.9", "ssh-key 0.6.7", + "strip-ansi-escapes", "tempfile", - "thiserror", + "thiserror 2.0.21", "time", "tokio", + "tokio-socks", "tokio-util", "tracing", "uuid", + "vnc-rs", "zeroize", ] @@ -2367,7 +2476,7 @@ dependencies = [ "log", "rand 0.10.3", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.21", "tokio", "windows", "windows-strings", @@ -2655,6 +2764,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.47" @@ -2676,6 +2791,7 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", "rand_chacha", "rand_core 0.6.4", ] @@ -2741,7 +2857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -2974,7 +3090,7 @@ dependencies = [ "ssh-encoding 0.3.0", "ssh-key 0.7.0-rc.11", "subtle", - "thiserror", + "thiserror 2.0.21", "tokio", "typenum", "universal-hash 0.6.1", @@ -3004,7 +3120,7 @@ dependencies = [ "log", "serde", "serde_bytes", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -3570,6 +3686,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3679,13 +3804,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3791,6 +3936,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -3810,6 +3967,7 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "libc", @@ -3873,6 +4031,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4012,6 +4171,36 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vnc-rs" +version = "0.5.3" +dependencies = [ + "aes 0.8.4", + "async_io_stream", + "eax", + "flate2", + "futures", + "image", + "rand 0.8.8", + "rsa 0.9.10", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4502,3 +4691,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src-tauri/crates/nyaterm-core/Cargo.toml b/src-tauri/crates/nyaterm-core/Cargo.toml index 9f70cc59..a7cafc24 100644 --- a/src-tauri/crates/nyaterm-core/Cargo.toml +++ b/src-tauri/crates/nyaterm-core/Cargo.toml @@ -35,6 +35,12 @@ time = { version = "0.3", features = ["formatting", "macros"] } genai = { version = "0.6.5", default-features = false, features = ["native-tls"] } reqwest = { version = "0.13", default-features = false, features = ["socks", "json", "form", "query", "native-tls"] } +encoding_rs = "0.8" +strip-ansi-escapes = "0.2" +vnc-rs = { path = "../../vendor/vnc-rs" } +tokio-socks = "0.5.2" +async-http-proxy = { version = "1.2.5", features = ["runtime-tokio", "basic-auth"] } + [dev-dependencies] tempfile = "3" diff --git a/src-tauri/crates/nyaterm-core/src/lib.rs b/src-tauri/crates/nyaterm-core/src/lib.rs index e8c46d5c..08c968a5 100644 --- a/src-tauri/crates/nyaterm-core/src/lib.rs +++ b/src-tauri/crates/nyaterm-core/src/lib.rs @@ -7,3 +7,9 @@ pub mod services; pub mod ssh; pub mod storage; pub mod utils; + +pub mod network; +pub mod remote_desktop_frame; +pub mod telnet; +pub mod terminal_encoding; +pub mod vnc; diff --git a/src-tauri/crates/nyaterm-core/src/network.rs b/src-tauri/crates/nyaterm-core/src/network.rs new file mode 100644 index 00000000..059e795e --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/network.rs @@ -0,0 +1,178 @@ +use crate::config::{self, ConnectionNetwork, ProxySettings}; +use crate::error::{AppError, AppResult}; +use std::net::SocketAddr; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T {} + +pub type BoxedTransportStream = Box; + +pub struct OpenedTransport { + pub stream: BoxedTransportStream, + pub local_addr: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportRouteKind { + Direct, + Proxy, + SshJump, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedTransportRoute { + pub kind: TransportRouteKind, + pub proxy_id: Option, + pub proxy_jump_id: Option, +} + +pub fn resolve_transport_route(network: Option<&ConnectionNetwork>) -> ResolvedTransportRoute { + let proxy_jump_id = network + .and_then(|network| network.proxy_jump_id.as_deref()) + .filter(|id| !id.trim().is_empty()) + .map(ToOwned::to_owned); + let proxy_id = network + .and_then(|network| network.proxy_id.as_deref()) + .filter(|id| !id.trim().is_empty()) + .map(ToOwned::to_owned); + + if proxy_jump_id.is_some() { + return ResolvedTransportRoute { + kind: TransportRouteKind::SshJump, + proxy_id, + proxy_jump_id, + }; + } + + if proxy_id.is_some() { + return ResolvedTransportRoute { + kind: TransportRouteKind::Proxy, + proxy_id, + proxy_jump_id: None, + }; + } + + ResolvedTransportRoute { + kind: TransportRouteKind::Direct, + proxy_id: None, + proxy_jump_id: None, + } +} + +pub async fn open_direct_transport( + target_host: &str, + target_port: u16, +) -> AppResult { + let stream = tokio::net::TcpStream::connect((target_host, target_port)) + .await + .map_err(|error| { + AppError::Channel(format!( + "Direct TCP connection to {target_host}:{target_port} failed: {error}" + )) + })?; + let local_addr = stream.local_addr().ok(); + Ok(OpenedTransport { + stream: Box::new(stream), + local_addr, + }) +} + +pub async fn open_proxy_transport( + proxy: ProxySettings, + target_host: &str, + target_port: u16, +) -> AppResult { + let proxy_addr = if proxy.host.contains(':') { + format!("[{}]:{}", proxy.host, proxy.port) + } else { + format!("{}:{}", proxy.host, proxy.port) + }; + if proxy.username.is_some() != proxy.password.is_some() { + return Err(AppError::Config( + "Proxy authentication requires both username and password".into(), + )); + } + match proxy.protocol.as_str() { + "socks5" => { + let stream = match (&proxy.username, &proxy.password) { + (Some(user), Some(pass)) => { + tokio_socks::tcp::Socks5Stream::connect_with_password( + proxy_addr.as_str(), + (target_host, target_port), + user, + pass, + ) + .await + } + _ => { + tokio_socks::tcp::Socks5Stream::connect( + proxy_addr.as_str(), + (target_host, target_port), + ) + .await + } + } + .map_err(|error| AppError::Auth(format!("SOCKS5 proxy connection failed: {error}")))? + .into_inner(); + let local_addr = stream.local_addr().ok(); + Ok(OpenedTransport { + stream: Box::new(stream), + local_addr, + }) + } + "http" => { + let mut stream = + tokio::net::TcpStream::connect(&proxy_addr) + .await + .map_err(|error| { + AppError::Channel(format!("HTTP proxy connection failed: {error}")) + })?; + match (&proxy.username, &proxy.password) { + (Some(user), Some(pass)) => { + async_http_proxy::http_connect_tokio_with_basic_auth( + &mut stream, + target_host, + target_port, + user, + pass, + ) + .await + } + _ => { + async_http_proxy::http_connect_tokio(&mut stream, target_host, target_port) + .await + } + } + .map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {error}")))?; + let local_addr = stream.local_addr().ok(); + Ok(OpenedTransport { + stream: Box::new(stream), + local_addr, + }) + } + other => Err(AppError::Config(format!( + "Unsupported proxy protocol '{other}'" + ))), + } +} + +pub fn resolve_proxy(app: &impl Sized, proxy_id: &str) -> AppResult { + let proxy_cfg = config::load_proxy_by_id(app, proxy_id)? + .ok_or_else(|| AppError::Config(format!("Proxy '{proxy_id}' not found")))?; + let password = proxy_cfg + .password + .as_ref() + .map(|ciphertext| crate::utils::crypto::decrypt(ciphertext)) + .transpose()?; + + Ok(ProxySettings { + enabled: true, + protocol: proxy_cfg.protocol, + host: proxy_cfg.host, + port: proxy_cfg.port, + command: proxy_cfg.command, + username: proxy_cfg.username, + password, + }) +} diff --git a/src-tauri/crates/nyaterm-core/src/remote_desktop_frame.rs b/src-tauri/crates/nyaterm-core/src/remote_desktop_frame.rs new file mode 100644 index 00000000..880e8810 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/remote_desktop_frame.rs @@ -0,0 +1,153 @@ +use crate::error::{AppError, AppResult}; + +pub const FRAME_HEADER_BYTES: usize = 44; +pub const PIXEL_FORMAT_BGRA8888: u32 = 1; +pub const PIXEL_FORMAT_RGBA8888: u32 = 2; +const BYTES_PER_PIXEL: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum RemoteDesktopPixelFormat { + Bgra8888, + Rgba8888, +} + +impl RemoteDesktopPixelFormat { + fn wire_value(self) -> u32 { + match self { + Self::Bgra8888 => PIXEL_FORMAT_BGRA8888, + Self::Rgba8888 => PIXEL_FORMAT_RGBA8888, + } + } +} + +pub struct RemoteDesktopFramePatch<'a> { + pub sequence: u64, + pub desktop_width: u32, + pub desktop_height: u32, + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub stride: u32, + pub pixel_format: RemoteDesktopPixelFormat, + pub payload: &'a [u8], +} + +pub fn encode_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult> { + validate_frame_patch(patch)?; + let mut frame = Vec::with_capacity( + FRAME_HEADER_BYTES + .checked_add(patch.payload.len()) + .ok_or_else(|| AppError::Config("Remote desktop frame size overflows".to_string()))?, + ); + frame.extend_from_slice(&patch.sequence.to_le_bytes()); + frame.extend_from_slice(&patch.desktop_width.to_le_bytes()); + frame.extend_from_slice(&patch.desktop_height.to_le_bytes()); + frame.extend_from_slice(&patch.x.to_le_bytes()); + frame.extend_from_slice(&patch.y.to_le_bytes()); + frame.extend_from_slice(&patch.width.to_le_bytes()); + frame.extend_from_slice(&patch.height.to_le_bytes()); + frame.extend_from_slice(&patch.stride.to_le_bytes()); + frame.extend_from_slice(&patch.pixel_format.wire_value().to_le_bytes()); + let payload_len = u32::try_from(patch.payload.len()).map_err(|_| { + AppError::Config("Remote desktop frame payload exceeds the wire limit".to_string()) + })?; + frame.extend_from_slice(&payload_len.to_le_bytes()); + frame.extend_from_slice(patch.payload); + Ok(frame) +} + +fn validate_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<()> { + if patch.desktop_width == 0 || patch.desktop_height == 0 { + return Err(AppError::Config( + "Remote desktop framebuffer dimensions must be non-zero".to_string(), + )); + } + if patch.width == 0 || patch.height == 0 { + return Err(AppError::Config( + "Remote desktop frame rectangle must be non-zero".to_string(), + )); + } + let right = patch.x.checked_add(patch.width).ok_or_else(|| { + AppError::Config("Remote desktop frame horizontal bounds overflow".to_string()) + })?; + let bottom = patch.y.checked_add(patch.height).ok_or_else(|| { + AppError::Config("Remote desktop frame vertical bounds overflow".to_string()) + })?; + if right > patch.desktop_width || bottom > patch.desktop_height { + return Err(AppError::Config( + "Remote desktop frame rectangle exceeds framebuffer bounds".to_string(), + )); + } + let row_bytes = usize::try_from(patch.width) + .ok() + .and_then(|width| width.checked_mul(BYTES_PER_PIXEL)) + .ok_or_else(|| AppError::Config("Remote desktop frame row size overflows".to_string()))?; + let stride = usize::try_from(patch.stride) + .map_err(|_| AppError::Config("Remote desktop frame stride is invalid".to_string()))?; + if stride < row_bytes { + return Err(AppError::Config( + "Remote desktop frame stride is too small".to_string(), + )); + } + let required_payload = usize::try_from(patch.height) + .ok() + .and_then(|height| stride.checked_mul(height)) + .ok_or_else(|| { + AppError::Config("Remote desktop frame payload size overflows".to_string()) + })?; + if patch.payload.len() < required_payload { + return Err(AppError::Config( + "Remote desktop frame payload is too small".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_the_shared_frame_header() { + let payload = [1, 2, 3, 255]; + let frame = encode_frame_patch(&RemoteDesktopFramePatch { + sequence: 7, + desktop_width: 10, + desktop_height: 10, + x: 2, + y: 3, + width: 1, + height: 1, + stride: 4, + pixel_format: RemoteDesktopPixelFormat::Rgba8888, + payload: &payload, + }) + .expect("frame should encode"); + + assert_eq!(frame.len(), FRAME_HEADER_BYTES + payload.len()); + assert_eq!(u64::from_le_bytes(frame[0..8].try_into().unwrap()), 7); + assert_eq!(u32::from_le_bytes(frame[36..40].try_into().unwrap()), 2); + assert_eq!(&frame[FRAME_HEADER_BYTES..], &payload); + } + + #[test] + fn rejects_rectangles_outside_the_framebuffer() { + let error = encode_frame_patch(&RemoteDesktopFramePatch { + sequence: 1, + desktop_width: 10, + desktop_height: 10, + x: 10, + y: 0, + width: 1, + height: 1, + stride: 4, + pixel_format: RemoteDesktopPixelFormat::Rgba8888, + payload: &[0; 4], + }) + .expect_err("out-of-bounds rectangle must fail"); + + assert!(error.to_string().contains("exceeds framebuffer bounds")); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/services.rs b/src-tauri/crates/nyaterm-core/src/services.rs index e0c0db82..c9f8c68c 100644 --- a/src-tauri/crates/nyaterm-core/src/services.rs +++ b/src-tauri/crates/nyaterm-core/src/services.rs @@ -242,11 +242,12 @@ pub fn validate_proxy_jump_config( if !matches!( connection.config, config::ConnectionType::Ssh { .. } + | config::ConnectionType::Telnet { .. } | config::ConnectionType::Rdp { .. } | config::ConnectionType::Vnc { .. } ) { return Err(AppError::Config( - "ProxyJump is only supported for SSH, RDP, and VNC connections".to_string(), + "ProxyJump is only supported for SSH, Telnet, RDP, and VNC connections".to_string(), )); } @@ -255,6 +256,9 @@ pub fn validate_proxy_jump_config( let mut current_jump_id = proxy_jump_id; loop { + if visited.len() > 8 { + return Err(AppError::Config("SSH jump chain exceeds 8 hops".into())); + } if !visited.insert(current_jump_id) { if connection.id == current_jump_id { return Err(AppError::Config( diff --git a/src-tauri/crates/nyaterm-core/src/ssh/terminal.rs b/src-tauri/crates/nyaterm-core/src/ssh/terminal.rs index f13055e0..cb97e0ed 100644 --- a/src-tauri/crates/nyaterm-core/src/ssh/terminal.rs +++ b/src-tauri/crates/nyaterm-core/src/ssh/terminal.rs @@ -4,6 +4,7 @@ use tokio_util::sync::CancellationToken; pub enum Command { Input(Vec), + RawInput(Vec), Resize(u32, u32), Close, } @@ -11,6 +12,7 @@ pub enum Output { Data(Vec), Closed, Error, + Failure(String), } pub const CHUNK_BYTES: usize = 64 * 1024; pub const OUTPUT_CAPACITY: usize = 32; @@ -38,7 +40,7 @@ pub async fn run( loop { tokio::select! { command = input.recv() => match command { - Some(Command::Input(data)) => write(&channel, &data).await?, + Some(Command::Input(data) | Command::RawInput(data)) => write(&channel, &data).await?, Some(Command::Resize(cols, rows)) => resize(&channel, cols, rows).await?, Some(Command::Close) | None => break, }, diff --git a/src-tauri/src/core/terminal_session/telnet/auto_login.rs b/src-tauri/crates/nyaterm-core/src/telnet/auto_login.rs similarity index 83% rename from src-tauri/src/core/terminal_session/telnet/auto_login.rs rename to src-tauri/crates/nyaterm-core/src/telnet/auto_login.rs index ac45f823..3f215ae7 100644 --- a/src-tauri/src/core/terminal_session/telnet/auto_login.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/auto_login.rs @@ -89,7 +89,9 @@ impl TelnetAutoLogin { if !config.enabled { return None; } - if credentials.username.trim().is_empty() && credentials.password.as_deref().is_none_or(str::is_empty) { + if credentials.username.trim().is_empty() + && credentials.password.as_deref().is_none_or(str::is_empty) + { return None; } @@ -117,9 +119,8 @@ impl TelnetAutoLogin { return Vec::new(); } - if now.duration_since(self.started_at) > Duration::from_millis(self.config.timeout_ms) { - self.disabled = true; - return vec![TelnetAutoLoginAction::Disable]; + if let Some(action) = self.tick(now) { + return vec![action]; } self.push_tail(text); @@ -161,7 +162,11 @@ impl TelnetAutoLogin { } if !self.sent_password - && self.credentials.password.as_deref().is_some_and(|value| !value.is_empty()) + && self + .credentials + .password + .as_deref() + .is_some_and(|value| !value.is_empty()) && self.matches_password_prompt(&prompts) { self.sent_password = true; @@ -187,6 +192,18 @@ impl TelnetAutoLogin { Some(TelnetAutoLoginAction::Disable) } + pub fn tick(&mut self, now: Instant) -> Option { + if !self.disabled + && !self.completed + && now.duration_since(self.started_at) > Duration::from_millis(self.config.timeout_ms) + { + self.disabled = true; + Some(TelnetAutoLoginAction::Disable) + } else { + None + } + } + fn push_tail(&mut self, text: &str) { self.tail.push_str(text); self.tail = last_chars(&self.tail, AUTO_LOGIN_TAIL_CHARS); @@ -227,13 +244,16 @@ impl TelnetAutoLogin { fn matches_failure(&self, text: &str, last_line: &str) -> bool { self.failure_regex.as_ref().map_or_else( - || default_failure_regex().is_match(text) || default_failure_regex().is_match(last_line), + || { + default_failure_regex().is_match(text) + || default_failure_regex().is_match(last_line) + }, |regex| regex.is_match(text) || regex.is_match(last_line), ) } } -fn compile_optional_regex(pattern: Option<&str>) -> Option { +pub fn compile_optional_regex(pattern: Option<&str>) -> Option { let trimmed = pattern?.trim(); if trimmed.is_empty() { return None; @@ -241,13 +261,13 @@ fn compile_optional_regex(pattern: Option<&str>) -> Option { Regex::new(trimmed).ok() } -fn line_bytes(value: &str, enter_mode: TelnetEnterMode) -> Vec { +pub fn line_bytes(value: &str, enter_mode: TelnetEnterMode) -> Vec { let mut data = value.as_bytes().to_vec(); data.push(b'\r'); normalize_enter_bytes(&data, enter_mode) } -fn last_chars(value: &str, max_chars: usize) -> String { +pub fn last_chars(value: &str, max_chars: usize) -> String { let len = value.chars().count(); if len <= max_chars { return value.to_string(); @@ -255,7 +275,7 @@ fn last_chars(value: &str, max_chars: usize) -> String { value.chars().skip(len - max_chars).collect() } -fn last_non_empty_line(value: &str) -> String { +pub fn last_non_empty_line(value: &str) -> String { value .lines() .rev() @@ -265,7 +285,7 @@ fn last_non_empty_line(value: &str) -> String { .to_string() } -fn prompt_candidates(window: &str, current_input: &str) -> Vec { +pub fn prompt_candidates(window: &str, current_input: &str) -> Vec { let mut prompts = Vec::new(); for source in [window, current_input] { for line in source.lines() { @@ -277,13 +297,13 @@ fn prompt_candidates(window: &str, current_input: &str) -> Vec { prompts } -fn push_prompt_candidate(prompts: &mut Vec, prompt: &str) { +pub fn push_prompt_candidate(prompts: &mut Vec, prompt: &str) { if !prompt.is_empty() && !prompts.iter().any(|existing| existing == prompt) { prompts.push(prompt.to_string()); } } -fn push_prompt_suffix_candidates(prompts: &mut Vec, prompt: &str) { +pub fn push_prompt_suffix_candidates(prompts: &mut Vec, prompt: &str) { const KEYWORDS: &[&str] = &[ "user name", "username", @@ -321,7 +341,7 @@ fn push_prompt_suffix_candidates(prompts: &mut Vec, prompt: &str) { } } -fn default_username_regex() -> &'static Regex { +pub fn default_username_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { Regex::new( @@ -331,14 +351,13 @@ fn default_username_regex() -> &'static Regex { }) } -fn last_login_regex() -> &'static Regex { +pub fn last_login_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"(?i)\b(?:last|previous)\s+login\b").expect("last login regex") - }) + REGEX + .get_or_init(|| Regex::new(r"(?i)\b(?:last|previous)\s+login\b").expect("last login regex")) } -fn default_password_regex() -> &'static Regex { +pub fn default_password_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { Regex::new( @@ -348,7 +367,7 @@ fn default_password_regex() -> &'static Regex { }) } -fn default_wake_regex() -> &'static Regex { +pub fn default_wake_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { Regex::new(r"(?i)(press\s+(?:return||\[enter\]|enter|any\s+key))") @@ -356,15 +375,17 @@ fn default_wake_regex() -> &'static Regex { }) } -fn default_success_regex() -> &'static Regex { +pub fn default_success_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| Regex::new(r"[$#>]\s*$").expect("default success prompt regex")) } -fn default_failure_regex() -> &'static Regex { +pub fn default_failure_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { - Regex::new(r"(?i)(login\s+incorrect|authentication\s+failed|access\s+denied|密码错误|认证失败)") - .expect("default failure prompt regex") + Regex::new( + r"(?i)(login\s+incorrect|authentication\s+failed|access\s+denied|密码错误|认证失败)", + ) + .expect("default failure prompt regex") }) } diff --git a/src-tauri/src/core/terminal_session/telnet/line_editor.rs b/src-tauri/crates/nyaterm-core/src/telnet/line_editor.rs similarity index 93% rename from src-tauri/src/core/terminal_session/telnet/line_editor.rs rename to src-tauri/crates/nyaterm-core/src/telnet/line_editor.rs index 3b127d77..205bb5d4 100644 --- a/src-tauri/src/core/terminal_session/telnet/line_editor.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/line_editor.rs @@ -1,4 +1,4 @@ -fn local_echo_text(data: &[u8]) -> String { +pub fn local_echo_text(data: &[u8]) -> String { let mut visible = Vec::with_capacity(data.len()); let mut i = 0; while i < data.len() { @@ -41,23 +41,22 @@ fn local_echo_text(data: &[u8]) -> String { } #[derive(Debug, Default)] -struct TelnetLineEditor { +pub struct TelnetLineEditor { buffer: String, } #[derive(Debug, Default, PartialEq, Eq)] -struct TelnetLineEditResult { - display: String, - writes: Vec>, +pub struct TelnetLineEditResult { + pub display: String, + pub writes: Vec>, } impl TelnetLineEditor { - #[cfg(test)] - fn buffer(&self) -> &str { + pub fn buffer(&self) -> &str { &self.buffer } - fn process(&mut self, data: &[u8], enter_mode: TelnetEnterMode) -> TelnetLineEditResult { + pub fn process(&mut self, data: &[u8], enter_mode: TelnetEnterMode) -> TelnetLineEditResult { let input = String::from_utf8_lossy(data); let mut result = TelnetLineEditResult::default(); let mut chars = input.char_indices().peekable(); @@ -116,7 +115,7 @@ impl TelnetLineEditor { } } -fn consume_escape_sequence_end( +pub fn consume_escape_sequence_end( start: usize, chars: &mut std::iter::Peekable>, ) -> usize { diff --git a/src-tauri/crates/nyaterm-core/src/telnet/mod.rs b/src-tauri/crates/nyaterm-core/src/telnet/mod.rs new file mode 100644 index 00000000..51b0364a --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/telnet/mod.rs @@ -0,0 +1,17 @@ +//! Transport-independent Telnet negotiation, editing and automatic login. +pub const IAC: u8 = 255; +pub const WILL: u8 = 251; +pub const WONT: u8 = 252; +pub const DO: u8 = 253; +pub const DONT: u8 = 254; +pub const SB: u8 = 250; +pub const SE: u8 = 240; +pub const OPT_ECHO: u8 = 1; +pub const OPT_SUPPRESS_GO_AHEAD: u8 = 3; +pub const OPT_NAWS: u8 = 31; +include!("types.rs"); +include!("negotiation.rs"); +include!("line_editor.rs"); +include!("auto_login.rs"); + +include!("tests.rs"); diff --git a/src-tauri/src/core/terminal_session/telnet/negotiation.rs b/src-tauri/crates/nyaterm-core/src/telnet/negotiation.rs similarity index 59% rename from src-tauri/src/core/terminal_session/telnet/negotiation.rs rename to src-tauri/crates/nyaterm-core/src/telnet/negotiation.rs index 8915d881..344ae4fb 100644 --- a/src-tauri/src/core/terminal_session/telnet/negotiation.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/negotiation.rs @@ -1,4 +1,4 @@ -fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) -> Vec { +pub fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) -> Vec { match command { WILL => { if option == OPT_ECHO || (send_sga && option == OPT_SUPPRESS_GO_AHEAD) { @@ -8,7 +8,7 @@ fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) } } DO => { - if send_naws && option == OPT_NAWS { + if (send_naws && option == OPT_NAWS) || (send_sga && option == OPT_SUPPRESS_GO_AHEAD) { vec![IAC, WILL, option] } else { vec![IAC, WONT, option] @@ -21,21 +21,56 @@ fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) } /// Build a NAWS (Negotiate About Window Size) subnegotiation sequence. -fn build_naws(cols: u16, rows: u16) -> Vec { - vec![ - IAC, - SB, - OPT_NAWS, - (cols >> 8) as u8, - (cols & 0xff) as u8, - (rows >> 8) as u8, - (rows & 0xff) as u8, - IAC, - SE, - ] +pub fn build_naws(cols: u16, rows: u16) -> Vec { + let mut result = vec![IAC, SB, OPT_NAWS]; + result.extend(escape_telnet_application_data( + &[(cols >> 8) as u8, cols as u8, (rows >> 8) as u8, rows as u8], + false, + )); + result.extend([IAC, SE]); + result } -fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Option> { +/// Negotiation may span arbitrary TCP reads, including escaped IAC bytes. +#[derive(Default)] +pub struct TelnetDecoder { + state: u8, + command: u8, +} +impl TelnetDecoder { + pub fn decode(&mut self, data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec { + let mut visible = Vec::with_capacity(data.len()); + for &byte in data { + match self.state { + 0 if byte == IAC => self.state = 1, + 0 => visible.push(byte), + 1 => match byte { + IAC => { + visible.push(IAC); + self.state = 0; + } + WILL | WONT | DO | DONT => { + self.command = byte; + self.state = 2; + } + SB => self.state = 3, + _ => self.state = 0, + }, + 2 => { + on_negotiate(self.command, byte); + self.state = 0; + } + 3 if byte == IAC => self.state = 4, + 3 => {} + 4 => self.state = if byte == SE { 0 } else { 3 }, + _ => unreachable!(), + } + } + visible + } +} + +pub fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Option> { if config.raw_tcp_cli || !config.send_naws { None } else { @@ -43,7 +78,7 @@ fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Optio } } -fn unescape_iac_iac(data: &[u8]) -> Vec { +pub fn unescape_iac_iac(data: &[u8]) -> Vec { let mut visible = Vec::with_capacity(data.len()); let mut i = 0; while i < data.len() { @@ -58,7 +93,7 @@ fn unescape_iac_iac(data: &[u8]) -> Vec { visible } -fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec { +pub fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec { if raw_tcp_cli { return data.to_vec(); } @@ -75,7 +110,7 @@ fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec { /// Strip IAC sequences from raw data, returning only user-visible bytes. /// Calls `on_negotiate` for each IAC command/option pair encountered. -fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec { +pub fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec { let mut visible = Vec::with_capacity(data.len()); let mut i = 0; while i < data.len() { @@ -117,7 +152,7 @@ fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> visible } -fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec { +pub fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec { let replacement: &[u8] = match enter_mode { TelnetEnterMode::Crlf => b"\r\n", TelnetEnterMode::Cr => b"\r", @@ -134,7 +169,7 @@ fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec { normalized } -fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] { +pub fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] { match enter_mode { TelnetEnterMode::Crlf => b"\r\n", TelnetEnterMode::Cr => b"\r", @@ -142,7 +177,7 @@ fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] { } } -fn split_write_chunks(data: &[u8], force_character_at_a_time: bool) -> Vec> { +pub fn split_write_chunks(data: &[u8], force_character_at_a_time: bool) -> Vec> { if !force_character_at_a_time { return vec![data.to_vec()]; } diff --git a/src-tauri/crates/nyaterm-core/src/telnet/tests.rs b/src-tauri/crates/nyaterm-core/src/telnet/tests.rs new file mode 100644 index 00000000..6bd60c9f --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/telnet/tests.rs @@ -0,0 +1,405 @@ +#[cfg(test)] +mod tests { + #[test] + fn fragmented_iac_and_subnegotiation_preserve_only_application_bytes() { + use super::{SB, SE, TelnetDecoder}; + let wire = [ + b'a', + IAC, + WILL, + OPT_SUPPRESS_GO_AHEAD, + IAC, + IAC, + IAC, + SB, + 31, + 0, + IAC, + IAC, + 3, + IAC, + SE, + b'b', + ]; + for split in 0..=wire.len() { + let mut decoder = TelnetDecoder::default(); + let mut negotiations = Vec::new(); + let mut callback = |cmd, opt| negotiations.push((cmd, opt)); + let mut bytes = decoder.decode(&wire[..split], &mut callback); + bytes.extend(decoder.decode(&wire[split..], &mut callback)); + assert_eq!(bytes, [b'a', IAC, b'b'], "split={split}"); + assert_eq!(negotiations, [(WILL, OPT_SUPPRESS_GO_AHEAD)]); + } + } + + #[test] + fn naws_escapes_255_in_each_dimension() { + assert_eq!( + super::build_naws(255, 511), + [ + IAC, + super::SB, + OPT_NAWS, + 0, + IAC, + IAC, + 1, + IAC, + IAC, + IAC, + super::SE + ] + ); + } + use super::{ + DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetAutoLogin, TelnetAutoLoginAction, + TelnetAutoLoginConfig, TelnetAutoLoginCredentials, TelnetEnterMode, TelnetLineEditor, + TelnetSessionConfig, WILL, escape_telnet_application_data, maybe_build_naws, + negotiate_response, normalize_enter_bytes, split_write_chunks, strip_telnet_commands, + }; + use std::time::Instant; + + #[test] + fn standard_negotiation_responds_by_default() { + assert_eq!( + negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, true), + vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] + ); + } + + #[test] + fn send_sga_false_rejects_sga() { + assert_ne!( + negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, false), + vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] + ); + } + + #[test] + fn send_naws_false_rejects_naws_negotiation() { + assert_ne!( + negotiate_response(DO, OPT_NAWS, false, true), + vec![IAC, WILL, OPT_NAWS] + ); + } + + #[test] + fn raw_mode_can_suppress_negotiation_responses() { + let config = TelnetSessionConfig { + raw_tcp_cli: true, + ..Default::default() + }; + let mut responses = Vec::new(); + if !config.raw_tcp_cli { + let _ = strip_telnet_commands(&[IAC, WILL, OPT_SUPPRESS_GO_AHEAD], &mut |cmd, opt| { + responses.push(negotiate_response( + cmd, + opt, + config.send_naws, + config.send_sga, + )); + }); + } + assert!(responses.is_empty()); + } + + #[test] + fn binary_input_escapes_iac_only_for_telnet_transport() { + let mouse_report = b"\x1b[M \xff\x80"; + assert_eq!( + escape_telnet_application_data(mouse_report, false), + b"\x1b[M \xff\xff\x80" + ); + assert_eq!( + escape_telnet_application_data(mouse_report, true), + mouse_report + ); + } + + #[test] + fn send_naws_false_prevents_naws_resize_payload() { + let config = TelnetSessionConfig { + send_naws: false, + ..Default::default() + }; + assert!(maybe_build_naws(80, 24, &config).is_none()); + } + + #[test] + fn raw_mode_prevents_naws_resize_payload() { + let config = TelnetSessionConfig { + raw_tcp_cli: true, + ..Default::default() + }; + assert!(maybe_build_naws(80, 24, &config).is_none()); + } + + #[test] + fn enter_conversion_maps_carriage_return() { + assert_eq!( + normalize_enter_bytes(b"show\r", TelnetEnterMode::Crlf), + b"show\r\n" + ); + assert_eq!( + normalize_enter_bytes(b"show\r", TelnetEnterMode::Cr), + b"show\r" + ); + assert_eq!( + normalize_enter_bytes(b"show\r", TelnetEnterMode::Lf), + b"show\n" + ); + } + + #[test] + fn force_character_at_a_time_preserves_utf8_order() { + let chunks = split_write_chunks("a中\r".as_bytes(), true); + assert_eq!( + chunks, + vec![b"a".to_vec(), "中".as_bytes().to_vec(), b"\r".to_vec()] + ); + let joined: Vec = chunks.into_iter().flatten().collect(); + assert_eq!(joined, "a中\r".as_bytes()); + } + + #[test] + fn strip_telnet_commands_emits_naws_response_request() { + let mut seen = Vec::new(); + let visible = strip_telnet_commands(b"hi\xff\xfd\x1f", &mut |cmd, opt| { + seen.push((cmd, opt)); + }); + assert_eq!(visible, b"hi"); + assert_eq!(seen, vec![(DO, OPT_NAWS)]); + } + + #[test] + fn local_line_editor_backspace_updates_buffer() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7f", TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), "ab"); + assert_eq!(result.display, "abc\x08 \x08"); + assert!(result.writes.is_empty()); + } + + #[test] + fn local_line_editor_sends_buffer_on_enter() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), ""); + assert_eq!(result.writes, vec![b"abd\r".to_vec()]); + assert_eq!(result.display, "abc\x08 \x08d\r\n"); + + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Crlf); + assert_eq!(result.writes, vec![b"abd\r\n".to_vec()]); + + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Lf); + assert_eq!(result.writes, vec![b"abd\n".to_vec()]); + } + + #[test] + fn local_line_editor_backspace_removes_one_utf8_char() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process("中a\u{7f}".as_bytes(), TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), "中"); + assert_eq!(result.display, "中a\x08 \x08"); + + let result = editor.process(b"\x7f", TelnetEnterMode::Cr); + assert_eq!(editor.buffer(), ""); + assert_eq!(result.display, "\x08 \x08"); + } + + #[test] + fn local_line_editor_passes_controls_without_buffering() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"a\x03\x04\x1b[A\x1b[3~", TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), ""); + assert_eq!( + result.writes, + vec![vec![0x03], vec![0x04], b"\x1b[A".to_vec()] + ); + assert_eq!(result.display, "a\x08 \x08"); + } + + fn auto_login() -> TelnetAutoLogin { + TelnetAutoLogin::new( + TelnetAutoLoginConfig::default(), + TelnetAutoLoginCredentials { + username: "admin".to_string(), + password: Some("secret".to_string()), + }, + TelnetEnterMode::Cr, + Instant::now(), + ) + .expect("auto login") + } + + fn sent_payloads(actions: Vec) -> Vec> { + actions + .into_iter() + .filter_map(|action| match action { + TelnetAutoLoginAction::Send(data) => Some(data), + _ => None, + }) + .collect() + } + + #[test] + fn auto_login_detects_split_username_and_password_prompts() { + let mut login = auto_login(); + + assert!(login.handle_text("User", Instant::now()).is_empty()); + assert_eq!( + sent_payloads(login.handle_text("name:", Instant::now())), + vec![b"admin\r".to_vec()] + ); + assert!(login.handle_text("Pass", Instant::now()).is_empty()); + assert_eq!( + sent_payloads(login.handle_text("word:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_detects_chinese_prompts() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("用户名:", Instant::now())), + vec![b"admin\r".to_vec()] + ); + assert_eq!( + sent_payloads(login.handle_text("密码:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_detects_linux_getty_host_login_prompt() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text( + "\r\nLinux 6.8.0-107-generic (kaikai) (pts/3)\r\n\r\nkaikai login: ", + Instant::now() + )), + vec![b"admin\r".to_vec()] + ); + } + + #[test] + fn auto_login_detects_password_prompt_without_colon() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("Input Password", Instant::now())), + vec![b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_sends_wake_enter_once() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("Press RETURN to get started.", Instant::now())), + vec![b"\r".to_vec()] + ); + assert!( + login + .handle_text("Press RETURN to get started.", Instant::now()) + .is_empty() + ); + } + + #[test] + fn auto_login_does_not_treat_last_login_as_username_prompt() { + let mut login = auto_login(); + + assert!( + login + .handle_text("Last login: Mon Jul 13 12:00:00", Instant::now()) + .is_empty() + ); + assert!( + login + .handle_text("Previous login: Mon Jul 13 12:00:00", Instant::now()) + .is_empty() + ); + } + + #[test] + fn auto_login_sends_username_before_password_in_same_chunk() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("Username:\nPassword:", Instant::now())), + vec![b"admin\r".to_vec(), b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_completes_on_shell_prompt_after_credentials() { + let mut login = auto_login(); + let _ = login.handle_text("Username:", Instant::now()); + let _ = login.handle_text("Password:", Instant::now()); + + assert!( + login + .handle_text("router#", Instant::now()) + .contains(&TelnetAutoLoginAction::Complete) + ); + } + + #[test] + fn auto_login_failure_retries_then_disables() { + let mut login = TelnetAutoLogin::new( + TelnetAutoLoginConfig { + max_retries: 1, + ..TelnetAutoLoginConfig::default() + }, + TelnetAutoLoginCredentials { + username: String::new(), + password: Some("secret".to_string()), + }, + TelnetEnterMode::Cr, + Instant::now(), + ) + .expect("auto login"); + + assert_eq!( + sent_payloads(login.handle_text("Password:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + assert!( + login + .handle_text("Authentication failed", Instant::now()) + .is_empty() + ); + assert_eq!( + sent_payloads(login.handle_text("Password:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + assert!( + login + .handle_text("Authentication failed", Instant::now()) + .contains(&TelnetAutoLoginAction::Disable) + ); + } + + #[test] + fn auto_login_user_input_disables_but_automated_input_does_not() { + let mut login = auto_login(); + + assert_eq!(login.handle_user_input(true), None); + assert_eq!( + login.handle_user_input(false), + Some(TelnetAutoLoginAction::Disable) + ); + assert!(login.handle_text("Username:", Instant::now()).is_empty()); + } +} diff --git a/src-tauri/src/core/terminal_session/telnet/types.rs b/src-tauri/crates/nyaterm-core/src/telnet/types.rs similarity index 95% rename from src-tauri/src/core/terminal_session/telnet/types.rs rename to src-tauri/crates/nyaterm-core/src/telnet/types.rs index 1cc3310e..f0522d1f 100644 --- a/src-tauri/src/core/terminal_session/telnet/types.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/types.rs @@ -23,6 +23,7 @@ impl TelnetEnterMode { #[derive(Debug, Clone)] pub struct TelnetSessionConfig { + pub network: Option, pub host: String, pub port: u16, pub name: String, @@ -43,6 +44,7 @@ pub struct TelnetSessionConfig { impl Default for TelnetSessionConfig { fn default() -> Self { Self { + network: None, host: String::new(), port: 23, name: "Telnet".to_string(), @@ -67,4 +69,3 @@ pub struct TelnetStartupCommand { pub command: String, pub delay_ms: u64, } - diff --git a/src-tauri/crates/nyaterm-core/src/terminal_encoding.rs b/src-tauri/crates/nyaterm-core/src/terminal_encoding.rs new file mode 100644 index 00000000..0213949c --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/terminal_encoding.rs @@ -0,0 +1,185 @@ +fn remap_del_to_bs(data: &mut [u8]) { + for byte in data { + if *byte == 0x7f { + *byte = 0x08; + } + } +} +use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8}; + +pub fn terminal_encoding(label: &str) -> &'static Encoding { + let trimmed = label.trim(); + if trimmed.is_empty() { + return UTF_8; + } + Encoding::for_label(trimmed.as_bytes()).unwrap_or(UTF_8) +} + +pub struct TerminalOutputDecoder { + decoder: Decoder, +} + +impl TerminalOutputDecoder { + pub fn new(encoding: &str) -> Self { + Self { + decoder: terminal_encoding(encoding).new_decoder_without_bom_handling(), + } + } + + pub fn decode(&mut self, data: &[u8]) -> String { + let capacity = self + .decoder + .max_utf8_buffer_length(data.len()) + .unwrap_or_else(|| data.len().saturating_mul(4)); + let mut output = String::with_capacity(capacity); + let mut total_read = 0; + while total_read < data.len() { + let (result, read, _) = + self.decoder + .decode_to_string(&data[total_read..], &mut output, false); + total_read += read; + match result { + CoderResult::InputEmpty => break, + CoderResult::OutputFull => { + output.reserve( + self.decoder + .max_utf8_buffer_length(data.len() - total_read) + .unwrap_or_else(|| (data.len() - total_read).saturating_mul(4)) + .max(4), + ); + } + } + } + output + } +} + +pub fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec { + let target = terminal_encoding(encoding); + if target == UTF_8 { + return data.to_vec(); + } + + let mut result = Vec::with_capacity(data.len()); + let mut i = 0; + while i < data.len() { + if data[i] == 0x1b { + let end = ansi_sequence_end(data, i); + result.extend_from_slice(&data[i..end]); + i = end; + continue; + } + + if data[i].is_ascii() { + result.push(data[i]); + i += 1; + continue; + } + + let start = i; + while i < data.len() && !data[i].is_ascii() && data[i] != 0x1b { + i += 1; + } + + let text = String::from_utf8_lossy(&data[start..i]); + let (encoded, _, _) = target.encode(&text); + result.extend_from_slice(&encoded); + } + + result +} + +pub fn prepare_terminal_write_input( + mut data: Vec, + encoding: &str, + raw: bool, + backspace_as_bs: bool, +) -> Vec { + if raw { + return data; + } + if backspace_as_bs { + remap_del_to_bs(&mut data); + } + encode_terminal_input(&data, encoding) +} + +fn ansi_sequence_end(data: &[u8], start: usize) -> usize { + let mut i = start + 1; + if i >= data.len() { + return i; + } + + if matches!(data[i], b'[' | b']' | b'(' | b')' | b'#') { + i += 1; + while i < data.len() { + let byte = data[i]; + i += 1; + if (0x40..=0x7e).contains(&byte) { + break; + } + } + return i; + } + + (i + 1).min(data.len()) +} + +#[cfg(test)] +mod tests { + use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input}; + + #[test] + fn write_input_encodes_text_but_preserves_raw_bytes_and_del() { + assert_eq!( + prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true), + vec![0xB2, 0xE2, 0x08] + ); + let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f]; + assert_eq!( + prepare_terminal_write_input(raw.clone(), "GBK", true, true), + raw + ); + } + + #[test] + fn gbk_decoder_preserves_split_multibyte_character() { + let mut decoder = TerminalOutputDecoder::new("GBK"); + assert_eq!(decoder.decode(&[0xB2]), ""); + assert_eq!(decoder.decode(&[0xE2]), "测"); + } + + #[test] + fn gb18030_decoder_preserves_split_multibyte_character() { + let mut decoder = TerminalOutputDecoder::new("GB18030"); + assert_eq!(decoder.decode(&[0xB2]), ""); + assert_eq!(decoder.decode(&[0xE2]), "测"); + } + + #[test] + fn gbk_decoder_handles_utf8_output_expansion() { + let mut decoder = TerminalOutputDecoder::new("GBK"); + let mut input = Vec::new(); + for _ in 0..1024 { + input.extend_from_slice(&[0xB2, 0xE2]); + } + + let output = decoder.decode(&input); + + assert_eq!(output.chars().count(), 1024); + assert!(output.chars().all(|ch| ch == '测')); + } + + #[test] + fn input_encoder_keeps_controls_and_ansi_sequences_raw() { + let input = b"\x1b[200~\x7f\xe6\xb5\x8b\x1b[201~"; + let encoded = encode_terminal_input(input, "GBK"); + assert_eq!( + encoded, + vec![ + 0x1b, b'[', b'2', b'0', b'0', b'~', 0x7f, 0xB2, 0xE2, 0x1b, b'[', b'2', b'0', b'1', + b'~' + ] + ); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/vnc/manager.rs b/src-tauri/crates/nyaterm-core/src/vnc/manager.rs new file mode 100644 index 00000000..acb4fe33 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/vnc/manager.rs @@ -0,0 +1,1795 @@ +use super::runtime::{FrameChannel, VncContext as AppHandle, open_tcp_transport}; +use crate::config::{self, ConnectionAuth, ConnectionNetwork, ConnectionType}; +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use tokio::sync::{Mutex, mpsc, oneshot, watch}; +use tokio::task::JoinHandle; +use tokio::time::{Duration, timeout}; +use vnc::{ + ClientKeyEvent, ClientMouseEvent, PixelFormat, Rect, Screen, VncClient, VncConnector, + VncEncoding, VncError, VncEvent, VncLimits, VncSecurityPolicy, VncServerKey, X11Event, +}; +use zeroize::Zeroizing; + +use crate::remote_desktop_frame::{ + RemoteDesktopFramePatch, RemoteDesktopPixelFormat, encode_frame_patch, +}; + +const MAX_FRAMEBUFFER_WIDTH: u16 = 7680; +const MAX_FRAMEBUFFER_HEIGHT: u16 = 4320; +const MAX_VNC_CLIPBOARD_BYTES: usize = 1024 * 1024; +const MAX_INPUT_BATCH: usize = 256; +const WORKER_COMMAND_CHANNEL_CAPACITY: usize = 256; +const MAX_PENDING_FRAMES: usize = 2; +// A routed connection can require interactive SSH jump authentication. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(120); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); +const RA2_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(75); +const SERVER_KEY_PROMPT_TIMEOUT: Duration = Duration::from_mins(1); +const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(8); +const UPDATE_REQUEST_INTERVAL: Duration = Duration::from_millis(16); + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VncSessionState { + Connecting, + Authenticating, + Negotiating, + Active, + Reconnecting, + Disconnected, + Failed, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VncErrorKind { + Transport, + Authentication, + Protocol, + Encoding, + Clipboard, + Internal, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VncStateEvent { + pub session_id: String, + pub state: VncSessionState, + pub message: Option, + pub error_kind: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VncServerKeyVerifyEvent { + pub request_id: String, + pub session_id: String, + pub host: String, + pub port: u16, + pub fingerprint: String, + pub key_bits: u32, + pub known_host_status: String, + pub target_window_label: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VncClipboardEvent { + pub session_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum VncInputEvent { + #[serde(rename = "key")] + Key { keysym: u32, pressed: bool }, + #[serde(rename = "pointer")] + Pointer { + x: u16, + y: u16, + #[serde(alias = "buttonMask")] + button_mask: u8, + }, + #[serde(rename = "release-all-keys")] + ReleaseAllKeys, +} + +#[derive(Debug)] +enum VncWorkerCommand { + Input(VncInputEvent), + Clipboard(String), + FullRefresh, +} + +struct VncServerKeyPending { + payload: VncServerKeyVerifyEvent, + session_id: String, + generation: u64, + responder: oneshot::Sender, +} + +#[derive(Debug, Clone)] +pub struct VncConnectConfig { + pub owner_window_label: String, + pub session_id: String, + pub connection_id: String, + pub name: String, + pub host: String, + pub port: u16, + pub username: String, + pub password: Option, + pub security_mode: String, + pub scale_mode: String, + pub clipboard_enabled: bool, + pub reconnect_enabled: bool, + pub reconnect_max_attempts: u32, + pub shared: bool, + pub view_only: bool, + pub network: Option, +} + +struct VncFramebuffer { + width: u16, + height: u16, + rgba: Vec, +} + +impl VncFramebuffer { + fn new(width: u16, height: u16) -> AppResult { + validate_framebuffer_dimensions(width, height)?; + let len = usize::from(width) + .checked_mul(usize::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| AppError::Config("VNC framebuffer size overflows".to_string()))?; + Ok(Self { + width, + height, + rgba: vec![0; len], + }) + } + + fn apply_rgba(&mut self, rect: Rect, pixels: &[u8]) -> AppResult<()> { + validate_rectangle(rect, self.width, self.height)?; + let row_bytes = usize::from(rect.width) + .checked_mul(4) + .ok_or_else(|| AppError::Config("VNC rectangle row size overflows".to_string()))?; + let required = row_bytes + .checked_mul(usize::from(rect.height)) + .ok_or_else(|| AppError::Config("VNC rectangle payload size overflows".to_string()))?; + if pixels.len() != required { + return Err(AppError::Config( + "VNC rectangle payload length is invalid".to_string(), + )); + } + let framebuffer_stride = usize::from(self.width) * 4; + for row in 0..usize::from(rect.height) { + let src_start = row * row_bytes; + let dst_start = + (usize::from(rect.y) + row) * framebuffer_stride + usize::from(rect.x) * 4; + self.rgba[dst_start..dst_start + row_bytes] + .copy_from_slice(&pixels[src_start..src_start + row_bytes]); + } + Ok(()) + } + + fn patch_bytes(&self, sequence: u64, rect: Rect, pixels: &[u8]) -> AppResult> { + encode_frame_patch(&RemoteDesktopFramePatch { + sequence, + desktop_width: u32::from(self.width), + desktop_height: u32::from(self.height), + x: u32::from(rect.x), + y: u32::from(rect.y), + width: u32::from(rect.width), + height: u32::from(rect.height), + stride: u32::from(rect.width) * 4, + pixel_format: RemoteDesktopPixelFormat::Rgba8888, + payload: pixels, + }) + } + + fn full_frame_bytes(&self, sequence: u64) -> AppResult> { + self.patch_bytes( + sequence, + Rect { + x: 0, + y: 0, + width: self.width, + height: self.height, + }, + &self.rgba, + ) + } +} + +pub struct VncSession { + pub config: VncConnectConfig, + state: Mutex, + message: Mutex>, + error_kind: Mutex>, + generation: AtomicU64, + frame_sequence: AtomicU64, + frame_attach_id: AtomicU64, + frame_channel: Mutex>, + pending_frames: Mutex>>, + framebuffer: Mutex>, + command_sender: Mutex>>, + cancel_sender: Mutex>>, + worker: Mutex>>, + close_requested: AtomicBool, + trust_commit_guard: std::sync::Mutex<()>, + pending_server_keys: Arc>>, +} + +pub struct VncSessionManager { + sessions: Mutex>>, + pending_server_keys: Arc>>, +} + +impl VncSessionManager { + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + pending_server_keys: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn create_session( + self: &Arc, + app: AppHandle, + config: VncConnectConfig, + ) -> AppResult { + let session_id = config.session_id.clone(); + let session = Arc::new(VncSession { + config, + state: Mutex::new(VncSessionState::Connecting), + message: Mutex::new(None), + error_kind: Mutex::new(None), + generation: AtomicU64::new(0), + frame_sequence: AtomicU64::new(0), + frame_attach_id: AtomicU64::new(0), + frame_channel: Mutex::new(None), + pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), + framebuffer: Mutex::new(None), + command_sender: Mutex::new(None), + cancel_sender: Mutex::new(None), + worker: Mutex::new(None), + close_requested: AtomicBool::new(false), + trust_commit_guard: std::sync::Mutex::new(()), + pending_server_keys: self.pending_server_keys.clone(), + }); + self.sessions + .lock() + .await + .insert(session_id.clone(), session.clone()); + emit_state(&app, &session, VncSessionState::Connecting, None, None); + tracing::info!( + session_id = %session_id, + connection_id = %session.config.connection_id, + name = %session.config.name, + scale_mode = %session.config.scale_mode, + "Created VNC session" + ); + spawn_session_worker(app.clone(), session).await; + let _ = app.emit("sessions-changed", ()); + Ok(session_id) + } + + pub async fn attach_frame_channel( + &self, + app: &AppHandle, + session_id: &str, + channel: FrameChannel, + ) -> AppResult<()> { + let session = self.get(session_id).await?; + let attach_id = session.frame_attach_id.fetch_add(1, Ordering::AcqRel) + 1; + *session.frame_channel.lock().await = Some((attach_id, channel)); + // The framebuffer is authoritative; replaying partial history before it + // fills bounded Web queues and can perpetuate refresh overflows. + if session.framebuffer.lock().await.is_some() { + session.pending_frames.lock().await.clear(); + } else { + flush_pending_frames(&session, attach_id).await; + } + send_full_frame(&session, Some(attach_id)).await?; + let _ = send_worker_command(&session, VncWorkerCommand::FullRefresh).await; + replay_state(app, &session).await; + for pending in session + .pending_server_keys + .lock() + .await + .values() + .filter(|p| { + p.session_id == session_id + && p.generation == session.generation.load(Ordering::Acquire) + }) + { + let _ = app.emit_to( + &session.config.owner_window_label, + "vnc-server-key-verify", + pending.payload.clone(), + ); + } + Ok(()) + } + + pub async fn detach_frame_channel(&self, session_id: &str) -> AppResult<()> { + let session = self.get(session_id).await?; + session.frame_attach_id.fetch_add(1, Ordering::AcqRel); + *session.frame_channel.lock().await = None; + Ok(()) + } + + pub async fn send_input(&self, session_id: &str, events: Vec) -> AppResult<()> { + let session = self.get(session_id).await?; + if session.config.view_only { + return Err(AppError::Config( + "VNC input is disabled for a view-only session".to_string(), + )); + } + if events.len() > MAX_INPUT_BATCH { + return Err(AppError::Config(format!( + "VNC input batch exceeds {MAX_INPUT_BATCH} events" + ))); + } + let sender = session + .command_sender + .lock() + .await + .clone() + .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; + for event in events { + sender + .send(VncWorkerCommand::Input(event)) + .await + .map_err(|_| AppError::Channel("VNC worker channel closed".to_string()))?; + } + Ok(()) + } + + pub async fn set_clipboard_text(&self, session_id: &str, text: String) -> AppResult<()> { + let session = self.get(session_id).await?; + if session.config.view_only || !session.config.clipboard_enabled { + return Err(AppError::Config( + "VNC clipboard sending is disabled".to_string(), + )); + } + if text.len() > MAX_VNC_CLIPBOARD_BYTES || !text.chars().all(|ch| u32::from(ch) <= 0xff) { + return Err(AppError::Config( + "VNC clipboard text must be Latin-1 and no larger than 1 MiB".to_string(), + )); + } + send_worker_command(&session, VncWorkerCommand::Clipboard(text)).await + } + + pub async fn reconnect(self: &Arc, app: AppHandle, session_id: &str) -> AppResult<()> { + let session = self.get(session_id).await?; + invalidate_vnc_generation(&session, false); + stop_worker(&session).await; + self.cancel_pending_server_keys_for_session(app.clone(), session_id) + .await; + session.close_requested.store(false, Ordering::Release); + *session.framebuffer.lock().await = None; + session.pending_frames.lock().await.clear(); + session.frame_sequence.store(0, Ordering::Release); + emit_state(&app, &session, VncSessionState::Reconnecting, None, None); + spawn_session_worker(app, session).await; + Ok(()) + } + + pub async fn close(&self, app: &AppHandle, session_id: &str) -> AppResult<()> { + let Some(session) = self.sessions.lock().await.remove(session_id) else { + return Ok(()); + }; + invalidate_vnc_generation(&session, true); + stop_worker(&session).await; + self.cancel_pending_server_keys_for_session(app.clone(), session_id) + .await; + session.frame_attach_id.fetch_add(1, Ordering::AcqRel); + *session.frame_channel.lock().await = None; + session.pending_frames.lock().await.clear(); + *session.framebuffer.lock().await = None; + set_state(&session, VncSessionState::Disconnected, None, None).await; + emit_state(app, &session, VncSessionState::Disconnected, None, None); + let _ = app.emit("sessions-changed", ()); + Ok(()) + } + + pub async fn respond_server_key(&self, request_id: &str, accepted: bool) -> AppResult<()> { + let Some(pending) = self.pending_server_keys.lock().await.remove(request_id) else { + return Err(AppError::Cancelled(format!( + "No pending VNC server key request with id '{request_id}'" + ))); + }; + let session = self.get(&pending.session_id).await?; + if session.generation.load(Ordering::Acquire) != pending.generation + || session.close_requested.load(Ordering::Acquire) + { + return Ok(()); + } + let _ = pending.responder.send(accepted); + Ok(()) + } + + async fn cancel_pending_server_keys_for_session(&self, app: AppHandle, session_id: &str) { + let mut pending = self.pending_server_keys.lock().await; + let request_ids = pending + .iter() + .filter(|(_, request)| request.session_id == session_id) + .map(|(request_id, _)| request_id.clone()) + .collect::>(); + for request_id in request_ids { + if let Some(request) = pending.remove(&request_id) { + let _ = request.responder.send(false); + let _ = app.emit( + "vnc-server-key-verify-resolved", + serde_json::json!({"requestId": request_id}), + ); + } + } + } + + pub async fn close_all(&self, app: &AppHandle) { + let ids = self + .sessions + .lock() + .await + .keys() + .cloned() + .collect::>(); + for id in ids { + if let Err(error) = self.close(app, &id).await { + tracing::warn!(session_id = %id, "Failed to close VNC session: {error}"); + } + } + } + + async fn get(&self, session_id: &str) -> AppResult> { + self.sessions + .lock() + .await + .get(session_id) + .cloned() + .ok_or_else(|| { + AppError::SessionNotFound(format!("VNC session '{session_id}' not found")) + }) + } +} + +impl Default for VncSessionManager { + fn default() -> Self { + Self::new() + } +} + +async fn spawn_session_worker(app: AppHandle, session: Arc) { + let (cancel_tx, cancel_rx) = watch::channel(false); + *session.cancel_sender.lock().await = Some(cancel_tx); + let worker_session = session.clone(); + let handle = tokio::spawn(async move { + run_session_worker(app, worker_session, cancel_rx).await; + }); + *session.worker.lock().await = Some(handle); +} + +async fn run_session_worker( + app: AppHandle, + session: Arc, + mut cancel_rx: watch::Receiver, +) { + let mut attempt = 0_u32; + loop { + if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { + return; + } + let generation = session.generation.fetch_add(1, Ordering::AcqRel) + 1; + let state = if attempt == 0 { + VncSessionState::Connecting + } else { + VncSessionState::Reconnecting + }; + set_state(&session, state.clone(), None, None).await; + emit_state(&app, &session, state, None, None); + + match run_protocol_generation(&app, &session, generation, &mut cancel_rx).await { + Ok(()) => return, + Err((kind, message, retryable)) => { + if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { + return; + } + if !retryable + || !session.config.reconnect_enabled + || attempt >= session.config.reconnect_max_attempts + { + set_state( + &session, + VncSessionState::Failed, + Some(message.clone()), + Some(kind), + ) + .await; + emit_state( + &app, + &session, + VncSessionState::Failed, + Some(message), + Some(kind), + ); + let _ = app.emit("sessions-changed", ()); + return; + } + attempt += 1; + let delay = reconnect_delay(attempt); + let sleep = tokio::time::sleep(delay); + tokio::pin!(sleep); + tokio::select! { + _ = &mut sleep => {}, + changed = cancel_rx.changed() => { + if changed.is_err() || *cancel_rx.borrow() { return; } + } + } + } + } + } +} + +async fn run_protocol_generation( + app: &AppHandle, + session: &Arc, + generation: u64, + cancel_rx: &mut watch::Receiver, +) -> Result<(), (VncErrorKind, String, bool)> { + let (host, port) = vnc_connect_target(&session.config); + let transport = tokio::select! { + result = timeout(CONNECT_TIMEOUT, open_tcp_transport( + app, + host, + port, + session.config.network.as_ref(), + Some(session.config.owner_window_label.clone()), + )) => { + result.map_err(|_| (VncErrorKind::Transport, "VNC connection timed out".to_string(), true))? + .map_err(|error| (VncErrorKind::Transport, format!("Unable to connect to the VNC server: {error}"), true))? + } + changed = cancel_rx.changed() => { + let _ = changed; + return Ok(()); + } + }; + let stream = transport.stream; + + set_state(session, VncSessionState::Authenticating, None, None).await; + emit_state(app, session, VncSessionState::Authenticating, None, None); + let password = Zeroizing::new(session.config.password.clone().unwrap_or_default()); + if session.config.security_mode == "vnc-auth" && password.len() > 8 { + return Err(( + VncErrorKind::Authentication, + "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), + false, + )); + } + + let limits = VncLimits { + max_framebuffer_width: MAX_FRAMEBUFFER_WIDTH, + max_framebuffer_height: MAX_FRAMEBUFFER_HEIGHT, + max_framebuffer_pixels: usize::from(MAX_FRAMEBUFFER_WIDTH) + * usize::from(MAX_FRAMEBUFFER_HEIGHT), + max_clipboard_bytes: MAX_VNC_CLIPBOARD_BYTES, + max_rectangles_per_update: 1024, + max_encoded_payload_bytes: 64 * 1024 * 1024, + max_decoded_payload_bytes: usize::from(MAX_FRAMEBUFFER_WIDTH) + * usize::from(MAX_FRAMEBUFFER_HEIGHT) + * 4, + channel_capacity: 32, + ..VncLimits::default() + }; + let auth_password = password.to_string(); + let has_credentials = session.config.password.is_some(); + let handshake_timeout = if session.config.security_mode == "auto" && has_credentials { + RA2_HANDSHAKE_TIMEOUT + } else { + HANDSHAKE_TIMEOUT + }; + let trust_candidate = Arc::new(std::sync::Mutex::new(None::)); + let verifier_trust_candidate = trust_candidate.clone(); + let authenticated_key = Arc::new(std::sync::Mutex::new(None::)); + let notified_key = authenticated_key.clone(); + let verifier_app = app.clone(); + let verifier_session = session.clone(); + let state = VncConnector::new(stream) + .set_auth_method(async move { Ok(auth_password) }) + .set_credentials_available(has_credentials) + .set_username(session.config.username.clone()) + .set_server_key_verifier(move |server_key| { + let app = verifier_app.clone(); + let session = verifier_session.clone(); + let trust_candidate = verifier_trust_candidate.clone(); + async move { + if let Some(fingerprint) = + verify_vnc_server_key(&app, &session, generation, &server_key).await? + { + *trust_candidate.lock().expect("VNC trust candidate lock") = Some(fingerprint); + } + Ok(()) + } + }) + .set_server_key_authenticated(move |key| { + *notified_key.lock().expect("VNC authenticated key lock") = + Some(vnc_key_fingerprint(&key)); + }) + .set_security_policy(security_policy(&session.config.security_mode)) + .set_pixel_format(PixelFormat::rgba()) + .set_limits(limits) + .add_encoding(VncEncoding::DesktopSizePseudo) + .add_encoding(VncEncoding::Zrle) + .add_encoding(VncEncoding::Tight) + .add_encoding(VncEncoding::Raw) + .allow_shared(session.config.shared) + .build() + .map_err(classify_vnc_error)?; + let handshake_result = tokio::select! { + biased; + changed = cancel_rx.changed() => { let _ = changed; None }, + result = timeout(handshake_timeout, state.try_start()) => { Some(result) }, + }; + // Also clean up a prompt if the outer handshake timeout dropped its future. + resolve_vnc_prompts(app, session, generation).await; + let Some(handshake_result) = handshake_result else { + return Ok(()); + }; + let client = handshake_result + .map_err(|_| { + ( + VncErrorKind::Transport, + "VNC protocol negotiation timed out".to_string(), + true, + ) + })? + .and_then(|state| state.finish()) + .map_err(classify_vnc_error)?; + { + // Serialize the trust write with close/reconnect invalidation. No await + // separates the final generation/cancellation check from the storage write. + let _guard = session + .trust_commit_guard + .lock() + .expect("VNC trust commit lock"); + let trust_candidate = trust_candidate.lock().expect("VNC trust candidate lock"); + let authenticated_key = authenticated_key + .lock() + .expect("VNC authenticated key lock"); + commit_vnc_trust( + trust_candidate.as_deref(), + authenticated_key.as_deref(), + generation, + session.generation.load(Ordering::Acquire), + session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow(), + |fingerprint| { + crate::storage::upsert_vnc_known_host( + &session.config.host, + session.config.port, + fingerprint, + ) + }, + ) + .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; + } + + set_state(session, VncSessionState::Negotiating, None, None).await; + emit_state(app, session, VncSessionState::Negotiating, None, None); + let (command_tx, mut command_rx) = mpsc::channel(WORKER_COMMAND_CHANNEL_CAPACITY); + *session.command_sender.lock().await = Some(command_tx); + let mut pressed_keys = Vec::::new(); + let mut poll = tokio::time::interval(EVENT_POLL_INTERVAL); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut refresh_due = false; + let refresh_delay = tokio::time::sleep(Duration::from_secs(86_400)); + tokio::pin!(refresh_delay); + + loop { + if session.generation.load(Ordering::Acquire) != generation { + let _ = client.close().await; + return Ok(()); + } + tokio::select! { + changed = cancel_rx.changed() => { + let _ = changed; + release_pressed_keys(&client, &mut pressed_keys).await; + let _ = client.close().await; + return Ok(()); + } + _ = poll.tick() => { + loop { + match client.poll_event().await { + Ok(Some(event)) => { + handle_vnc_event(app, session, generation, event).await?; + refresh_due = true; + refresh_delay.as_mut().reset(tokio::time::Instant::now() + UPDATE_REQUEST_INTERVAL); + } + Ok(None) => break, + Err(error) => return Err(classify_vnc_error(error)), + } + } + } + _ = &mut refresh_delay, if refresh_due => { + client.input(X11Event::Refresh).await.map_err(classify_vnc_error)?; + refresh_due = false; + } + command = command_rx.recv() => { + let Some(command) = command else { + let _ = client.close().await; + return Ok(()); + }; + handle_worker_command(&client, command, &mut pressed_keys).await?; + } + } + } +} + +fn vnc_connect_target(config: &VncConnectConfig) -> (&str, u16) { + (config.host.as_str(), config.port) +} + +fn security_policy(mode: &str) -> VncSecurityPolicy { + match mode { + "none" => VncSecurityPolicy::NoneOnly, + "vnc-auth" => VncSecurityPolicy::VncAuthOnly, + _ => VncSecurityPolicy::Auto, + } +} + +fn vnc_key_fingerprint(key: &VncServerKey) -> String { + format!("SHA256:{}", hex::encode(Sha256::digest(key.encoded()))) +} + +fn invalidate_vnc_generation(session: &VncSession, closing: bool) { + let _guard = session + .trust_commit_guard + .lock() + .expect("VNC trust commit lock"); + session.generation.fetch_add(1, Ordering::AcqRel); + if closing { + session.close_requested.store(true, Ordering::Release); + } +} + +fn commit_vnc_trust( + trust_candidate: Option<&str>, + authenticated_key: Option<&str>, + generation: u64, + current_generation: u64, + cancelled: bool, + save: impl FnOnce(&str) -> AppResult<()>, +) -> AppResult<()> { + if generation == current_generation + && !cancelled + && let (Some(trust_candidate), Some(authenticated_key)) = + (trust_candidate, authenticated_key) + && trust_candidate == authenticated_key + { + save(trust_candidate)?; + } + Ok(()) +} + +async fn resolve_vnc_prompts(app: &AppHandle, session: &VncSession, generation: u64) { + let mut pending = session.pending_server_keys.lock().await; + let ids: Vec<_> = pending + .iter() + .filter(|(_, request)| { + request.session_id == session.config.session_id && request.generation == generation + }) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + pending.remove(&id); + let _ = app.emit( + "vnc-server-key-verify-resolved", + serde_json::json!({"requestId": id}), + ); + } +} + +async fn verify_vnc_server_key( + app: &AppHandle, + session: &Arc, + generation: u64, + server_key: &VncServerKey, +) -> Result, VncError> { + if session.generation.load(Ordering::Acquire) != generation + || session.close_requested.load(Ordering::Acquire) + { + return Err(VncError::Ra2ServerKeyRejected( + "stale VNC connection generation".to_string(), + )); + } + + let fingerprint = vnc_key_fingerprint(server_key); + let known_host_status = crate::storage::check_vnc_known_host( + &session.config.host, + session.config.port, + &fingerprint, + ) + .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; + let confirmed_new_trust = confirm_vnc_trust( + known_host_status, + prompt_vnc_server_key( + app, + session, + generation, + server_key, + fingerprint.clone(), + known_host_status, + ), + ) + .await?; + Ok(confirmed_new_trust.then_some(fingerprint)) +} + +async fn confirm_vnc_trust( + known_host_status: crate::storage::KnownHostCheck, + prompt: impl std::future::Future>, +) -> Result { + if known_host_status == crate::storage::KnownHostCheck::Match { + return Ok(false); + } + prompt.await?; + Ok(true) +} + +async fn prompt_vnc_server_key( + app: &AppHandle, + session: &Arc, + generation: u64, + server_key: &VncServerKey, + fingerprint: String, + known_host_status: crate::storage::KnownHostCheck, +) -> Result<(), VncError> { + let request_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + let payload = VncServerKeyVerifyEvent { + request_id: request_id.clone(), + session_id: session.config.session_id.clone(), + host: session.config.host.clone(), + port: session.config.port, + fingerprint, + key_bits: server_key.bits(), + target_window_label: session.config.owner_window_label.clone(), + known_host_status: match known_host_status { + crate::storage::KnownHostCheck::Match => "match", + crate::storage::KnownHostCheck::HostSeen => "changed", + crate::storage::KnownHostCheck::UnknownHost => "unknown", + } + .to_string(), + }; + session.pending_server_keys.lock().await.insert( + request_id.clone(), + VncServerKeyPending { + payload: payload.clone(), + session_id: session.config.session_id.clone(), + generation, + responder: tx, + }, + ); + app.emit_to( + &session.config.owner_window_label, + "vnc-server-key-verify", + payload, + ) + .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; + + let response = timeout(SERVER_KEY_PROMPT_TIMEOUT, rx).await; + session.pending_server_keys.lock().await.remove(&request_id); + let _ = app.emit( + "vnc-server-key-verify-resolved", + serde_json::json!({"requestId": request_id}), + ); + let Ok(Ok(accepted)) = response else { + return Err(VncError::Ra2ServerKeyRejected( + "verification timed out".to_string(), + )); + }; + if session.generation.load(Ordering::Acquire) != generation + || session.close_requested.load(Ordering::Acquire) + { + return Err(VncError::Ra2ServerKeyRejected( + "stale VNC connection generation".to_string(), + )); + } + if !accepted { + return Err(VncError::Ra2ServerKeyRejected( + "user rejected the server key".to_string(), + )); + } + Ok(()) +} + +async fn handle_vnc_event( + app: &AppHandle, + session: &Arc, + generation: u64, + event: VncEvent, +) -> Result<(), (VncErrorKind, String, bool)> { + if session.generation.load(Ordering::Acquire) != generation { + return Ok(()); + } + match event { + VncEvent::SetResolution(Screen { width, height }) => { + let framebuffer = VncFramebuffer::new(width, height) + .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; + *session.framebuffer.lock().await = Some(framebuffer); + } + VncEvent::RawImage(rect, pixels) => { + let mut framebuffer_guard = session.framebuffer.lock().await; + if framebuffer_guard.is_none() { + let width = rect.x.checked_add(rect.width).ok_or_else(|| { + ( + VncErrorKind::Protocol, + "VNC rectangle bounds overflow".to_string(), + false, + ) + })?; + let height = rect.y.checked_add(rect.height).ok_or_else(|| { + ( + VncErrorKind::Protocol, + "VNC rectangle bounds overflow".to_string(), + false, + ) + })?; + *framebuffer_guard = Some( + VncFramebuffer::new(width, height) + .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?, + ); + } + let framebuffer = framebuffer_guard.as_mut().expect("framebuffer initialized"); + framebuffer + .apply_rgba(rect, &pixels) + .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; + let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); + let frame = framebuffer + .patch_bytes(sequence, rect, &pixels) + .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; + queue_or_send_frame(session, frame).await; + drop(framebuffer_guard); + let was_active = matches!(*session.state.lock().await, VncSessionState::Active); + if !was_active { + set_state(session, VncSessionState::Active, None, None).await; + emit_state(app, session, VncSessionState::Active, None, None); + let _ = app.emit("sessions-changed", ()); + } + } + VncEvent::Text(text) => { + if session.config.clipboard_enabled && text.len() <= MAX_VNC_CLIPBOARD_BYTES { + let _ = app.emit( + format!("vnc-clipboard-{}", session.config.session_id).as_str(), + VncClipboardEvent { + session_id: session.config.session_id.clone(), + text, + }, + ); + } + } + VncEvent::Error(message) => { + return Err(classify_vnc_event_error(message)); + } + VncEvent::JpegImage(_, _) => { + return Err(( + VncErrorKind::Encoding, + "The server sent a Tight JPEG event instead of decoded RGBA pixels".to_string(), + false, + )); + } + VncEvent::Copy(_, _) | VncEvent::SetCursor(_, _) => { + return Err(( + VncErrorKind::Encoding, + "The server sent an unrequested VNC encoding".to_string(), + false, + )); + } + VncEvent::SetPixelFormat(_) | VncEvent::Bell => {} + _ => {} + } + Ok(()) +} + +fn classify_vnc_event_error(message: String) -> (VncErrorKind, String, bool) { + let lower = message.to_ascii_lowercase(); + let kind = if lower.contains("image") + || lower.contains("encoding") + || lower.contains("zrle") + || lower.contains("tight") + || lower.contains("jpeg") + { + VncErrorKind::Encoding + } else { + VncErrorKind::Protocol + }; + (kind, message, false) +} + +async fn handle_worker_command( + client: &VncClient, + command: VncWorkerCommand, + pressed_keys: &mut Vec, +) -> Result<(), (VncErrorKind, String, bool)> { + match command { + VncWorkerCommand::Input(input) => send_vnc_input(client, input, pressed_keys).await, + VncWorkerCommand::Clipboard(text) => { + client + .input(X11Event::CopyText(text)) + .await + .map_err(|error| { + let (_, message, retryable) = classify_vnc_error(error); + (VncErrorKind::Clipboard, message, retryable) + }) + } + VncWorkerCommand::FullRefresh => client + .input(X11Event::FullRefresh) + .await + .map_err(classify_vnc_error), + } +} + +async fn send_vnc_input( + client: &VncClient, + input: VncInputEvent, + pressed_keys: &mut Vec, +) -> Result<(), (VncErrorKind, String, bool)> { + match input { + VncInputEvent::Key { keysym, pressed } => { + if pressed { + if !pressed_keys.contains(&keysym) { + pressed_keys.push(keysym); + } + } else { + pressed_keys.retain(|key| *key != keysym); + } + client + .input(X11Event::KeyEvent(ClientKeyEvent { + keycode: keysym, + down: pressed, + })) + .await + .map_err(classify_vnc_error)?; + } + VncInputEvent::Pointer { x, y, button_mask } => { + client + .input(X11Event::PointerEvent(ClientMouseEvent { + position_x: x, + position_y: y, + bottons: button_mask, + })) + .await + .map_err(classify_vnc_error)?; + } + VncInputEvent::ReleaseAllKeys => release_pressed_keys(client, pressed_keys).await, + } + Ok(()) +} + +async fn release_pressed_keys(client: &VncClient, pressed_keys: &mut Vec) { + for keysym in pressed_keys.drain(..) { + let _ = client + .input(X11Event::KeyEvent(ClientKeyEvent { + keycode: keysym, + down: false, + })) + .await; + } +} + +async fn send_worker_command( + session: &Arc, + command: VncWorkerCommand, +) -> AppResult<()> { + let sender = session + .command_sender + .lock() + .await + .clone() + .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; + sender + .send(command) + .await + .map_err(|_| AppError::Channel("VNC worker channel closed".to_string())) +} + +async fn stop_worker(session: &Arc) { + if let Some(cancel) = session.cancel_sender.lock().await.take() { + let _ = cancel.send(true); + } + *session.command_sender.lock().await = None; + if let Some(mut worker) = session.worker.lock().await.take() { + if timeout(WORKER_SHUTDOWN_TIMEOUT, &mut worker).await.is_err() { + worker.abort(); + let _ = worker.await; + } + } +} + +async fn queue_or_send_frame(session: &Arc, frame: Vec) { + let attach_id = session.frame_attach_id.load(Ordering::Acquire); + let sent = { + let mut channel = session.frame_channel.lock().await; + match channel.as_ref() { + Some((current_attach_id, sender)) if *current_attach_id == attach_id => { + if sender.send(frame.clone()).is_ok() { + true + } else { + if channel.as_ref().is_some_and(|(id, _)| *id == attach_id) { + *channel = None; + } + false + } + } + _ => false, + } + }; + if sent { + return; + } + + let mut pending = session.pending_frames.lock().await; + while pending.len() >= MAX_PENDING_FRAMES { + pending.pop_front(); + } + pending.push_back(frame); +} + +async fn flush_pending_frames(session: &Arc, attach_id: u64) { + let channel = session.frame_channel.lock().await; + let Some((current_attach_id, channel)) = channel.as_ref() else { + return; + }; + if *current_attach_id != attach_id { + return; + } + let mut pending = session.pending_frames.lock().await; + while let Some(frame) = pending.pop_front() { + if channel.send(frame).is_err() { + break; + } + } +} + +async fn send_full_frame(session: &Arc, attach_id: Option) -> AppResult<()> { + // Keep the snapshot and dispatch ordered with incremental updates. + let framebuffer_guard = session.framebuffer.lock().await; + let frame = { + let Some(framebuffer) = framebuffer_guard.as_ref() else { + return Ok(()); + }; + let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); + framebuffer.full_frame_bytes(sequence)? + }; + if let Some(expected_attach_id) = attach_id { + let mut channel = session.frame_channel.lock().await; + let failed = channel + .as_ref() + .filter(|(current_attach_id, _)| *current_attach_id == expected_attach_id) + .is_some_and(|(_, channel)| channel.send(frame.clone()).is_err()); + if failed + && channel + .as_ref() + .is_some_and(|(current_attach_id, _)| *current_attach_id == expected_attach_id) + { + *channel = None; + } + } else { + queue_or_send_frame(session, frame).await; + } + Ok(()) +} + +async fn set_state( + session: &VncSession, + state: VncSessionState, + message: Option, + error_kind: Option, +) { + *session.state.lock().await = state; + *session.message.lock().await = message; + *session.error_kind.lock().await = error_kind; +} + +fn emit_state( + app: &AppHandle, + session: &VncSession, + state: VncSessionState, + message: Option, + error_kind: Option, +) { + let _ = app.emit( + format!("vnc-state-{}", session.config.session_id).as_str(), + VncStateEvent { + session_id: session.config.session_id.clone(), + state, + message, + error_kind, + }, + ); +} + +async fn replay_state(app: &AppHandle, session: &VncSession) { + emit_state( + app, + session, + session.state.lock().await.clone(), + session.message.lock().await.clone(), + *session.error_kind.lock().await, + ); +} + +fn classify_vnc_error(error: VncError) -> (VncErrorKind, String, bool) { + match error { + VncError::NoPassword | VncError::WrongPassword => ( + VncErrorKind::Authentication, + "VNC authentication failed".to_string(), + false, + ), + VncError::CredentialTooLong { .. } | VncError::Ra2ServerKeyRejected(_) => { + (VncErrorKind::Authentication, error.to_string(), false) + } + VncError::UnsupportedSecurityType + | VncError::RequiredSecurityTypeUnavailable(_) + | VncError::InvalidSecurityType(_) => ( + VncErrorKind::Authentication, + format!( + "The VNC server requires an unsupported security type. Currently supported: None, VNC Authentication, and RA2_256 in Auto mode. Details: {error}" + ), + false, + ), + VncError::InvalidEncoding(_) | VncError::InvalidImageData => { + (VncErrorKind::Encoding, error.to_string(), false) + } + VncError::IoError(ref source) if source.kind() == std::io::ErrorKind::InvalidData => { + (VncErrorKind::Protocol, error.to_string(), false) + } + VncError::IoError(_) => (VncErrorKind::Transport, error.to_string(), true), + VncError::LimitExceeded { .. } + | VncError::InvalidDimensions + | VncError::IntegerOverflow(_) + | VncError::WrongPixelFormat + | VncError::WrongServerMessage + | VncError::InvalidSecurityResult(_) + | VncError::SecurityFailure(_) + | VncError::InvalidRa2KeyLength { .. } + | VncError::InvalidRa2PublicKey + | VncError::InvalidRa2EncryptedRandomLength { .. } + | VncError::InvalidRa2RandomLength(_) + | VncError::Ra2ServerHashMismatch + | VncError::InvalidRa2Subtype(_) + | VncError::Ra2ServerKeyVerifierRequired + | VncError::InvalidRa2RecordLimit(_) + | VncError::Ra2Crypto(_) => (VncErrorKind::Protocol, error.to_string(), false), + _ => (VncErrorKind::Internal, error.to_string(), true), + } +} + +fn reconnect_delay(attempt: u32) -> Duration { + Duration::from_secs(match attempt { + 0 | 1 => 1, + 2 => 2, + 3 => 4, + 4 => 8, + 5 => 15, + _ => 30, + }) +} + +fn validate_framebuffer_dimensions(width: u16, height: u16) -> AppResult<()> { + if width == 0 || height == 0 || width > MAX_FRAMEBUFFER_WIDTH || height > MAX_FRAMEBUFFER_HEIGHT + { + return Err(AppError::Config(format!( + "VNC framebuffer {width}x{height} is outside the supported range" + ))); + } + Ok(()) +} + +fn validate_rectangle(rect: Rect, desktop_width: u16, desktop_height: u16) -> AppResult<()> { + let right = rect + .x + .checked_add(rect.width) + .ok_or_else(|| AppError::Config("VNC rectangle horizontal bounds overflow".to_string()))?; + let bottom = rect + .y + .checked_add(rect.height) + .ok_or_else(|| AppError::Config("VNC rectangle vertical bounds overflow".to_string()))?; + if rect.width == 0 || rect.height == 0 || right > desktop_width || bottom > desktop_height { + return Err(AppError::Config( + "VNC rectangle exceeds framebuffer bounds".to_string(), + )); + } + Ok(()) +} + +pub fn load_saved_vnc_config( + app: &AppHandle, + connection_id: &str, + owner_window_label: String, +) -> AppResult { + let connection = config::load_connection_by_id(app, connection_id)?; + config_from_connection(app, connection, owner_window_label) +} +pub fn config_from_connection( + app: &impl Sized, + connection: config::SavedConnection, + owner_window_label: String, +) -> AppResult { + let connection_id = connection.id.clone(); + let network = connection.network.clone(); + let password = resolve_vnc_password(app, connection.auth.as_ref())?; + let ConnectionType::Vnc { + host, + port, + username, + security, + display, + clipboard, + reconnect, + shared, + view_only, + } = connection.config + else { + return Err(AppError::Config( + "Connection is not a VNC connection".to_string(), + )); + }; + if matches!(security.mode.as_str(), "vnc-auth") && password.is_none() { + return Err(AppError::Config( + "VNC Authentication requires a password".to_string(), + )); + } + if security.mode == "vnc-auth" + && let Some(password) = password.as_ref() + && password.len() > 8 + { + return Err(AppError::Config( + "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), + )); + } + + Ok(VncConnectConfig { + owner_window_label, + session_id: uuid::Uuid::new_v4().to_string(), + connection_id: connection_id.to_string(), + name: connection.name, + host, + port, + username, + password, + security_mode: security.mode, + scale_mode: display.scale_mode, + clipboard_enabled: clipboard.enabled, + reconnect_enabled: reconnect.enabled, + reconnect_max_attempts: reconnect.max_attempts, + shared, + view_only, + network, + }) +} + +fn resolve_vnc_password( + app: &impl Sized, + auth: Option<&ConnectionAuth>, +) -> AppResult> { + let Some(auth) = auth else { + return Ok(None); + }; + if auth.mode != "password" { + return Ok(None); + } + if let Some(password_id) = auth.password_id.as_deref().filter(|id| !id.is_empty()) { + let account = config::load_saved_account(app, None, Some(password_id))?; + return config::decrypt_account_password(account.as_ref()); + } + crate::utils::crypto::decrypt_optional(&auth.password) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn framebuffer_rejects_oversized_and_out_of_bounds_data() { + assert!(VncFramebuffer::new(7681, 1).is_err()); + let mut framebuffer = VncFramebuffer::new(10, 10).expect("framebuffer"); + assert!( + framebuffer + .apply_rgba( + Rect { + x: 10, + y: 0, + width: 1, + height: 1, + }, + &[0; 4] + ) + .is_err() + ); + } + + #[test] + fn framebuffer_applies_patches_and_builds_full_refreshes() { + let mut framebuffer = VncFramebuffer::new(2, 2).expect("framebuffer"); + framebuffer + .apply_rgba( + Rect { + x: 1, + y: 1, + width: 1, + height: 1, + }, + &[1, 2, 3, 255], + ) + .expect("patch"); + let frame = framebuffer.full_frame_bytes(7).expect("full frame"); + assert_eq!(&frame[0..8], &7_u64.to_le_bytes()); + assert_eq!(&frame[40..44], &16_u32.to_le_bytes()); + assert_eq!(&frame[56..60], &[1, 2, 3, 255]); + } + + #[test] + fn reconnect_delay_is_bounded() { + assert_eq!(reconnect_delay(1), Duration::from_secs(1)); + assert_eq!(reconnect_delay(100), Duration::from_secs(30)); + } + + #[tokio::test] + async fn vnc_tofu_commits_only_confirmed_successful_current_handshakes() { + use crate::storage::{KnownHostCheck, Storage}; + for (name, old_key, accepted, authenticated, current_generation, cancelled, expected) in [ + ( + "unknown-success", + None, + true, + true, + 1, + false, + KnownHostCheck::Match, + ), + ( + "unknown-reject", + None, + false, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "hash-mismatch", + None, + true, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "transport-error", + None, + true, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "timeout", + None, + true, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "changed-success", + Some("SHA256:old"), + true, + true, + 1, + false, + KnownHostCheck::Match, + ), + ( + "changed-reject", + Some("SHA256:old"), + false, + false, + 1, + false, + KnownHostCheck::HostSeen, + ), + ( + "stale-generation", + None, + true, + true, + 2, + false, + KnownHostCheck::UnknownHost, + ), + ( + "cancelled", + None, + true, + true, + 1, + true, + KnownHostCheck::UnknownHost, + ), + ] { + let dir = + std::env::temp_dir().join(format!("vnc-tofu-{name}-{}", uuid::Uuid::new_v4())); + let storage = Storage::open(&dir).unwrap(); + if let Some(old_key) = old_key { + storage + .upsert_vnc_known_host("pi.local", 5900, old_key) + .unwrap(); + } + let status = storage + .check_vnc_known_host("pi.local", 5900, "SHA256:new") + .unwrap(); + let trust_candidate = match confirm_vnc_trust(status, async { + if accepted { + Ok(()) + } else { + Err(VncError::Ra2ServerKeyRejected("rejected".into())) + } + }) + .await + { + Ok(true) => Some("SHA256:new"), + Ok(false) | Err(_) => None, + }; + let authenticated_key = authenticated.then_some("SHA256:new"); + commit_vnc_trust( + trust_candidate, + authenticated_key, + 1, + current_generation, + cancelled, + |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), + ) + .unwrap(); + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:new") + .unwrap(), + expected, + "{name}" + ); + if old_key.is_some() && expected != KnownHostCheck::Match { + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:old") + .unwrap(), + KnownHostCheck::Match + ); + } + drop(storage); + std::fs::remove_dir_all(dir).unwrap(); + } + } + + #[tokio::test] + async fn matching_vnc_key_does_not_poll_user_confirmation() { + assert!( + !confirm_vnc_trust(crate::storage::KnownHostCheck::Match, async { + panic!("matching key must never prompt"); + }) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn matching_vnc_key_cannot_overwrite_newer_explicit_trust() { + use crate::storage::{KnownHostCheck, Storage}; + + let dir = std::env::temp_dir().join(format!("vnc-tofu-race-{}", uuid::Uuid::new_v4())); + let storage = Storage::open(&dir).unwrap(); + storage + .upsert_vnc_known_host("pi.local", 5900, "SHA256:k1") + .unwrap(); + + let status = storage + .check_vnc_known_host("pi.local", 5900, "SHA256:k1") + .unwrap(); + let trust_candidate = confirm_vnc_trust(status, async { + panic!("matching key must never prompt"); + }) + .await + .unwrap() + .then_some("SHA256:k1"); + assert!(trust_candidate.is_none()); + + storage + .upsert_vnc_known_host("pi.local", 5900, "SHA256:k2") + .unwrap(); + + commit_vnc_trust( + trust_candidate, + Some("SHA256:k1"), + 1, + 1, + false, + |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), + ) + .unwrap(); + + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:k2") + .unwrap(), + KnownHostCheck::Match + ); + drop(storage); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn vnc_verification_payload_routes_to_its_session_owner() { + let payload = VncServerKeyVerifyEvent { + request_id: "request".into(), + session_id: "session".into(), + host: "pi.local".into(), + port: 5900, + fingerprint: "key".into(), + key_bits: 1024, + known_host_status: "unknown".into(), + target_window_label: "main-second".into(), + }; + assert_eq!( + serde_json::to_value(payload).unwrap()["targetWindowLabel"], + "main-second" + ); + } + + #[test] + fn security_mode_maps_to_fail_closed_policy() { + assert_eq!(security_policy("none"), VncSecurityPolicy::NoneOnly); + assert_eq!(security_policy("vnc-auth"), VncSecurityPolicy::VncAuthOnly); + assert_eq!(security_policy("auto"), VncSecurityPolicy::Auto); + } + + #[test] + fn connect_target_preserves_ipv6_literals_without_host_port_concatenation() { + let config = VncConnectConfig { + owner_window_label: "main-test".into(), + session_id: "vnc-test".to_string(), + connection_id: "connection-test".to_string(), + name: "IPv6 VNC".to_string(), + host: "::1".to_string(), + port: 5900, + username: String::new(), + password: None, + security_mode: "none".to_string(), + scale_mode: "fit".to_string(), + clipboard_enabled: true, + reconnect_enabled: false, + reconnect_max_attempts: 0, + shared: true, + view_only: false, + network: None, + }; + + assert_eq!(vnc_connect_target(&config), ("::1", 5900)); + } + + #[tokio::test] + async fn pending_frame_queue_keeps_latest_frames_under_pressure() { + let session = test_session(); + + queue_or_send_frame(&session, vec![1]).await; + queue_or_send_frame(&session, vec![2]).await; + queue_or_send_frame(&session, vec![3]).await; + + let pending = session.pending_frames.lock().await; + assert_eq!(pending.len(), MAX_PENDING_FRAMES); + assert_eq!(pending[0], vec![2]); + assert_eq!(pending[1], vec![3]); + } + fn test_session() -> Arc { + Arc::new(VncSession { + config: VncConnectConfig { + owner_window_label: "main-test".into(), + session_id: "vnc-test".to_string(), + connection_id: "connection-test".to_string(), + name: "Test VNC".to_string(), + host: "127.0.0.1".to_string(), + port: 5900, + username: String::new(), + password: None, + security_mode: "none".to_string(), + scale_mode: "fit".to_string(), + clipboard_enabled: true, + reconnect_enabled: false, + reconnect_max_attempts: 0, + shared: true, + view_only: false, + network: None, + }, + state: Mutex::new(VncSessionState::Connecting), + message: Mutex::new(None), + error_kind: Mutex::new(None), + generation: AtomicU64::new(0), + frame_sequence: AtomicU64::new(0), + frame_attach_id: AtomicU64::new(0), + frame_channel: Mutex::new(None), + pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), + framebuffer: Mutex::new(None), + command_sender: Mutex::new(None), + cancel_sender: Mutex::new(None), + worker: Mutex::new(None), + close_requested: AtomicBool::new(false), + trust_commit_guard: std::sync::Mutex::new(()), + pending_server_keys: Arc::new(Mutex::new(HashMap::new())), + }) + } + #[tokio::test] + async fn bounded_subscriber_recovers_dropped_patches_with_a_complete_frame() { + let manager = VncSessionManager::new(); + let session = test_session(); + *session.framebuffer.lock().await = Some(VncFramebuffer::new(2, 1).unwrap()); + manager + .sessions + .lock() + .await + .insert(session.config.session_id.clone(), session.clone()); + let context = AppHandle { + events: Arc::new(|_, _, _| Ok(())), + transport: Arc::new(|_, _, _, _| Box::pin(async { panic!("no network needed") })), + }; + let (tx, mut rx) = mpsc::channel(2); + let channel = FrameChannel(Arc::new(move |bytes| { + tx.try_send(bytes) + .map_err(|_| AppError::Channel("slow client".into())) + })); + manager + .attach_frame_channel(&context, "vnc-test", channel.clone()) + .await + .unwrap(); + for value in 1..=10 { + handle_vnc_event( + &context, + &session, + 0, + VncEvent::RawImage( + Rect { + x: 1, + y: 0, + width: 1, + height: 1, + }, + vec![value, 20, 30, 255], + ), + ) + .await + .unwrap(); + } + assert!(session.frame_channel.lock().await.is_none()); + assert!(session.pending_frames.lock().await.len() <= 2); + while rx.try_recv().is_ok() {} + manager + .attach_frame_channel(&context, "vnc-test", channel) + .await + .unwrap(); + let full = rx.recv().await.unwrap(); + assert_eq!(full.len(), 52); + assert_eq!(u32::from_le_bytes(full[16..20].try_into().unwrap()), 0); + assert_eq!(u32::from_le_bytes(full[24..28].try_into().unwrap()), 2); + assert_eq!(&full[48..52], &[10, 20, 30, 255]); + assert!(rx.try_recv().is_err()); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/vnc/mod.rs b/src-tauri/crates/nyaterm-core/src/vnc/mod.rs new file mode 100644 index 00000000..befc0e77 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/vnc/mod.rs @@ -0,0 +1,3 @@ +mod manager; +pub mod runtime; +pub use manager::*; diff --git a/src-tauri/crates/nyaterm-core/src/vnc/runtime.rs b/src-tauri/crates/nyaterm-core/src/vnc/runtime.rs new file mode 100644 index 00000000..d85d87a0 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/vnc/runtime.rs @@ -0,0 +1,46 @@ +use crate::{config::ConnectionNetwork, error::AppResult, network::OpenedTransport}; +use futures_util::future::BoxFuture; +use serde::Serialize; +use serde_json::Value; +use std::sync::Arc; + +type EventSink = dyn Fn(Option<&str>, &str, Value) -> AppResult<()> + Send + Sync; +type Transport = dyn Fn( + String, + u16, + Option, + Option, + ) -> BoxFuture<'static, AppResult> + + Send + + Sync; + +/// The protocol engine owns no native application handles or IPC channels. +#[derive(Clone)] +pub struct VncContext { + pub events: Arc, + pub transport: Arc, +} +impl VncContext { + pub fn emit(&self, event: &str, payload: T) -> AppResult<()> { + (self.events)(None, event, serde_json::to_value(payload)?) + } + pub fn emit_to(&self, target: &str, event: &str, payload: T) -> AppResult<()> { + (self.events)(Some(target), event, serde_json::to_value(payload)?) + } +} +#[derive(Clone)] +pub struct FrameChannel(pub Arc) -> AppResult<()> + Send + Sync>); +impl FrameChannel { + pub fn send(&self, frame: Vec) -> AppResult<()> { + (self.0)(frame) + } +} +pub async fn open_tcp_transport( + context: &VncContext, + host: &str, + port: u16, + network: Option<&ConnectionNetwork>, + owner: Option, +) -> AppResult { + (context.transport)(host.to_owned(), port, network.cloned(), owner).await +} diff --git a/src-tauri/crates/nyaterm-web/Cargo.lock b/src-tauri/crates/nyaterm-web/Cargo.lock index 11bc8545..de23a177 100644 --- a/src-tauri/crates/nyaterm-web/Cargo.lock +++ b/src-tauri/crates/nyaterm-web/Cargo.lock @@ -122,6 +122,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-http-proxy" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29faa5d4d308266048bd7505ba55484315a890102f9345b9ff4b87de64201592" +dependencies = [ + "base64 0.13.1", + "httparse", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -144,6 +156,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -224,6 +246,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -330,12 +358,24 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -422,6 +462,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cmov" version = "0.5.4" @@ -758,6 +809,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "defmt" version = "1.1.1" @@ -786,7 +846,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -807,6 +867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "pem-rfc7468 0.7.0", "zeroize", ] @@ -962,6 +1023,19 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -1038,6 +1112,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1871,6 +1951,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2175,6 +2269,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -2313,8 +2417,10 @@ name = "nyaterm-core" version = "0.1.0" dependencies = [ "aes-gcm 0.10.3", + "async-http-proxy", "base64 0.22.1", "dirs", + "encoding_rs", "font-kit", "futures-util", "genai", @@ -2331,12 +2437,15 @@ dependencies = [ "sha1 0.10.7", "sha2 0.10.9", "ssh-key 0.6.7", - "thiserror", + "strip-ansi-escapes", + "thiserror 2.0.21", "time", "tokio", + "tokio-socks", "tokio-util", "tracing", "uuid", + "vnc-rs", "zeroize", ] @@ -2521,7 +2630,7 @@ dependencies = [ "log", "rand 0.10.3", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.21", "tokio", "windows", "windows-strings", @@ -2819,6 +2928,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.47" @@ -2941,7 +3056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -3174,7 +3289,7 @@ dependencies = [ "ssh-encoding 0.3.0", "ssh-key 0.7.0-rc.11", "subtle", - "thiserror", + "thiserror 2.0.21", "tokio", "typenum", "universal-hash 0.6.1", @@ -3204,7 +3319,7 @@ dependencies = [ "log", "serde", "serde_bytes", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -3791,6 +3906,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3900,13 +4024,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4014,6 +4158,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -4045,6 +4201,7 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "libc", @@ -4159,7 +4316,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1 0.10.7", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -4270,6 +4427,36 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vnc-rs" +version = "0.5.3" +dependencies = [ + "aes 0.8.4", + "async_io_stream", + "eax", + "flate2", + "futures", + "image", + "rand 0.8.8", + "rsa 0.9.10", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4775,3 +4962,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src-tauri/crates/nyaterm-web/src/commands.rs b/src-tauri/crates/nyaterm-web/src/commands.rs index 7811520d..2027059f 100644 --- a/src-tauri/crates/nyaterm-web/src/commands.rs +++ b/src-tauri/crates/nyaterm-web/src/commands.rs @@ -65,7 +65,7 @@ pub async fn route( "save_connection" => { let _guard=state.mutation.lock().await; let connection:config::SavedConnection=argument(&args,"connection")?; - if !matches!(connection.config,config::ConnectionType::Ssh { .. }) { return Err(WebError::unsupported()); } + if !matches!(connection.config,config::ConnectionType::Ssh { .. } | config::ConnectionType::Telnet { .. } | config::ConnectionType::Vnc { .. }) { return Err(WebError::unsupported()); } let id=services::save_connection(&(),connection)?; state.broadcast("connections-changed",Value::Null).await;json!(id) }, @@ -75,6 +75,41 @@ pub async fn route( state.broadcast("connections-changed",Value::Null).await;Value::Null }, "get_groups" => json!(config::load_config(&())?.groups), + "get_proxies" => json!(config::load_proxies(&())?.into_iter().map(|mut proxy| { proxy.password=None; proxy }).collect::>()), + "get_proxy_groups" => json!(config::load_proxy_groups(&())?), + "get_proxy_password" => { + let proxy=config::load_proxy_by_id(&(), text(&args,"proxyId")?)?.ok_or(WebError::bad("Proxy not found"))?; + json!(crypto::decrypt_optional(&proxy.password)?) + }, + "save_proxy" => { + let _guard=state.mutation.lock().await; + let mut proxy:config::ProxyConfig=argument(&args,"proxy")?; + if !matches!(proxy.protocol.as_str(), "socks5" | "http") { return Err(WebError::bad("Web supports SOCKS5 and HTTP CONNECT proxies only")); } + crate::network::validate_target(&proxy.host, proxy.port)?; + if proxy.id.is_empty() { proxy.id=uuid::Uuid::new_v4().to_string(); } + let mut proxies=config::load_proxies(&())?; + proxy.password=match proxy.password.as_deref() { Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>proxies.iter().find(|p|p.id==proxy.id).and_then(|p|p.password.clone()) }; + let id=proxy.id.clone();proxies.retain(|p|p.id!=id);proxies.push(proxy);config::save_proxies(&(),&proxies)?; + state.broadcast("proxy-saved",Value::Null).await;json!(id) + }, + "delete_proxy" => { + let _guard=state.mutation.lock().await;let mut proxies=config::load_proxies(&())?;proxies.retain(|p|Some(p.id.as_str())!=args["proxyId"].as_str());config::save_proxies(&(),&proxies)?; + state.broadcast("proxy-saved",Value::Null).await;Value::Null + }, + "set_proxy_group" => { + let _guard=state.mutation.lock().await;let mut proxies=config::load_proxies(&())?; + let proxy=proxies.iter_mut().find(|p|Some(p.id.as_str())==args["proxyId"].as_str()).ok_or(WebError::bad("Proxy not found"))?; + proxy.group_id=args["groupId"].as_str().map(String::from);config::save_proxies(&(),&proxies)?;state.broadcast("proxy-saved",Value::Null).await;Value::Null + }, + "save_proxy_group" => { + let _guard=state.mutation.lock().await;let mut group:config::ProxyGroup=argument(&args,"group")?; + if group.id.is_empty() { group.id=uuid::Uuid::new_v4().to_string(); }let id=group.id.clone();let mut groups=config::load_proxy_groups(&())?;groups.retain(|g|g.id!=id);groups.push(group);config::save_proxy_groups(&(),&groups)?;state.broadcast("proxy-saved",Value::Null).await;json!(id) + }, + "delete_proxy_group" => { + let _guard=state.mutation.lock().await;let id=text(&args,"groupId")?; + let mut groups=config::load_proxy_groups(&())?;groups.retain(|g|g.id!=id);config::save_proxy_groups(&(),&groups)?; + let mut proxies=config::load_proxies(&())?;proxies.retain(|p|p.group_id.as_deref()!=Some(id));config::save_proxies(&(),&proxies)?;state.broadcast("proxy-saved",Value::Null).await;Value::Null + }, "get_connection_custom_icons" => json!(config::load_config(&())?.custom_icons), "get_supported_ssh_algorithms" => json!(nyaterm_core::ssh::algorithms::get_supported_ssh_algorithms()), "save_group" => { @@ -130,6 +165,7 @@ pub async fn route( }, "close_session" => { state.session(&owner.0,text(&args,"sessionId")?).await?.cancel.cancel();Value::Null }, "cancel_session_creation" => { let id=text(&args,"createRequestId")?;for session in state.sessions.lock().await.values().filter(|s|s.owner==owner.0&&s.request_id.as_deref()==Some(id)){session.cancel.cancel();}Value::Null }, + "vnc_input_batch" | "vnc_set_clipboard_text" | "vnc_reconnect" | "close_vnc_session" | "respond_vnc_server_key" => crate::vnc::command(&state,&owner.0,&command,&args).await?, "list_sessions" | "get_sessions" => json!(state.sessions.lock().await.values().filter(|s|s.owner==owner.0).map(|s|s.info()).collect::>()), "get_session_info" => state.session(&owner.0,text(&args,"sessionId")?).await?.info(), "try_get_terminal_cwd" | "get_session_cwd" => { state.session(&owner.0,text(&args,"sessionId")?).await?;Value::Null }, diff --git a/src-tauri/crates/nyaterm-web/src/lib.rs b/src-tauri/crates/nyaterm-web/src/lib.rs index cf01dcea..2623246a 100644 --- a/src-tauri/crates/nyaterm-web/src/lib.rs +++ b/src-tauri/crates/nyaterm-web/src/lib.rs @@ -2,10 +2,13 @@ pub mod ai; pub mod auth; pub mod commands; pub mod error; +pub mod network; pub mod plugins; pub mod session; pub mod sftp; pub mod state; +pub mod telnet; +pub mod vnc; use crate::{auth::Owner, state::State}; use axum::{ @@ -53,6 +56,7 @@ pub fn router(state: Arc, dist: std::path::PathBuf) -> Router { .route("/events", get(events)) .route("/sessions", post(session::create_route)) .route("/sessions/{id}/terminal", get(session::ws_route)) + .route("/sessions/{id}/vnc", get(vnc::ws_route)) .route("/sessions/{id}/download", get(sftp::download)) .route( "/sessions/{id}/upload", diff --git a/src-tauri/crates/nyaterm-web/src/network.rs b/src-tauri/crates/nyaterm-web/src/network.rs new file mode 100644 index 00000000..a4c128fe --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/network.rs @@ -0,0 +1,145 @@ +//! Authenticated proxy and recursive SSH jump transports for Web sessions. +use crate::{ + error::{Result, WebError}, + session, + state::State, +}; +use futures_util::future::BoxFuture; +use nyaterm_core::{ + config::{self, ConnectionNetwork}, + network::{self, OpenedTransport, TransportRouteKind}, +}; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_util::sync::CancellationToken; + +pub fn open( + state: Arc, + owner: String, + cancel: CancellationToken, + host: String, + port: u16, + route: Option, +) -> BoxFuture<'static, Result> { + Box::pin(async move { + validate_target(&host, port)?; + tokio::select! { + _ = cancel.cancelled() => Err(WebError::bad("Connection cancelled")), + result = tokio::time::timeout(std::time::Duration::from_secs(120), open_inner(state, owner, cancel.clone(), host, port, route, Vec::new())) => result.unwrap_or(Err(WebError::bad("Network connection timed out"))), + } + }) +} +pub fn validate_target(host: &str, port: u16) -> Result<()> { + if host.is_empty() + || host.len() > 253 + || host.chars().any(|c| c.is_whitespace() || c.is_control()) + || port == 0 + { + return Err(WebError::bad("Invalid host or port")); + } + Ok(()) +} +fn open_inner( + state: Arc, + owner: String, + cancel: CancellationToken, + host: String, + port: u16, + route: Option, + mut visited: Vec, +) -> BoxFuture<'static, Result> { + Box::pin(async move { + validate_target(&host, port)?; + let resolved = network::resolve_transport_route(route.as_ref()); + match resolved.kind { + TransportRouteKind::Direct => Ok(network::open_direct_transport(&host, port) + .await + .map_err(|e| WebError::bad(e.to_string()))?), + TransportRouteKind::Proxy => { + let proxy = network::resolve_proxy(&(), resolved.proxy_id.as_deref().unwrap()) + .map_err(|e| match e { + nyaterm_core::error::AppError::Crypto(_) => { + WebError::bad("Proxy credentials could not be decrypted") + } + _ => WebError::bad("Proxy configuration is missing or invalid"), + })?; + if !matches!(proxy.protocol.as_str(), "socks5" | "http") { + return Err(WebError::bad( + "Web supports SOCKS5 and HTTP CONNECT proxies only", + )); + } + validate_target(&proxy.host, proxy.port)?; + Ok(network::open_proxy_transport(proxy, &host, port) + .await + .map_err(|e| WebError::bad(e.to_string()))?) + } + TransportRouteKind::SshJump => { + let id = resolved.proxy_jump_id.unwrap(); + if visited.contains(&id) || visited.len() >= 8 { + return Err(WebError::bad("SSH jump cycle or depth limit exceeded")); + } + visited.push(id.clone()); + let connection = config::load_connection_by_id(&(), &id)?; + if !matches!(connection.config, config::ConnectionType::Ssh { .. }) { + return Err(WebError::bad("SSH jump must refer to an SSH connection")); + } + let target = session::jump_target(&id)?; + let transport = open_inner( + state.clone(), + owner.clone(), + cancel.clone(), + target.host.clone(), + target.port, + target.network.clone(), + visited, + ) + .await?; + let handle = + session::authenticate(&state, &owner, &cancel, target, transport.stream) + .await?; + let channel = handle + .channel_open_direct_tcpip(&host, u32::from(port), "127.0.0.1", 0) + .await?; + Ok(OpenedTransport { + stream: Box::new(ForwardedStream { + stream: channel.into_stream(), + _handle: handle, + }), + local_addr: None, + }) + } + } + }) +} +struct ForwardedStream { + stream: russh::ChannelStream, + _handle: russh::client::Handle, +} +impl AsyncRead for ForwardedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_read(cx, buf) + } +} +impl AsyncWrite for ForwardedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} diff --git a/src-tauri/crates/nyaterm-web/src/session.rs b/src-tauri/crates/nyaterm-web/src/session.rs index 61da4110..6ed6ca42 100644 --- a/src-tauri/crates/nyaterm-web/src/session.rs +++ b/src-tauri/crates/nyaterm-web/src/session.rs @@ -80,6 +80,7 @@ impl client::Handler for Handler { } } pub struct WebSession { + pub protocol: SessionProtocol, pub id: String, pub owner: String, pub name: String, @@ -88,6 +89,7 @@ pub struct WebSession { pub username: String, pub connection_id: Option, pub request_id: Option, + pub workspace_pane_id: Option, pub cancel: CancellationToken, pub handle: Mutex>>, pub input: mpsc::Sender, @@ -98,16 +100,106 @@ pub struct WebSession { pub transfers: Arc, pub sftp: config::SftpSettings, } +pub enum SessionProtocol { + Ssh, + Telnet, + Vnc(Arc), +} impl WebSession { pub fn info(&self) -> Value { - json!({"id":self.id,"name":self.name,"session_type":"SSH","host":self.host,"port":self.port,"username":self.username,"connection_id":self.connection_id,"owner_window_label":"main","terminal_available":true,"shell_available":true,"sftp_available":self.sftp.enabled && self.ready.load(Ordering::Acquire),"runtime_mode":"standard","dynamic_title_enabled":false,"dynamic_title_integration_active":false}) + let kind = match self.protocol { + SessionProtocol::Ssh => "SSH", + SessionProtocol::Telnet => "Telnet", + SessionProtocol::Vnc(_) => "VNC", + }; + let terminal = !matches!(self.protocol, SessionProtocol::Vnc(_)); + let sftp = matches!(self.protocol, SessionProtocol::Ssh) + && self.sftp.enabled + && self.ready.load(Ordering::Acquire); + let connected = match &self.protocol { + SessionProtocol::Vnc(web) => *web.state.lock().unwrap() == "active", + _ => self.ready.load(Ordering::Acquire), + } && !self.cancel.is_cancelled(); + json!({"id":self.id,"name":self.name,"session_type":kind,"host":self.host,"port":self.port,"username":self.username,"connection_id":self.connection_id,"owner_window_label":"main","workspace_pane_id":self.workspace_pane_id,"attached":self.attached.load(Ordering::Acquire),"ready":self.ready.load(Ordering::Acquire),"connected":connected,"terminal_available":terminal,"shell_available":terminal,"sftp_available":sftp,"remote_file_browser_enabled":sftp,"remote_stats_enabled":false,"injection_active":false,"runtime_mode":"standard","ssh_runtime_mode":"standard","dynamic_title_enabled":false,"dynamic_title_integration_active":false}) } } -struct Target { - host: String, - port: u16, - username: String, +pub(crate) struct SessionMetadata { + pub host: String, + pub port: u16, + pub username: String, + pub name: String, + pub connection_id: Option, + pub sftp: Option, +} +pub(crate) async fn register( + state: &Arc, + owner: &str, + args: &Value, + protocol: SessionProtocol, + metadata: SessionMetadata, + cancel: CancellationToken, +) -> Result<( + Arc, + mpsc::Receiver, + mpsc::Sender, +)> { + state.login(owner).await?; + crate::network::validate_target(&metadata.host, metadata.port)?; + let (input, commands) = mpsc::channel(128); + let (sender, output) = mpsc::channel(terminal::OUTPUT_CAPACITY); + let session = Arc::new(WebSession { + protocol, + id: uuid::Uuid::new_v4().to_string(), + owner: owner.into(), + name: metadata.name, + host: metadata.host, + port: metadata.port, + username: metadata.username, + connection_id: metadata.connection_id, + request_id: args["createRequestId"].as_str().map(String::from), + workspace_pane_id: args["recordingScopeId"].as_str().map(String::from), + cancel, + handle: Mutex::new(None), + input, + output: Arc::new(Mutex::new(output)), + attached: AtomicBool::new(false), + detached_at: StdMutex::new(Instant::now()), + ready: AtomicBool::new(false), + transfers: Arc::new(tokio::sync::Semaphore::new(4)), + sftp: metadata.sftp.unwrap_or(config::SftpSettings { + enabled: false, + ..Default::default() + }), + }); + let mut registry = state.sessions.lock().await; + if registry.len() >= 128 || registry.values().filter(|s| s.owner == owner).count() >= 16 { + return Err(WebError::bad("Too many sessions")); + } + registry.insert(session.id.clone(), session.clone()); + Ok((session, commands, sender)) +} +pub(crate) fn connection(args: &Value, kind: &str) -> Result<(config::SavedConnection, bool)> { + if let Some(id) = args["connectionId"].as_str() { + return Ok((config::load_connection_by_id(&(), id)?, true)); + } + let mut value = if args["config"].is_object() { + args["config"].clone() + } else { + args.clone() + }; + value["type"] = json!(kind); + value["id"] = json!(""); + if !value["name"].is_string() { + value["name"] = json!(kind); + } + Ok((serde_json::from_value(value)?, false)) +} + +pub(crate) struct Target { + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) username: String, name: String, mode: String, secret: Option>, @@ -115,6 +207,7 @@ struct Target { passphrase: Option>, preferences: Option, connection_id: Option, + pub(crate) network: Option, terminal_type: config::SshTerminalType, sftp: config::SftpSettings, } @@ -140,7 +233,13 @@ fn validate_sftp(settings: &config::SftpSettings) -> Result<()> { } Ok(()) } -fn target(args: &Value) -> Result { +pub(crate) fn target(args: &Value) -> Result { + target_for(args, false) +} +pub(crate) fn jump_target(id: &str) -> Result { + target_for(&json!({"connectionId": id}), true) +} +fn target_for(args: &Value, transport_only: bool) -> Result { if let Some(id) = args["connectionId"].as_str() { let conn = config::load_connection_by_id(&(), id)?; let config::ConnectionType::Ssh { @@ -155,26 +254,23 @@ fn target(args: &Value) -> Result { else { return Err(WebError::unsupported()); }; - if *x11_forwarding || agent_forwarding_config.as_ref().is_some_and(|c| c.enabled) { + if !transport_only + && (*x11_forwarding || agent_forwarding_config.as_ref().is_some_and(|c| c.enabled)) + { return Err(WebError::unsupported()); } - require_utf8(encoding)?; - validate_sftp(&conn.sftp)?; - if conn.post_login.as_ref().is_some_and(|p| p.enabled) - || conn.ssh_profile != config::SshProfile::Standard + if !transport_only { + require_utf8(encoding)?; + validate_sftp(&conn.sftp)?; + } + if !transport_only + && (conn.post_login.as_ref().is_some_and(|p| p.enabled) + || conn.ssh_profile != config::SshProfile::Standard) { return Err(WebError::unsupported()); } let terminal_type = config::resolve_ssh_terminal_type(&conn.ssh_profile, conn.terminal_type.as_ref()); - // Unsupported network paths must not silently become direct connections. - if conn - .network - .as_ref() - .is_some_and(|n| n.proxy_id.is_some() || n.proxy_jump_id.is_some()) - { - return Err(WebError::unsupported()); - } let account = conn .auth .as_ref() @@ -219,6 +315,7 @@ fn target(args: &Value) -> Result { key, passphrase: passphrase.map(zeroize::Zeroizing::new), preferences: conn.ssh_algorithms, + network: conn.network.clone(), connection_id: Some(id.into()), terminal_type, sftp: conn.sftp, @@ -285,6 +382,11 @@ fn target(args: &Value) -> Result { .filter(|v| !v.is_null()) .map(|v| serde_json::from_value(v.clone())) .transpose()?, + network: value + .get("network") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose()?, connection_id: None, terminal_type, sftp, @@ -292,6 +394,12 @@ fn target(args: &Value) -> Result { } } pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + match args["type"].as_str().unwrap_or("ssh") { + "telnet" => return crate::telnet::create(state, owner, args).await, + "vnc" => return crate::vnc::create(state, owner, args).await, + "ssh" => {} + _ => return Err(WebError::unsupported()), + } if args["startupCommand"].as_object().is_some() || args["runtimeMode"] .as_str() @@ -318,34 +426,24 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result= 128 || registry.values().filter(|s| s.owner == owner).count() >= 16 { - return Err(WebError::bad("Too many sessions")); - } - registry.insert(id.clone(), session.clone()); - drop(registry); + sftp: Some(target.sftp.clone()), + }; + let (session, commands, sender) = register( + &state, + owner, + &args, + SessionProtocol::Ssh, + metadata, + login.cancel.child_token(), + ) + .await?; + let id = session.id.clone(); tokio::spawn(async move { let result = tokio::select! { _ = session.cancel.cancelled() => Err(WebError::bad("Session creation cancelled")), @@ -359,47 +457,85 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + Err(error) => { state .event( &session.owner, &format!("connection-error-{}", session.id), - json!("SSH connection failed or authentication was cancelled"), + json!(error.1), ) .await; - let _ = sender.try_send(Output::Error); + let _ = sender.try_send(Output::Failure(error.1)); } } - session.cancel.cancel(); - if let Some(handle) = session.handle.lock().await.take() { - let _ = tokio::time::timeout( - Duration::from_secs(2), - handle.disconnect(russh::Disconnect::ByApplication, "", ""), - ) - .await; - } - state.sessions.lock().await.remove(&session.id); - // Attached terminals receive ordered closure on the WS after final bytes. - if !session.attached.load(Ordering::Acquire) { - state - .event( - &session.owner, - &format!("session-closed-{}", session.id), - Value::Null, - ) - .await; - } - state - .event(&session.owner, "sessions-changed", Value::Null) - .await; + finish(&state, &session).await; }); Ok(id) } +pub(crate) async fn finish(state: &State, session: &WebSession) { + session.cancel.cancel(); + if let Some(handle) = session.handle.lock().await.take() { + let _ = tokio::time::timeout( + Duration::from_secs(2), + handle.disconnect(russh::Disconnect::ByApplication, "", ""), + ) + .await; + } + state.sessions.lock().await.remove(&session.id); + if !session.attached.load(Ordering::Acquire) { + state + .event( + &session.owner, + &format!("session-closed-{}", session.id), + Value::Null, + ) + .await; + } + state + .event(&session.owner, "sessions-changed", Value::Null) + .await; +} async fn connect( state: &Arc, session: &Arc, - mut target: Target, + target: Target, ) -> Result> { + let stream = crate::network::open( + state.clone(), + session.owner.clone(), + session.cancel.clone(), + target.host.clone(), + target.port, + target.network.clone(), + ) + .await?; + let terminal = target.terminal_type.as_str().to_owned(); + let connection_id = target.connection_id.clone(); + let mut handle = authenticate( + state, + &session.owner, + &session.cancel, + target, + stream.stream, + ) + .await?; + let channel = protocol::open_shell_with_terminal(&mut handle, 80, 24, &terminal).await?; + *session.handle.lock().await = Some(handle); + if let Some(id) = connection_id { + storage::mark_connection_used(&id)?; + } + Ok(channel) +} +pub(crate) async fn authenticate( + state: &Arc, + owner: &str, + cancel: &CancellationToken, + mut target: Target, + stream: nyaterm_core::network::BoxedTransportStream, +) -> Result> { + if !matches!(target.mode.as_str(), "none" | "password" | "key") { + return Err(WebError::bad("Unsupported SSH authentication mode")); + } let client_config = client::Config { preferred: algorithms::resolve_preferred_algorithms(target.preferences.as_ref())?, keepalive_interval: Some(Duration::from_secs(30)), @@ -408,16 +544,12 @@ async fn connect( }; let handler = Handler { state: state.clone(), - owner: session.owner.clone(), + owner: owner.into(), host: target.host.clone(), port: target.port, - cancel: session.cancel.clone(), + cancel: cancel.clone(), }; - let handle = - protocol::connect(Arc::new(client_config), &target.host, target.port, handler).await?; - let mut locked = session.handle.lock().await; - *locked = Some(handle); - let handle = locked.as_mut().unwrap(); + let mut handle = client::connect_stream(Arc::new(client_config), stream, handler).await?; let mut authenticated = false; if target.mode == "none" { authenticated = handle.authenticate_none(&target.username).await?.success(); @@ -434,7 +566,7 @@ async fn connect( target.passphrase.as_deref().map(|p| p.as_str()), ); if decoded.is_err() && target.passphrase.is_none() { - let response=state.prompt(&session.owner,"ssh-auth-request",json!({"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,"reason":"key_passphrase_required","promptKind":"passphrase","availableMethods":["publickey"],"currentAuthMode":"key","attempt":1,"canSave":false}),&session.cancel).await?; + let response=state.prompt(owner,"ssh-auth-request",json!({"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,"reason":"key_passphrase_required","promptKind":"passphrase","availableMethods":["publickey"],"currentAuthMode":"key","attempt":1,"canSave":false}),cancel).await?; target.passphrase = response["secret"] .as_str() .map(|s| zeroize::Zeroizing::new(s.into())); @@ -456,10 +588,10 @@ async fn connect( if !authenticated && target.mode != "key" { for attempt in 1..=3 { if target.secret.is_none() { - let response = state.prompt(&session.owner,"ssh-auth-request",json!({ + let response = state.prompt(owner,"ssh-auth-request",json!({ "connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username, "reason":if attempt == 1 {"missing_password"} else {"password_rejected"},"promptKind":"password","availableMethods":["password"],"currentAuthMode":"password","attempt":attempt,"canSave":false,"accountId":null, - }),&session.cancel).await?; + }),cancel).await?; target.secret = response["secret"] .as_str() .map(|s| zeroize::Zeroizing::new(s.into())); @@ -467,7 +599,7 @@ async fn connect( let Some(secret) = &target.secret else { return Err(WebError::bad("SSH authentication cancelled")); }; - let result = protocol::password(handle, &target.username, secret).await?; + let result = protocol::password(&mut handle, &target.username, secret).await?; if result.success() { authenticated = true; break; @@ -477,7 +609,8 @@ async fn connect( } = result { if remaining_methods.contains(&russh::MethodKind::KeyboardInteractive) { - authenticated = keyboard_auth(state, session, handle, &target).await?; + authenticated = + keyboard_auth(state, owner, cancel, &mut handle, &target).await?; break; } } @@ -485,21 +618,17 @@ async fn connect( } } if !authenticated && target.mode == "key" { - authenticated = keyboard_auth(state, session, handle, &target).await?; + authenticated = keyboard_auth(state, owner, cancel, &mut handle, &target).await?; } if !authenticated { return Err(WebError::bad("SSH authentication failed")); } - let channel = - protocol::open_shell_with_terminal(handle, 80, 24, target.terminal_type.as_str()).await?; - if let Some(id) = &target.connection_id { - storage::mark_connection_used(id)?; - } - Ok(channel) + Ok(handle) } async fn keyboard_auth( state: &Arc, - session: &Arc, + owner: &str, + cancel: &CancellationToken, handle: &mut client::Handle, target: &Target, ) -> Result { @@ -516,7 +645,7 @@ async fn keyboard_auth( if prompts.len() > 16 { return Err(WebError::bad("Too many authentication prompts")); } - let response=state.prompt(&session.owner,"otp-request",json!({"connectionName":target.name,"name":name,"instructions":instructions,"round":round,"prompts":prompts.iter().map(|p|json!({"prompt":p.prompt,"echo":p.echo})).collect::>(),"otpEntryId":null}),&session.cancel).await?; + let response=state.prompt(owner,"otp-request",json!({"connectionName":target.name,"name":name,"instructions":instructions,"round":round,"prompts":prompts.iter().map(|p|json!({"prompt":p.prompt,"echo":p.echo})).collect::>(),"otpEntryId":null}),cancel).await?; let responses: Vec = serde_json::from_value(response)?; if responses.len() != prompts.len() { return Err(WebError::bad("Invalid authentication response")); @@ -543,6 +672,9 @@ pub async fn ws_route( upgrade: WebSocketUpgrade, ) -> Result { let session = state.session(&owner.0, &id).await?; + if matches!(session.protocol, SessionProtocol::Vnc(_)) { + return Err(WebError::bad("This session has no terminal")); + } let receiver = session.output.clone().try_lock_owned().map_err(|_| { WebError( axum::http::StatusCode::CONFLICT, @@ -578,7 +710,8 @@ async fn socket_loop( let final_frame = !matches!(&next, Some(Output::Data(_))); let message = match next { Some(Output::Data(data)) => Message::Binary(data.into()), - Some(Output::Error) => Message::Text(json!({"type":"error","error":"SSH connection failed"}).to_string().into()), + Some(Output::Error) => Message::Text(json!({"type":"error","error":if matches!(session.protocol, SessionProtocol::Telnet) { "Telnet connection failed" } else { "SSH connection failed" }}).to_string().into()), + Some(Output::Failure(error)) => Message::Text(json!({"type":"error","error":error}).to_string().into()), _ => Message::Text(json!({"type":"closed"}).to_string().into()), }; if !matches!(tokio::time::timeout(Duration::from_secs(10),socket.send(message)).await,Ok(Ok(()))) { session.cancel.cancel(); break; } @@ -598,7 +731,7 @@ async fn socket_loop( let Some(command) = command else { break; }; if session.input.try_send(command).is_err() { break; } }, - Some(Ok(Message::Binary(bytes))) => { last_peer=Instant::now(); if session.input.try_send(Command::Input(bytes.to_vec())).is_err() { break; } }, + Some(Ok(Message::Binary(bytes))) => { last_peer=Instant::now(); if session.input.try_send(Command::RawInput(bytes.to_vec())).is_err() { break; } }, Some(Ok(Message::Pong(_))) => last_peer=Instant::now(), Some(Ok(Message::Ping(data))) => { if !matches!(tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Pong(data))).await,Ok(Ok(()))) { break; } }, _ => break, @@ -609,4 +742,17 @@ async fn socket_loop( } } } + // Complete the WebSocket close handshake before dropping the TCP stream. + // Dropping with an unread pong/input can reset the connection on Windows, + // discarding the last terminal output even though send() already succeeded. + let _ = tokio::time::timeout(Duration::from_secs(2), async { + if socket.send(Message::Close(None)).await.is_ok() { + while let Some(Ok(message)) = socket.recv().await { + if matches!(message, Message::Close(_)) { + break; + } + } + } + }) + .await; } diff --git a/src-tauri/crates/nyaterm-web/src/sftp.rs b/src-tauri/crates/nyaterm-web/src/sftp.rs index 91ce4345..d38f89d8 100644 --- a/src-tauri/crates/nyaterm-web/src/sftp.rs +++ b/src-tauri/crates/nyaterm-web/src/sftp.rs @@ -36,7 +36,7 @@ pub fn supports(command: &str) -> bool { ) } pub async fn open(session: &WebSession) -> Result { - if !session.sftp.enabled { + if !matches!(session.protocol, crate::session::SessionProtocol::Ssh) || !session.sftp.enabled { return Err(WebError::unsupported()); } if !session.ready.load(std::sync::atomic::Ordering::Acquire) { diff --git a/src-tauri/crates/nyaterm-web/src/telnet.rs b/src-tauri/crates/nyaterm-web/src/telnet.rs new file mode 100644 index 00000000..5d6df280 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/telnet.rs @@ -0,0 +1,255 @@ +use crate::{ + error::{Result, WebError}, + session::{self, SessionMetadata, SessionProtocol, WebSession}, + state::State, +}; +use nyaterm_core::{ + config, + ssh::terminal::{Command, Output}, + telnet::*, + terminal_encoding::*, +}; +use serde_json::Value; +use std::{ + sync::{Arc, atomic::Ordering}, + time::Instant, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::mpsc, +}; + +pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + let (conn, saved) = session::connection(&args, "telnet")?; + let config::ConnectionType::Telnet { + host, + port, + username, + backspace_mode, + raw_tcp_cli, + enter_mode, + local_echo, + local_line_edit, + force_character_at_a_time, + send_naws, + send_sga, + auto_login, + encoding, + .. + } = conn.config + else { + return Err(WebError::bad("Connection is not Telnet")); + }; + let account = conn + .auth + .as_ref() + .map(|a| config::load_saved_account(&(), a.account_id.as_deref(), a.password_id.as_deref())) + .transpose()? + .flatten(); + let username = config::resolve_account_username(account.as_ref(), &username); + let password = match conn.auth.as_ref() { + Some(a) if a.mode == "password" => { + if saved { + match &a.password { + Some(secret) => Some(nyaterm_core::utils::crypto::decrypt(secret)?), + None if a.password_source.as_deref() != Some("connection") => { + config::decrypt_account_password(account.as_ref())? + } + _ => None, + } + } else { + match &a.password { + Some(password) => Some(password.clone()), + None => config::decrypt_account_password(account.as_ref())?, + } + } + } + _ => None, + }; + let encoding = if encoding.is_empty() || encoding.eq_ignore_ascii_case("global") { + config::load_app_settings(&())?.interaction.default_encoding + } else { + encoding + }; + let config = TelnetSessionConfig { + network: conn.network.clone(), + host: host.clone(), + port, + name: conn.name.clone(), + username: username.clone(), + password, + backspace_mode, + raw_tcp_cli, + enter_mode: TelnetEnterMode::from_config_value(&enter_mode), + local_echo, + local_line_edit, + force_character_at_a_time, + send_naws, + send_sga, + auto_login: auto_login.into(), + encoding, + }; + let cancel = state.login(owner).await?.cancel.child_token(); + let (session, commands, sender) = session::register( + &state, + owner, + &args, + SessionProtocol::Telnet, + SessionMetadata { + host, + port, + username, + name: conn.name, + connection_id: saved.then_some(conn.id), + sftp: None, + }, + cancel, + ) + .await?; + let id = session.id.clone(); + let startup = args["startupCommand"].clone(); + tokio::spawn(async move { + let result = async { + let transport = crate::network::open( + state.clone(), + session.owner.clone(), + session.cancel.clone(), + session.host.clone(), + session.port, + conn.network, + ) + .await?; + session.ready.store(true, Ordering::Release); + if let Some(id) = &session.connection_id { + nyaterm_core::storage::mark_connection_used(id)?; + } + run( + transport.stream, + config, + commands, + sender.clone(), + &session, + startup, + ) + .await + } + .await; + let end = if let Err(e) = result { + state + .event( + &session.owner, + &format!("connection-error-{}", session.id), + serde_json::json!({"sessionId":session.id,"error":e.1}), + ) + .await; + Output::Failure(e.1) + } else { + Output::Closed + }; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), sender.send(end)).await; + session::finish(&state, &session).await; + }); + Ok(id) +} +async fn run( + mut stream: nyaterm_core::network::BoxedTransportStream, + config: TelnetSessionConfig, + mut input: mpsc::Receiver, + output: mpsc::Sender, + session: &Arc, + startup: Value, +) -> Result<()> { + let mut decoder = TelnetDecoder::default(); + let mut text_decoder = TerminalOutputDecoder::new(&config.encoding); + let mut editor = TelnetLineEditor::default(); + let mut auto_login = TelnetAutoLogin::new( + config.auto_login.clone(), + TelnetAutoLoginCredentials { + username: config.username.clone(), + password: config.password.clone(), + }, + config.enter_mode, + Instant::now(), + ); + let mut buf = [0u8; 16 * 1024]; + let mut startup_input = startup["command"] + .as_str() + .filter(|s| !s.trim().is_empty()) + .map(|command| normalize_enter_bytes(format!("{command}\r").as_bytes(), config.enter_mode)); + let startup_delay = std::time::Duration::from_millis( + startup["delayMs"] + .as_u64() + .or(startup["delay_ms"].as_u64()) + .unwrap_or(0) + .min(300_000), + ); + let mut startup_deadline = if auto_login.is_none() && startup_input.is_some() { + Some(tokio::time::Instant::now() + startup_delay) + } else { + None + }; + let mut login_tick = tokio::time::interval(std::time::Duration::from_millis(250)); + let transfer = async { + loop { + tokio::select! { + _ = async { tokio::time::sleep_until(startup_deadline.unwrap()).await }, if startup_deadline.is_some() => { + startup_deadline = None; + if let Some(data) = startup_input.take() { + stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&data, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet startup write failed"))?; + } + }, + _ = login_tick.tick() => { + if let Some(login) = &mut auto_login { + if login.tick(Instant::now()).is_some() { startup_input = None; startup_deadline = None; } + } + }, + read = stream.read(&mut buf) => { + let n = read.map_err(|_| WebError::bad("Telnet read failed"))?; + if n == 0 { break; } + let mut replies = Vec::new(); + let visible = if config.raw_tcp_cli { buf[..n].to_vec() } else { decoder.decode(&buf[..n], &mut |command, option| { + replies.extend(negotiate_response(command, option, config.send_naws, config.send_sga)); + }) }; + if !replies.is_empty() { stream.write_all(&replies).await.map_err(|_| WebError::bad("Telnet negotiation failed"))?; } + let text = text_decoder.decode(&visible); + if let Some(login) = &mut auto_login { + for action in login.handle_text(&text, Instant::now()) { + match action { + TelnetAutoLoginAction::Send(bytes) => stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&bytes, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet login failed"))?, + TelnetAutoLoginAction::Complete => if startup_input.is_some() { startup_deadline = Some(tokio::time::Instant::now() + startup_delay); }, + TelnetAutoLoginAction::Disable => { startup_input = None; startup_deadline = None; }, + } + } + } + if !text.is_empty() { output.send(Output::Data(text.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; } + }, + command = input.recv() => match command { + Some(Command::Input(mut bytes)) => { + if let Some(login) = &mut auto_login { + if login.handle_user_input(false).is_some() { startup_input = None; startup_deadline = None; } + } + let chunks = if config.raw_tcp_cli && config.local_line_edit { + let edited = editor.process(&bytes, config.enter_mode); + if !edited.display.is_empty() { output.send(Output::Data(edited.display.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; } + edited.writes + } else { + if config.backspace_mode == "ctrl_h" { for byte in &mut bytes { if *byte == 0x7f { *byte = 8; } } } + let data = normalize_enter_bytes(&bytes, config.enter_mode); + if config.local_echo { let text = local_echo_text(&data); if !text.is_empty() { output.send(Output::Data(text.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; } } + split_write_chunks(&data, config.force_character_at_a_time) + }; + for chunk in chunks { stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&chunk, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet write failed"))?; } + }, + Some(Command::RawInput(bytes)) => { + if let Some(login) = &mut auto_login { if login.handle_user_input(false).is_some() { startup_input = None; startup_deadline = None; } } + stream.write_all(&escape_telnet_application_data(&bytes, config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet write failed"))?; + }, + Some(Command::Resize(cols, rows)) => if let Some(bytes) = maybe_build_naws(cols as u16, rows as u16, &config) { stream.write_all(&bytes).await.map_err(|_| WebError::bad("Telnet resize failed"))?; }, + Some(Command::Close) | None => break, + } + } + } + Ok(()) + }; + tokio::select! { _ = session.cancel.cancelled() => Ok(()), result = transfer => result } +} diff --git a/src-tauri/crates/nyaterm-web/src/vnc.rs b/src-tauri/crates/nyaterm-web/src/vnc.rs new file mode 100644 index 00000000..0903a9a5 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/vnc.rs @@ -0,0 +1,324 @@ +use crate::{ + auth::Owner, + error::{Result, WebError}, + session::{self, SessionMetadata, SessionProtocol}, + state::State, +}; +use axum::{ + Extension, + extract::{ + Path, State as ExtractState, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + response::Response, +}; +use nyaterm_core::{ + error::AppError, + vnc::{ + self, VncInputEvent, VncSessionManager, + runtime::{FrameChannel, VncContext}, + }, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashSet, + sync::{ + Arc, Mutex as StdMutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; +use tokio::sync::{broadcast, mpsc}; + +pub struct WebVnc { + pub manager: Arc, + pub context: VncContext, + pub pending_keys: Arc>>, + pub state: Arc>, + events: broadcast::Sender<(String, Value)>, +} +pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + let (mut conn, saved) = session::connection(&args, "vnc")?; + nyaterm_core::services::validate_vnc_config(&conn)?; + let login = state.login(owner).await?; + let cancel = login.cancel.child_token(); + // Only persisted secrets need decryption. Encrypt transient input through the same path. + if !saved { + if let Some(auth) = &mut conn.auth { + if let Some(password) = &auth.password { + auth.password = Some(nyaterm_core::utils::crypto::encrypt(password)?); + } + } + } + let mut config = vnc::config_from_connection(&(), conn, "main".into())?; + let pending = Arc::new(StdMutex::new(HashSet::new())); + let latest_state = Arc::new(StdMutex::new("connecting".into())); + let (events, _) = broadcast::channel(64); + let event_state = state.clone(); + let event_owner = owner.to_owned(); + let event_pending = pending.clone(); + let event_sender = events.clone(); + let event_latest_state = latest_state.clone(); + let transport_state = state.clone(); + let transport_owner = owner.to_owned(); + let transport_cancel = cancel.clone(); + let context = VncContext { + events: Arc::new(move |_, event, payload| { + if event.starts_with("vnc-state-") { + if let Some(state) = payload["state"].as_str() { + *event_latest_state.lock().unwrap() = state.into(); + } + } + if let Some(id) = payload["requestId"].as_str() { + let mut pending = event_pending.lock().unwrap(); + if event == "vnc-server-key-verify" { + pending.insert(id.into()); + } else if event == "vnc-server-key-verify-resolved" { + pending.remove(id); + } + } + if event.starts_with("vnc-state-") || event.starts_with("vnc-clipboard-") { + let _ = event_sender.send((event.to_owned(), payload)); + return Ok(()); + } + let state = event_state.clone(); + let owner = event_owner.clone(); + let event = event.to_owned(); + tokio::spawn(async move { + state.event(&owner, &event, payload).await; + }); + Ok(()) + }), + transport: Arc::new(move |host, port, network, _| { + let state = transport_state.clone(); + let owner = transport_owner.clone(); + let cancel = transport_cancel.clone(); + Box::pin(async move { + crate::network::open(state, owner, cancel, host, port, network) + .await + .map_err(|e| AppError::Channel(e.1)) + }) + }), + }; + let web = Arc::new(WebVnc { + manager: Arc::new(VncSessionManager::new()), + context, + pending_keys: pending, + state: latest_state, + events, + }); + let metadata = SessionMetadata { + host: config.host.clone(), + port: config.port, + username: config.username.clone(), + name: config.name.clone(), + connection_id: saved.then_some(config.connection_id.clone()), + sftp: None, + }; + let (session, _, _) = session::register( + &state, + owner, + &args, + SessionProtocol::Vnc(web.clone()), + metadata, + cancel, + ) + .await?; + config.session_id = session.id.clone(); + if let Err(e) = web + .manager + .create_session(web.context.clone(), config) + .await + { + state.sessions.lock().await.remove(&session.id); + return Err(e.into()); + } + session.ready.store(true, Ordering::Release); + if let Some(id) = &session.connection_id { + nyaterm_core::storage::mark_connection_used(id)?; + } + let id = session.id.clone(); + tokio::spawn(async move { + session.cancel.cancelled().await; + let _ = web.manager.close(&web.context, &session.id).await; + session::finish(&state, &session).await; + }); + Ok(id) +} +pub async fn command( + state: &Arc, + owner: &str, + command: &str, + args: &Value, +) -> Result { + if command == "respond_vnc_server_key" { + let id = args["requestId"] + .as_str() + .ok_or(WebError::bad("Missing key request id"))?; + let sessions = state + .sessions + .lock() + .await + .values() + .filter(|s| s.owner == owner) + .cloned() + .collect::>(); + for session in sessions { + if let SessionProtocol::Vnc(web) = &session.protocol { + if web.pending_keys.lock().unwrap().contains(id) { + web.manager + .respond_server_key( + id, + args["accepted"] + .as_bool() + .ok_or(WebError::bad("Missing key response"))?, + ) + .await?; + return Ok(Value::Null); + } + } + } + return Err(WebError::forbidden()); + } + let session = state + .session( + owner, + args["sessionId"] + .as_str() + .ok_or(WebError::bad("Missing session id"))?, + ) + .await?; + let SessionProtocol::Vnc(web) = &session.protocol else { + return Err(WebError::bad("Not a VNC session")); + }; + match command { + "vnc_input_batch" => { + web.manager + .send_input(&session.id, serde_json::from_value(args["events"].clone())?) + .await? + } + "vnc_set_clipboard_text" => { + web.manager + .set_clipboard_text( + &session.id, + args["text"] + .as_str() + .ok_or(WebError::bad("Missing clipboard text"))? + .into(), + ) + .await? + } + "vnc_reconnect" => { + web.manager + .reconnect(web.context.clone(), &session.id) + .await? + } + "close_vnc_session" => session.cancel.cancel(), + _ => return Err(WebError::unsupported()), + } + Ok(Value::Null) +} +pub async fn ws_route( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + Path(id): Path, + upgrade: WebSocketUpgrade, +) -> Result { + let session = state.session(&owner.0, &id).await?; + if !matches!(session.protocol, SessionProtocol::Vnc(_)) { + return Err(WebError::bad("Not a VNC session")); + } + if session + .attached + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(WebError( + axum::http::StatusCode::CONFLICT, + "VNC already attached".into(), + )); + } + Ok(upgrade + .max_message_size(2 * 1024 * 1024) + .max_frame_size(2 * 1024 * 1024) + .max_write_buffer_size(140 * 1024 * 1024) + .on_failed_upgrade({ + let session = session.clone(); + move |_| { + session.attached.store(false, Ordering::Release); + } + }) + .on_upgrade(move |socket| socket_loop(session, socket))) +} +async fn socket_loop(session: Arc, mut socket: WebSocket) { + let SessionProtocol::Vnc(web) = &session.protocol else { + return; + }; + let (tx, mut frames) = mpsc::channel(2); + let overflow = Arc::new(AtomicBool::new(false)); + let overflow_sink = overflow.clone(); + let sink = FrameChannel(Arc::new(move |frame| { + tx.try_send(frame).map_err(|_| { + overflow_sink.store(true, Ordering::Release); + AppError::Channel("VNC consumer needs a full refresh".into()) + }) + })); + let mut events = web.events.subscribe(); + let _ = web + .manager + .attach_frame_channel(&web.context, &session.id, sink.clone()) + .await; + let mut heartbeat = tokio::time::interval(Duration::from_secs(15)); + let mut refresh = tokio::time::interval(Duration::from_millis(32)); + let mut last_peer = Instant::now(); + loop { + let message = tokio::select! { + _ = session.cancel.cancelled() => break, + _ = refresh.tick() => { + if overflow.swap(false, Ordering::AcqRel) { + while frames.try_recv().is_ok() {} + let _ = web.manager.attach_frame_channel(&web.context, &session.id, sink.clone()).await; + } + continue; + }, + frame = frames.recv() => match frame { Some(frame) => Message::Binary(frame.into()), None => break }, + event = events.recv() => match event { + Ok((event, payload)) => Message::Text(json!({"type":"event","event":event,"payload":payload}).to_string().into()), + Err(broadcast::error::RecvError::Lagged(_)) => { let _ = web.manager.attach_frame_channel(&web.context, &session.id, sink.clone()).await; continue; }, + Err(_) => break, + }, + next = socket.recv() => { + match next { + Some(Ok(Message::Text(text))) => { + last_peer = Instant::now(); + let result = async { + let value: Value = serde_json::from_str(&text)?; + match value["type"].as_str() { + Some("input") => web.manager.send_input(&session.id, serde_json::from_value::>(value["events"].clone())?).await?, + Some("clipboard") => web.manager.set_clipboard_text(&session.id, value["text"].as_str().ok_or(WebError::bad("Missing clipboard"))?.into()).await?, + Some("close") => session.cancel.cancel(), + _ => return Err(WebError::bad("Invalid VNC message")), + } + Ok::<(), WebError>(()) + }.await; + if let Err(e) = result { Message::Text(json!({"type":"error","error":e.1}).to_string().into()) } else { continue; } + }, + Some(Ok(Message::Ping(data))) => { last_peer = Instant::now(); Message::Pong(data) }, + Some(Ok(Message::Pong(_))) => { last_peer = Instant::now(); continue; }, + _ => break, + } + }, + _ = heartbeat.tick() => { if last_peer.elapsed() > Duration::from_secs(45) { break; } Message::Ping(Vec::new().into()) }, + }; + if !matches!( + tokio::time::timeout(Duration::from_secs(5), socket.send(message)).await, + Ok(Ok(())) + ) { + break; + } + } + let _ = web.manager.detach_frame_channel(&session.id).await; + *session.detached_at.lock().unwrap() = Instant::now(); + session.attached.store(false, Ordering::Release); + let _ = socket.send(Message::Close(None)).await; +} diff --git a/src-tauri/crates/nyaterm-web/tests/protocols.rs b/src-tauri/crates/nyaterm-web/tests/protocols.rs new file mode 100644 index 00000000..305415ee --- /dev/null +++ b/src-tauri/crates/nyaterm-web/tests/protocols.rs @@ -0,0 +1,1230 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use futures_util::{SinkExt, StreamExt}; +use nyaterm_web::{auth, state::State}; +use serde_json::{Value, json}; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::{Mutex, mpsc}, +}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Message, client::IntoClientRequest}, +}; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; +fn state(host: String, base_path: &str) -> Arc { + Arc::new(State { + origin: format!("http://{host}"), + host, + base_path: base_path.into(), + secure_cookie: false, + password_hash: auth::digest("login-password-at-least-32-characters"), + logins: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + prompts: Mutex::new(HashMap::new()), + ai_streams: Mutex::new(HashMap::new()), + login_attempts: Mutex::new(vec![]), + mutation: Mutex::new(()), + shutdown: CancellationToken::new(), + }) +} +async fn request( + app: &Router, + state: &State, + path: &str, + body: Option, + owner: Option<&str>, + csrf: Option<&str>, +) -> (StatusCode, axum::http::HeaderMap, Value) { + let mut builder = Request::builder() + .uri(format!("/nyaterm/api/{path}")) + .header("host", &state.host) + .header("origin", &state.origin) + .header("x-nyaterm-request", "1"); + if let Some(owner) = owner { + builder = builder.header("cookie", format!("{}={owner}", auth::COOKIE)); + } + if let Some(csrf) = csrf { + builder = builder.header("x-nyaterm-csrf", csrf); + } + if body.is_some() { + builder = builder + .method("POST") + .header("content-type", "application/json"); + } + let response = app + .clone() + .oneshot( + builder + .body( + body.map(|v| Body::from(v.to_string())) + .unwrap_or(Body::empty()), + ) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .unwrap(); + let value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, headers, value) +} +async fn login(app: &Router, state: &State) -> (String, String) { + let (status, headers, value) = request( + app, + state, + "auth/login", + Some(json!({"password":"login-password-at-least-32-characters"})), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let token = headers["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .split('=') + .nth(1) + .unwrap() + .to_owned(); + (token, value["csrf"].as_str().unwrap().into()) +} +async fn ws( + host: &str, + state: &State, + id: &str, + owner: &str, +) -> tokio_tungstenite::WebSocketStream> { + let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/terminal") + .into_client_request() + .unwrap(); + r.headers_mut() + .insert("origin", state.origin.parse().unwrap()); + r.headers_mut().insert( + "cookie", + format!("{}={owner}", auth::COOKIE).parse().unwrap(), + ); + connect_async(r).await.unwrap().0 +} +async fn binary( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> Vec { + loop { + match tokio::time::timeout(Duration::from_secs(20), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap() + { + Message::Binary(b) => return b.to_vec(), + Message::Ping(b) => socket.send(Message::Pong(b)).await.unwrap(), + other => panic!("Expected bytes, got {other:?}"), + } + } +} + +type Socket = tokio_tungstenite::WebSocketStream>; +async fn vnc_ws(host: &str, state: &State, id: &str, owner: &str) -> Socket { + let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/vnc") + .into_client_request() + .unwrap(); + r.headers_mut() + .insert("origin", state.origin.parse().unwrap()); + r.headers_mut().insert( + "cookie", + format!("{}={owner}", auth::COOKIE).parse().unwrap(), + ); + connect_async(r).await.unwrap().0 +} +async fn rejected_ws( + host: &str, + state: &State, + id: &str, + endpoint: &str, + owner: &str, + expected: u16, +) { + let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/{endpoint}") + .into_client_request() + .unwrap(); + r.headers_mut() + .insert("origin", state.origin.parse().unwrap()); + r.headers_mut().insert( + "cookie", + format!("{}={owner}", auth::COOKIE).parse().unwrap(), + ); + match connect_async(r).await { + Err(tokio_tungstenite::tungstenite::Error::Http(response)) => { + assert_eq!(response.status().as_u16(), expected) + } + _ => panic!("Expected a rejected WebSocket upgrade"), + } +} +async fn next_message(socket: &mut Socket) -> Message { + loop { + let message = tokio::time::timeout(Duration::from_secs(5), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + match message { + Message::Ping(b) => socket.send(Message::Pong(b)).await.unwrap(), + other => return other, + } + } +} +async fn frame(socket: &mut Socket) -> Vec { + loop { + if let Message::Binary(bytes) = next_message(socket).await { + return bytes.to_vec(); + } + } +} +async fn command( + app: &Router, + state: &State, + owner: &str, + csrf: &str, + name: &str, + args: Value, +) -> Value { + let (status, _, value) = request( + app, + state, + &format!("commands/{name}"), + Some(args), + Some(owner), + Some(csrf), + ) + .await; + assert_eq!(status, StatusCode::OK, "{name}: {value}"); + value +} +async fn create(app: &Router, state: &State, owner: &str, csrf: &str, args: Value) -> String { + let (status, _, value) = + request(app, state, "sessions", Some(args), Some(owner), Some(csrf)).await; + assert_eq!(status, StatusCode::OK, "{value}"); + value["session_id"].as_str().unwrap().into() +} +async fn wait_removed(state: &State, id: &str) { + tokio::time::timeout(Duration::from_secs(5), async { + while state.sessions.lock().await.contains_key(id) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); +} +async fn echo() -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + ( + port, + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let mut buf = [0; 1024]; + while let Ok(n) = stream.read(&mut buf).await { + if n == 0 { + break; + } + if stream.write_all(&buf[..n]).await.is_err() { + break; + } + } + }); + } + }), + ) +} +async fn proxy(http: bool, authenticated: bool) -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + ( + port, + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let (host, port) = if http { + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + bytes.push(stream.read_u8().await.unwrap()); + } + let request = String::from_utf8(bytes).unwrap(); + assert_eq!( + request.to_lowercase().contains( + "proxy-authorization: basic cHJveHk6c2VjcmV0" + .to_lowercase() + .as_str() + ), + authenticated + ); + let target = request.split_whitespace().nth(1).unwrap(); + let (host, port) = target.rsplit_once(':').unwrap(); + let target = (host.to_owned(), port.parse::().unwrap()); + stream + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + target + } else { + assert_eq!(stream.read_u8().await.unwrap(), 5); + let count = stream.read_u8().await.unwrap(); + let mut methods = vec![0; count as usize]; + stream.read_exact(&mut methods).await.unwrap(); + let method = if authenticated { 2 } else { 0 }; + assert!(methods.contains(&method)); + stream.write_all(&[5, method]).await.unwrap(); + if authenticated { + assert_eq!(stream.read_u8().await.unwrap(), 1); + let len = stream.read_u8().await.unwrap(); + let mut user = vec![0; len as usize]; + stream.read_exact(&mut user).await.unwrap(); + let len = stream.read_u8().await.unwrap(); + let mut password = vec![0; len as usize]; + stream.read_exact(&mut password).await.unwrap(); + assert_eq!(user, b"proxy"); + assert_eq!(password, b"secret"); + stream.write_all(&[1, 0]).await.unwrap(); + } + assert_eq!(stream.read_u8().await.unwrap(), 5); + assert_eq!(stream.read_u8().await.unwrap(), 1); + assert_eq!(stream.read_u8().await.unwrap(), 0); + let host = match stream.read_u8().await.unwrap() { + 1 => { + let mut ip = [0; 4]; + stream.read_exact(&mut ip).await.unwrap(); + std::net::Ipv4Addr::from(ip).to_string() + } + 3 => { + let len = stream.read_u8().await.unwrap(); + let mut name = vec![0; len as usize]; + stream.read_exact(&mut name).await.unwrap(); + String::from_utf8(name).unwrap() + } + _ => panic!("unsupported address"), + }; + let port = stream.read_u16().await.unwrap(); + stream + .write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]) + .await + .unwrap(); + (host, port) + }; + let mut target = TcpStream::connect((host.as_str(), port)).await.unwrap(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut target).await; + }); + } + }), + ) +} +async fn rfb() -> ( + u16, + mpsc::UnboundedReceiver>, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = mpsc::unbounded_channel(); + ( + port, + rx, + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let tx = tx.clone(); + tokio::spawn(async move { + stream.write_all(b"RFB 003.008\n").await.unwrap(); + let mut version = [0; 12]; + if stream.read_exact(&mut version).await.is_err() { + // A cancelled/replaced connection may stop mid-handshake. + return; + } + stream.write_all(&[1, 1]).await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), 1); + stream.write_all(&0_u32.to_be_bytes()).await.unwrap(); + let shared = stream.read_u8().await.unwrap(); + tx.send(vec![99, shared]).unwrap(); + stream + .write_all(&[ + 0, 2, 0, 1, 32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 0, 8, 16, 0, 0, 0, 0, + 0, 0, 4, + ]) + .await + .unwrap(); + stream.write_all(b"test").await.unwrap(); + let mut sent = false; + loop { + let Ok(kind) = stream.read_u8().await else { + break; + }; + let size = match kind { + 0 => 19, + 2 => 3, + 3 => 9, + 4 => 7, + 5 => 5, + 6 => 7, + _ => panic!("Unknown RFB input {kind}"), + }; + let mut packet = vec![kind]; + packet.resize(size + 1, 0); + if stream.read_exact(&mut packet[1..]).await.is_err() { + break; + } + if kind == 2 { + let count = u16::from_be_bytes([packet[2], packet[3]]); + let mut encodings = vec![0; count as usize * 4]; + stream.read_exact(&mut encodings).await.unwrap(); + } + if kind == 6 { + let count = + u32::from_be_bytes(packet[4..8].try_into().unwrap()) as usize; + let mut text = vec![0; count]; + stream.read_exact(&mut text).await.unwrap(); + packet.extend(&text); + if text == b"patch" { + stream + .write_all(&[ + 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 70, 80, 90, + 0, + ]) + .await + .unwrap(); + } + if text == b"resize" { + let mut resized = vec![0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 1]; + resized.extend((-223_i32).to_be_bytes()); + resized.extend([ + 0, 0, 0, 0, 0, 3, 0, 1, 0, 0, 0, 0, 1, 2, 3, 0, 4, 5, 6, 0, 7, + 8, 9, 0, + ]); + stream.write_all(&resized).await.unwrap(); + } + } + if kind == 3 && !sent { + // Two-pixel raw rectangle in the client's requested RGBA format. + let update = [ + 0, 0, 0, 1, 0, 0, 0, 0, 0, 2, 0, 1, 0, 0, 0, 0, 10, 20, 30, 0, 40, + 50, 60, 0, + ]; + stream.write_all(&update).await.unwrap(); + stream.write_all(&[3, 0, 0, 0, 0, 0, 0, 6]).await.unwrap(); + stream.write_all(b"remote").await.unwrap(); + sent = true; + } + if matches!(kind, 4 | 5 | 6) { + let _ = tx.send(packet); + } + } + }); + } + }), + ) +} + +#[derive(Clone)] +struct Jump; +impl russh::server::Server for Jump { + type Handler = Self; + fn new_client(&mut self, _: Option) -> Self { + Self + } +} +impl russh::server::Handler for Jump { + type Error = russh::Error; + async fn auth_password( + &mut self, + username: &str, + password: &str, + ) -> Result { + Ok(if username == "jump" && password == "jump-password" { + russh::server::Auth::Accept + } else { + russh::server::Auth::reject() + }) + } + async fn channel_open_direct_tcpip( + &mut self, + channel: russh::Channel, + host: &str, + port: u32, + _: &str, + _: u32, + reply: russh::server::ChannelOpenHandle, + _: &mut russh::server::Session, + ) -> Result<(), Self::Error> { + let mut target = TcpStream::connect((host, port as u16)).await?; + reply.accept().await; + tokio::spawn(async move { + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut target).await; + }); + Ok(()) + } +} +async fn jump() -> (u16, tokio::task::JoinHandle<()>) { + use russh::{keys::PublicKeyBase64, server::Server as _}; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let key = + russh::keys::PrivateKey::random(&mut rand_new::rng(), russh::keys::Algorithm::Ed25519) + .unwrap(); + let host_id = format!("[127.0.0.1]:{port}"); + nyaterm_core::storage::replace_known_host_for_host( + &host_id, + &format!( + "{host_id} {} {}", + key.algorithm(), + key.public_key().public_key_base64() + ), + ) + .unwrap(); + let config = Arc::new(russh::server::Config { + keys: vec![key], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + ..Default::default() + }); + ( + port, + tokio::spawn(async move { + Jump.run_on_socket(config, &listener).await.unwrap(); + }), + ) +} + +#[tokio::test] +async fn web_telnet_vnc_proxy_and_lifecycle() { + let data = tempfile::tempdir().unwrap(); + nyaterm_core::storage::init(data.path()).unwrap(); + nyaterm_core::utils::crypto::set_server_key_material([41; 32]); + let dist = tempfile::tempdir().unwrap(); + std::fs::write(dist.path().join("index.html"), "test").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = listener.local_addr().unwrap().to_string(); + let state = state(host.clone(), "/nyaterm"); + let app = nyaterm_web::router(state.clone(), dist.path().into()); + let serving = tokio::spawn(axum::serve(listener, app.clone()).into_future()); + let (owner, csrf) = login(&app, &state).await; + let (other, other_csrf) = login(&app, &state).await; + let (port, echo_task) = echo().await; + let saved = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"GBK Telnet","type":"telnet","host":"127.0.0.1","port":port,"encoding":"GBK","enter_mode":"crlf","auto_login":{"enabled":false}}})).await; + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","connectionId":saved,"recordingScopeId":"telnet-pane"}), + ) + .await; + let info = command( + &app, + &state, + &owner, + &csrf, + "get_session_info", + json!({"sessionId":id}), + ) + .await; + assert_eq!(info["session_type"], "Telnet"); + assert_eq!(info["workspace_pane_id"], "telnet-pane"); + assert_eq!(info["sftp_available"], false); + rejected_ws(&host, &state, &id, "vnc", &owner, 400).await; + let (status, _, _) = request( + &app, + &state, + &format!("commands/get_session_info"), + Some(json!({"sessionId":id})), + Some(&other), + Some(&other_csrf), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _, _) = request( + &app, + &state, + "commands/sftp_read_dir", + Some(json!({"sessionId":id,"path":"/"})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_ne!(status, StatusCode::OK); + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Text( + json!({"type":"input","data":"你好\r"}).to_string().into(), + )) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, "你好\r\n".as_bytes()); + // Raw bytes are not re-encoded as UTF-8 before the connection codec. + socket + .send(Message::Binary(vec![0xc4, 0xe3].into())) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, "你".as_bytes()); + socket.close(None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while state.sessions.lock().await[&id] + .attached + .load(std::sync::atomic::Ordering::Acquire) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let mut restored = ws(&host, &state, &id, &owner).await; + restored + .send(Message::Text( + json!({"type":"input","data":"restored"}).to_string().into(), + )) + .await + .unwrap(); + assert_eq!(binary(&mut restored).await, b"restored"); + command( + &app, + &state, + &owner, + &csrf, + "close_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + + // Automatic login gates the startup command and NAWS uses escaped dimensions. + let login_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let login_port = login_listener.local_addr().unwrap().port(); + let login_server = tokio::spawn(async move { + let (mut stream, _) = login_listener.accept().await.unwrap(); + stream.write_all(&[255, 253, 31]).await.unwrap(); + stream.write_all(b"login:").await.unwrap(); + let mut negotiation = [0; 3]; + stream.read_exact(&mut negotiation).await.unwrap(); + assert_eq!(negotiation, [255, 251, 31]); + let mut user = [0; 5]; + stream.read_exact(&mut user).await.unwrap(); + assert_eq!(&user, b"test\r"); + stream.write_all(b"Password:").await.unwrap(); + let mut password = [0; 7]; + stream.read_exact(&mut password).await.unwrap(); + assert_eq!(&password, b"secret\r"); + stream.write_all(b"\r\n$ ").await.unwrap(); + let started = tokio::time::Instant::now(); + let mut startup = [0; 7]; + stream.read_exact(&mut startup).await.unwrap(); + assert_eq!(&startup, b"whoami\r"); + assert!(started.elapsed() >= Duration::from_millis(70)); + stream.write_all(b"READY").await.unwrap(); + let mut naws = [0; 11]; + stream.read_exact(&mut naws).await.unwrap(); + assert_eq!(naws, [255, 250, 31, 0, 255, 255, 1, 255, 255, 255, 240]); + }); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":login_port,"username":"test","auth":{"mode":"password","password":"secret"},"auto_login":{"success_prompt_regex":"\\$"},"startupCommand":{"command":"whoami","delay_ms":80}})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + let mut text = Vec::new(); + while !text.ends_with(b"READY") { + text.extend(binary(&mut socket).await); + } + socket + .send(Message::Text( + json!({"type":"resize","cols":255,"rows":511}) + .to_string() + .into(), + )) + .await + .unwrap(); + login_server.await.unwrap(); + wait_removed(&state, &id).await; + // Raw TCP line editing echoes locally and sends only the final edited line. + let line_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let line_port = line_listener.local_addr().unwrap().port(); + let line_server = tokio::spawn(async move { + let (mut stream, _) = line_listener.accept().await.unwrap(); + let mut line = [0; 4]; + stream.read_exact(&mut line).await.unwrap(); + assert_eq!(&line, b"ab\r\n"); + stream.write_all(b"DONE").await.unwrap(); + }); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":line_port,"raw_tcp_cli":true,"local_line_edit":true,"enter_mode":"crlf"})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Text( + json!({"type":"input","data":"abX\u{7f}\r"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let mut text = Vec::new(); + while !text.ends_with(b"DONE") { + text.extend(binary(&mut socket).await); + } + assert!(text.starts_with(b"abX")); + line_server.await.unwrap(); + wait_removed(&state, &id).await; + for http in [false, true] { + for authenticated in [false, true] { + let (proxy_port, task) = proxy(http, authenticated).await; + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"fixture","protocol":if http {"http"} else {"socks5"},"host":"127.0.0.1","port":proxy_port,"username":authenticated.then_some("proxy"),"password":authenticated.then_some("secret")}})).await; + let proxies = command(&app, &state, &owner, &csrf, "get_proxies", json!({})).await; + assert!( + proxies + .as_array() + .unwrap() + .iter() + .all(|p| p["password"].is_null()) + ); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"raw_tcp_cli":true,"network":{"proxy_id":proxy_id}})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Binary(vec![0xff, b'x'].into())) + .await + .unwrap(); + // GBK is not configured here: input bytes survive on the wire, echo x proves routing. + let output = binary(&mut socket).await; + assert!(output.ends_with(b"x")); + command( + &app, + &state, + &owner, + &csrf, + "close_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + task.abort(); + } + } + + let group = command( + &app, + &state, + &owner, + &csrf, + "save_proxy_group", + json!({"group":{"id":"","name":"fixtures"}}), + ) + .await; + let credential_proxy = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"before edit","protocol":"http","host":"127.0.0.1","port":1,"username":"proxy","password":"secret","group_id":group}})).await; + command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":credential_proxy,"name":"after edit","protocol":"http","host":"127.0.0.1","port":1,"username":"proxy","group_id":group}})).await; + assert_eq!( + command( + &app, + &state, + &owner, + &csrf, + "get_proxy_password", + json!({"proxyId":credential_proxy}) + ) + .await, + "secret" + ); + command( + &app, + &state, + &owner, + &csrf, + "set_proxy_group", + json!({"proxyId":credential_proxy,"groupId":null}), + ) + .await; + command( + &app, + &state, + &owner, + &csrf, + "delete_proxy_group", + json!({"groupId":group}), + ) + .await; + assert!( + command(&app, &state, &owner, &csrf, "get_proxy_groups", json!({})) + .await + .as_array() + .unwrap() + .is_empty() + ); + command( + &app, + &state, + &owner, + &csrf, + "delete_proxy", + json!({"proxyId":credential_proxy}), + ) + .await; + // Jump B uses Jump A's own authenticated proxy; the target's bad proxy is + // intentionally ignored, matching desktop route priority. + let (jump_a_port, jump_a_task) = jump().await; + let (jump_b_port, jump_b_task) = jump().await; + let (jump_proxy_port, jump_proxy_task) = proxy(false, true).await; + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"jump proxy","protocol":"socks5","host":"127.0.0.1","port":jump_proxy_port,"username":"proxy","password":"secret"}})).await; + let a = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"Jump A","type":"ssh","host":"127.0.0.1","port":jump_a_port,"username":"jump","encoding":"GBK","auth":{"mode":"password","password":"jump-password"},"network":{"proxy_id":proxy_id}}})).await; + let b = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"Jump B","type":"ssh","host":"127.0.0.1","port":jump_b_port,"username":"jump","auth":{"mode":"password","password":"jump-password"},"network":{"proxy_jump_id":a}}})).await; + let telnet_route = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"Saved routed Telnet","type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":b}}})).await; + assert_eq!( + nyaterm_core::config::load_connection_by_id(&(), telnet_route.as_str().unwrap()) + .unwrap() + .network + .unwrap() + .proxy_jump_id + .as_deref(), + b.as_str() + ); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":b,"proxy_id":"missing-ignored"}})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Text( + json!({"type":"input","data":"nested jumps"}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, b"nested jumps"); + command( + &app, + &state, + &owner, + &csrf, + "close_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + let mut events = state.login(&owner).await.unwrap().events.subscribe(); + // Cycle detection happens before any TCP connection or authentication. + let (status,_,_) = request(&app,&state,"commands/save_connection",Some(json!({"connection":{"id":a,"name":"Jump A","type":"ssh","host":"127.0.0.1","port":jump_a_port,"username":"jump","auth":{"mode":"password"},"network":{"proxy_jump_id":b}}})),Some(&owner),Some(&csrf)).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + // Imported/on-disk cycles must also be rejected when opening a route. + let mut saved = nyaterm_core::config::load_config(&()).unwrap(); + saved + .connections + .iter_mut() + .find(|c| c.id == a.as_str().unwrap()) + .unwrap() + .network = Some(nyaterm_core::config::ConnectionNetwork { + proxy_id: None, + proxy_jump_id: Some(b.as_str().unwrap().into()), + }); + nyaterm_core::config::save_config(&(), &saved).unwrap(); + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":b}}), + ) + .await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!(error["error"].as_str().unwrap().contains("cycle")); + wait_removed(&state, &id).await; + + for http in [false, true] { + let denied = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let denied_port = denied.local_addr().unwrap().port(); + let denial = tokio::spawn(async move { + let (mut stream, _) = denied.accept().await.unwrap(); + if http { + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(stream.read_u8().await.unwrap()); + } + stream + .write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n") + .await + .unwrap(); + } else { + assert_eq!(stream.read_u8().await.unwrap(), 5); + let count = stream.read_u8().await.unwrap(); + let mut methods = vec![0; count as usize]; + stream.read_exact(&mut methods).await.unwrap(); + stream.write_all(&[5, 2]).await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), 1); + let count = stream.read_u8().await.unwrap(); + let mut username = vec![0; count as usize]; + stream.read_exact(&mut username).await.unwrap(); + let count = stream.read_u8().await.unwrap(); + let mut password = vec![0; count as usize]; + stream.read_exact(&mut password).await.unwrap(); + stream.write_all(&[1, 1]).await.unwrap(); + } + }); + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"reject auth","protocol":if http {"http"} else {"socks5"},"host":"127.0.0.1","port":denied_port,"username":"proxy","password":"wrong"}})).await; + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_id":proxy_id}}), + ) + .await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!(error["error"].as_str().unwrap().contains("proxy")); + wait_removed(&state, &id).await; + denial.await.unwrap(); + } + let mut persisted = nyaterm_core::config::load_config(&()).unwrap(); + for index in 0..9 { + persisted.connections.push(serde_json::from_value(json!({"id":format!("depth-{index}"),"name":"depth","type":"ssh","host":"127.0.0.1","port":jump_a_port,"username":"jump","network":if index < 8 {json!({"proxy_jump_id":format!("depth-{}",index+1)})} else {Value::Null}})).unwrap()); + } + nyaterm_core::config::save_config(&(), &persisted).unwrap(); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":"depth-0"}})).await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!(error["error"].as_str().unwrap().contains("depth")); + wait_removed(&state, &id).await; + // Missing/decryption errors must fail the selected route, never use direct TCP. + for proxy_id in ["not-found", "broken-secret"] { + if proxy_id == "broken-secret" { + let proxies = vec![serde_json::from_value(json!({"id":proxy_id,"name":"bad","protocol":"socks5","host":"127.0.0.1","port":jump_proxy_port,"username":"proxy","password":"not-encrypted"})).unwrap()]; + nyaterm_core::config::save_proxies(&(), &proxies).unwrap(); + } + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_id":proxy_id}}), + ) + .await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!( + error["error"] + .as_str() + .unwrap() + .contains(if proxy_id == "not-found" { + "configuration" + } else { + "decrypt" + }) + ); + wait_removed(&state, &id).await; + } + jump_a_task.abort(); + jump_b_task.abort(); + jump_proxy_task.abort(); + let (vnc_port, mut input, rfb_task) = rfb().await; + let saved_vnc = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"VNC fixture","type":"vnc","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"},"reconnect":{"enabled":false}}})).await; + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"vnc","connectionId":saved_vnc,"recordingScopeId":"vnc-pane"}), + ) + .await; + let info = command( + &app, + &state, + &owner, + &csrf, + "get_session_info", + json!({"sessionId":id}), + ) + .await; + assert_eq!(info["session_type"], "VNC"); + assert_eq!(info["workspace_pane_id"], "vnc-pane"); + assert_eq!(info["terminal_available"], false); + rejected_ws(&host, &state, &id, "terminal", &owner, 400).await; + rejected_ws(&host, &state, &id, "vnc", &other, 404).await; + let mut socket = vnc_ws(&host, &state, &id, &owner).await; + let bytes = frame(&mut socket).await; + assert_eq!(u32::from_le_bytes(bytes[8..12].try_into().unwrap()), 2); + assert_eq!(bytes.len(), 52); + assert_eq!(&bytes[44..47], &[10, 20, 30]); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [99, 1] + ); + socket.send(Message::Text(json!({"type":"input","events":[{"type":"key","keysym":65,"pressed":true},{"type":"pointer","x":1,"y":0,"buttonMask":1}]}).to_string().into())).await.unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [4, 1, 0, 0, 0, 0, 0, 65] + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [5, 1, 0, 1, 0, 0] + ); + socket + .send(Message::Text( + json!({"type":"clipboard","text":"local"}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [6, 0, 0, 0, 0, 0, 0, 5, b'l', b'o', b'c', b'a', b'l'] + ); + + socket + .send(Message::Text( + json!({"type":"clipboard","text":"patch"}) + .to_string() + .into(), + )) + .await + .unwrap(); + input.recv().await.unwrap(); + let patch = frame(&mut socket).await; + assert_eq!(u32::from_le_bytes(patch[16..20].try_into().unwrap()), 1); + assert_eq!(&patch[44..47], &[70, 80, 90]); + socket + .send(Message::Text( + json!({"type":"clipboard","text":"resize"}) + .to_string() + .into(), + )) + .await + .unwrap(); + input.recv().await.unwrap(); + let resized = frame(&mut socket).await; + assert_eq!(u32::from_le_bytes(resized[8..12].try_into().unwrap()), 3); + assert_eq!(resized.len(), 56); + socket.close(None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while state.sessions.lock().await[&id] + .attached + .load(std::sync::atomic::Ordering::Acquire) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let mut socket = vnc_ws(&host, &state, &id, &owner).await; + assert_eq!(frame(&mut socket).await.len(), 56); + command( + &app, + &state, + &owner, + &csrf, + "vnc_reconnect", + json!({"sessionId":id}), + ) + .await; + loop { + let packet = tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(); + if packet == [99, 1] { + break; + } + // Key releases are best effort while the old RFB connection closes. + assert_eq!(packet, [4, 0, 0, 0, 0, 0, 0, 65]); + } + assert_eq!(frame(&mut socket).await.len(), 52); + command( + &app, + &state, + &owner, + &csrf, + "close_vnc_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + let readonly = create(&app,&state,&owner,&csrf,json!({"type":"vnc","config":{"name":"Readonly","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"},"view_only":true,"reconnect":{"enabled":false}}})).await; + let mut socket = vnc_ws(&host, &state, &readonly, &owner).await; + frame(&mut socket).await; + let (status, _, _) = request( + &app, + &state, + "commands/vnc_set_clipboard_text", + Some(json!({"sessionId":readonly,"text":"denied"})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let (status, _, _) = request( + &app, + &state, + "commands/vnc_input_batch", + Some(json!({"sessionId":readonly,"events":[{"type":"key","keysym":65,"pressed":true}]})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(input.recv().await.unwrap(), [99, 1]); + assert!( + tokio::time::timeout(Duration::from_millis(100), input.recv()) + .await + .is_err() + ); + command( + &app, + &state, + &owner, + &csrf, + "close_vnc_session", + json!({"sessionId":readonly}), + ) + .await; + wait_removed(&state, &readonly).await; + + let stalled = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let stalled_port = stalled.local_addr().unwrap().port(); + let (accepted, accepted_rx) = tokio::sync::oneshot::channel(); + let stalled_task = tokio::spawn(async move { + let (mut stream, _) = stalled.accept().await.unwrap(); + let mut bytes = [0; 512]; + let count = stream.read(&mut bytes).await.unwrap(); + assert!(count > 0); + accepted.send(()).unwrap(); + assert_eq!(stream.read(&mut bytes).await.unwrap(), 0); + }); + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"stalled","protocol":"http","host":"127.0.0.1","port":stalled_port}})).await; + let cancelled = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"createRequestId":"cancel-network","network":{"proxy_id":proxy_id}})).await; + tokio::time::timeout(Duration::from_secs(3), accepted_rx) + .await + .unwrap() + .unwrap(); + command( + &app, + &state, + &owner, + &csrf, + "cancel_session_creation", + json!({"createRequestId":"cancel-network"}), + ) + .await; + wait_removed(&state, &cancelled).await; + stalled_task.await.unwrap(); + let mut limited = Vec::new(); + for _ in 0..16 { + limited.push( + create( + &app, + &state, + &other, + &other_csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port}), + ) + .await, + ); + } + let (status, _, _) = request( + &app, + &state, + "sessions", + Some(json!({"type":"vnc","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"}})), + Some(&other), + Some(&other_csrf), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + // Login cancellation releases sessions created up to the shared limit. + state.close_owner(&other).await; + for id in limited { + wait_removed(&state, &id).await; + } + let detached = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port}), + ) + .await; + let (status, _, _) = request( + &app, + &state, + "auth/logout", + Some(json!({})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + wait_removed(&state, &detached).await; + let (owner, csrf) = login(&app, &state).await; + let terminal = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port}), + ) + .await; + let desktop = create(&app,&state,&owner,&csrf,json!({"type":"vnc","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"},"reconnect":{"enabled":false}})).await; + state.shutdown.cancel(); + wait_removed(&state, &terminal).await; + wait_removed(&state, &desktop).await; + assert!(state.sessions.lock().await.is_empty()); + echo_task.abort(); + rfb_task.abort(); + serving.abort(); +} diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index 30395c13..7c4c1d8d 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -286,6 +286,8 @@ pub async fn create_telnet_session( create_request_id: Option, recording_scope_id: Option, startup_command: Option, + network: Option, + encoding: Option, ) -> AppResult { let pending_creation = state.begin_session_creation(create_request_id).await; let (guard, cancel_rx) = match pending_creation { @@ -330,6 +332,7 @@ pub async fn create_telnet_session( name: conn.name.clone(), username: config::resolve_account_username(account.as_ref(), &username), password: telnet_password, + network: conn.network.clone(), backspace_mode, raw_tcp_cli, enter_mode: core::TelnetEnterMode::from_config_value(&enter_mode), @@ -352,6 +355,8 @@ pub async fn create_telnet_session( host: host.ok_or_else(|| AppError::Config("host is required".to_string()))?, port: port.unwrap_or(23), name: name.unwrap_or_else(|| "Telnet".to_string()), + network, + encoding: encoding.unwrap_or_else(|| "UTF-8".into()), ..Default::default() } }; diff --git a/src-tauri/src/cmd/vnc.rs b/src-tauri/src/cmd/vnc.rs index f878521f..c7b72206 100644 --- a/src-tauri/src/cmd/vnc.rs +++ b/src-tauri/src/cmd/vnc.rs @@ -42,6 +42,14 @@ pub async fn vnc_attach_frame_channel( .await } +#[tauri::command] +pub async fn vnc_detach_frame_channel( + state: tauri::State<'_, Arc>, + session_id: String, +) -> AppResult<()> { + state.detach_frame_channel(&session_id).await +} + #[tauri::command] pub async fn vnc_input_batch( state: tauri::State<'_, Arc>, diff --git a/src-tauri/src/core/network/mod.rs b/src-tauri/src/core/network/mod.rs index 55749284..caa3a4ef 100644 --- a/src-tauri/src/core/network/mod.rs +++ b/src-tauri/src/core/network/mod.rs @@ -1,65 +1,10 @@ -use crate::config::{self, ConnectionNetwork, ProxySettings}; +use crate::config::{ConnectionNetwork, ProxySettings}; use crate::error::{AppError, AppResult}; -use std::net::SocketAddr; use tauri::AppHandle; -use tokio::io::{AsyncRead, AsyncWrite}; -pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} -impl AsyncReadWrite for T {} +pub use nyaterm_core::network::{BoxedTransportStream, OpenedTransport}; -pub type BoxedTransportStream = Box; - -pub struct OpenedTransport { - pub stream: BoxedTransportStream, - pub local_addr: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TransportRouteKind { - Direct, - Proxy, - SshJump, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedTransportRoute { - pub kind: TransportRouteKind, - pub proxy_id: Option, - pub proxy_jump_id: Option, -} - -pub fn resolve_transport_route(network: Option<&ConnectionNetwork>) -> ResolvedTransportRoute { - let proxy_jump_id = network - .and_then(|network| network.proxy_jump_id.as_deref()) - .filter(|id| !id.trim().is_empty()) - .map(ToOwned::to_owned); - let proxy_id = network - .and_then(|network| network.proxy_id.as_deref()) - .filter(|id| !id.trim().is_empty()) - .map(ToOwned::to_owned); - - if proxy_jump_id.is_some() { - return ResolvedTransportRoute { - kind: TransportRouteKind::SshJump, - proxy_id, - proxy_jump_id, - }; - } - - if proxy_id.is_some() { - return ResolvedTransportRoute { - kind: TransportRouteKind::Proxy, - proxy_id, - proxy_jump_id: None, - }; - } - - ResolvedTransportRoute { - kind: TransportRouteKind::Direct, - proxy_id: None, - proxy_jump_id: None, - } -} +pub use nyaterm_core::network::{TransportRouteKind, resolve_proxy, resolve_transport_route}; pub async fn open_tcp_transport( app: &AppHandle, @@ -101,18 +46,11 @@ pub async fn open_tcp_transport( } } -async fn open_direct_transport(target_host: &str, target_port: u16) -> AppResult { - let stream = tokio::net::TcpStream::connect((target_host, target_port)) - .await - .map_err(|error| { - AppError::Channel(format!( - "Direct TCP connection to {target_host}:{target_port} failed: {error}" - )) - })?; - let local_addr = stream.local_addr().ok(); +async fn open_direct_transport(host: &str, port: u16) -> AppResult { + let transport = nyaterm_core::network::open_direct_transport(host, port).await?; Ok(OpenedTransport { - stream: Box::new(stream), - local_addr, + stream: transport.stream, + local_addr: transport.local_addr, }) } @@ -121,63 +59,14 @@ async fn open_proxy_transport( target_host: &str, target_port: u16, ) -> AppResult { - let proxy_addr = format!("{}:{}", proxy.host, proxy.port); match proxy.protocol.as_str() { - "socks5" => { - let stream = match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - tokio_socks::tcp::Socks5Stream::connect_with_password( - proxy_addr.as_str(), - (target_host, target_port), - user, - pass, - ) - .await - } - _ => { - tokio_socks::tcp::Socks5Stream::connect( - proxy_addr.as_str(), - (target_host, target_port), - ) - .await - } - } - .map_err(|error| AppError::Auth(format!("SOCKS5 proxy connection failed: {error}")))? - .into_inner(); - let local_addr = stream.local_addr().ok(); + "socks5" | "http" => { + let transport = + nyaterm_core::network::open_proxy_transport(proxy, target_host, target_port) + .await?; Ok(OpenedTransport { - stream: Box::new(stream), - local_addr, - }) - } - "http" => { - let mut stream = - tokio::net::TcpStream::connect(&proxy_addr) - .await - .map_err(|error| { - AppError::Channel(format!("HTTP proxy connection failed: {error}")) - })?; - match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - async_http_proxy::http_connect_tokio_with_basic_auth( - &mut stream, - target_host, - target_port, - user, - pass, - ) - .await - } - _ => { - async_http_proxy::http_connect_tokio(&mut stream, target_host, target_port) - .await - } - } - .map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {error}")))?; - let local_addr = stream.local_addr().ok(); - Ok(OpenedTransport { - stream: Box::new(stream), - local_addr, + stream: transport.stream, + local_addr: transport.local_addr, }) } "proxycommand" => { @@ -200,25 +89,6 @@ async fn open_proxy_transport( } } -fn resolve_proxy(app: &AppHandle, proxy_id: &str) -> AppResult { - let proxy_cfg = config::load_proxy_by_id(app, proxy_id)? - .ok_or_else(|| AppError::Config(format!("Proxy '{proxy_id}' not found")))?; - let password = proxy_cfg - .password - .as_ref() - .and_then(|ciphertext| crate::utils::crypto::decrypt(ciphertext).ok()); - - Ok(ProxySettings { - enabled: true, - protocol: proxy_cfg.protocol, - host: proxy_cfg.host, - port: proxy_cfg.port, - command: proxy_cfg.command, - username: proxy_cfg.username, - password, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/core/remote_desktop/frame.rs b/src-tauri/src/core/remote_desktop/frame.rs index 880e8810..ccac39a7 100644 --- a/src-tauri/src/core/remote_desktop/frame.rs +++ b/src-tauri/src/core/remote_desktop/frame.rs @@ -1,153 +1 @@ -use crate::error::{AppError, AppResult}; - -pub const FRAME_HEADER_BYTES: usize = 44; -pub const PIXEL_FORMAT_BGRA8888: u32 = 1; -pub const PIXEL_FORMAT_RGBA8888: u32 = 2; -const BYTES_PER_PIXEL: usize = 4; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum RemoteDesktopPixelFormat { - Bgra8888, - Rgba8888, -} - -impl RemoteDesktopPixelFormat { - fn wire_value(self) -> u32 { - match self { - Self::Bgra8888 => PIXEL_FORMAT_BGRA8888, - Self::Rgba8888 => PIXEL_FORMAT_RGBA8888, - } - } -} - -pub struct RemoteDesktopFramePatch<'a> { - pub sequence: u64, - pub desktop_width: u32, - pub desktop_height: u32, - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, - pub stride: u32, - pub pixel_format: RemoteDesktopPixelFormat, - pub payload: &'a [u8], -} - -pub fn encode_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult> { - validate_frame_patch(patch)?; - let mut frame = Vec::with_capacity( - FRAME_HEADER_BYTES - .checked_add(patch.payload.len()) - .ok_or_else(|| AppError::Config("Remote desktop frame size overflows".to_string()))?, - ); - frame.extend_from_slice(&patch.sequence.to_le_bytes()); - frame.extend_from_slice(&patch.desktop_width.to_le_bytes()); - frame.extend_from_slice(&patch.desktop_height.to_le_bytes()); - frame.extend_from_slice(&patch.x.to_le_bytes()); - frame.extend_from_slice(&patch.y.to_le_bytes()); - frame.extend_from_slice(&patch.width.to_le_bytes()); - frame.extend_from_slice(&patch.height.to_le_bytes()); - frame.extend_from_slice(&patch.stride.to_le_bytes()); - frame.extend_from_slice(&patch.pixel_format.wire_value().to_le_bytes()); - let payload_len = u32::try_from(patch.payload.len()).map_err(|_| { - AppError::Config("Remote desktop frame payload exceeds the wire limit".to_string()) - })?; - frame.extend_from_slice(&payload_len.to_le_bytes()); - frame.extend_from_slice(patch.payload); - Ok(frame) -} - -fn validate_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<()> { - if patch.desktop_width == 0 || patch.desktop_height == 0 { - return Err(AppError::Config( - "Remote desktop framebuffer dimensions must be non-zero".to_string(), - )); - } - if patch.width == 0 || patch.height == 0 { - return Err(AppError::Config( - "Remote desktop frame rectangle must be non-zero".to_string(), - )); - } - let right = patch.x.checked_add(patch.width).ok_or_else(|| { - AppError::Config("Remote desktop frame horizontal bounds overflow".to_string()) - })?; - let bottom = patch.y.checked_add(patch.height).ok_or_else(|| { - AppError::Config("Remote desktop frame vertical bounds overflow".to_string()) - })?; - if right > patch.desktop_width || bottom > patch.desktop_height { - return Err(AppError::Config( - "Remote desktop frame rectangle exceeds framebuffer bounds".to_string(), - )); - } - let row_bytes = usize::try_from(patch.width) - .ok() - .and_then(|width| width.checked_mul(BYTES_PER_PIXEL)) - .ok_or_else(|| AppError::Config("Remote desktop frame row size overflows".to_string()))?; - let stride = usize::try_from(patch.stride) - .map_err(|_| AppError::Config("Remote desktop frame stride is invalid".to_string()))?; - if stride < row_bytes { - return Err(AppError::Config( - "Remote desktop frame stride is too small".to_string(), - )); - } - let required_payload = usize::try_from(patch.height) - .ok() - .and_then(|height| stride.checked_mul(height)) - .ok_or_else(|| { - AppError::Config("Remote desktop frame payload size overflows".to_string()) - })?; - if patch.payload.len() < required_payload { - return Err(AppError::Config( - "Remote desktop frame payload is too small".to_string(), - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encodes_the_shared_frame_header() { - let payload = [1, 2, 3, 255]; - let frame = encode_frame_patch(&RemoteDesktopFramePatch { - sequence: 7, - desktop_width: 10, - desktop_height: 10, - x: 2, - y: 3, - width: 1, - height: 1, - stride: 4, - pixel_format: RemoteDesktopPixelFormat::Rgba8888, - payload: &payload, - }) - .expect("frame should encode"); - - assert_eq!(frame.len(), FRAME_HEADER_BYTES + payload.len()); - assert_eq!(u64::from_le_bytes(frame[0..8].try_into().unwrap()), 7); - assert_eq!(u32::from_le_bytes(frame[36..40].try_into().unwrap()), 2); - assert_eq!(&frame[FRAME_HEADER_BYTES..], &payload); - } - - #[test] - fn rejects_rectangles_outside_the_framebuffer() { - let error = encode_frame_patch(&RemoteDesktopFramePatch { - sequence: 1, - desktop_width: 10, - desktop_height: 10, - x: 10, - y: 0, - width: 1, - height: 1, - stride: 4, - pixel_format: RemoteDesktopPixelFormat::Rgba8888, - payload: &[0; 4], - }) - .expect_err("out-of-bounds rectangle must fail"); - - assert!(error.to_string().contains("exceeds framebuffer bounds")); - } -} +pub use nyaterm_core::remote_desktop_frame::*; diff --git a/src-tauri/src/core/ssh/auth.rs b/src-tauri/src/core/ssh/auth.rs index 321d62ab..413f86c8 100644 --- a/src-tauri/src/core/ssh/auth.rs +++ b/src-tauri/src/core/ssh/auth.rs @@ -686,22 +686,7 @@ fn resolve_proxy( return Ok(None); }; - let proxy_cfg = crate::config::load_proxy_by_id(app, proxy_id)? - .ok_or_else(|| AppError::Config(format!("Proxy '{}' not found", proxy_id)))?; - let password = proxy_cfg - .password - .as_ref() - .and_then(|ciphertext| crate::utils::crypto::decrypt(ciphertext).ok()); - - Ok(Some(crate::config::ProxySettings { - enabled: true, - protocol: proxy_cfg.protocol, - host: proxy_cfg.host, - port: proxy_cfg.port, - command: proxy_cfg.command, - username: proxy_cfg.username, - password, - })) + Ok(Some(nyaterm_core::network::resolve_proxy(app, proxy_id)?)) } pub(super) async fn authenticate_handle( diff --git a/src-tauri/src/core/ssh/client.rs b/src-tauri/src/core/ssh/client.rs index f51b1684..8fb0d36e 100644 --- a/src-tauri/src/core/ssh/client.rs +++ b/src-tauri/src/core/ssh/client.rs @@ -1117,53 +1117,12 @@ pub(super) async fn connect_with_proxy( "Opening SSH transport via proxy" ); - let proxy_addr = format!("{}:{}", proxy.host, proxy.port); match proxy.protocol.as_str() { - "socks5" => { - let stream = match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - tokio_socks::tcp::Socks5Stream::connect_with_password( - proxy_addr.as_str(), - target, - user, - pass, - ) - .await - } - _ => tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target).await, - } - .map_err(|error| { - AppError::Auth(format!("SOCKS5 proxy connection failed: {}", error)) - })?; - client::connect_stream(ssh_config, stream.into_inner(), handler).await - } - "http" => { - let mut stream = - tokio::net::TcpStream::connect(&proxy_addr) - .await - .map_err(|error| { - AppError::Auth(format!("HTTP proxy connection failed: {}", error)) - })?; - - match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - async_http_proxy::http_connect_tokio_with_basic_auth( - &mut stream, - &config.host, - config.port, - user, - pass, - ) - .await - } - _ => { - async_http_proxy::http_connect_tokio(&mut stream, &config.host, config.port) - .await - } - } - .map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {}", error)))?; - - client::connect_stream(ssh_config, stream, handler).await + "socks5" | "http" => { + let transport = + nyaterm_core::network::open_proxy_transport(proxy, &config.host, config.port) + .await?; + client::connect_stream(ssh_config, transport.stream, handler).await } "proxycommand" => { let stream = open_proxy_command_stream( @@ -1175,8 +1134,10 @@ pub(super) async fn connect_with_proxy( .await?; client::connect_stream(ssh_config, stream, handler).await } - _ => { - nyaterm_core::ssh::protocol::connect(ssh_config, target.0, target.1, handler).await + other => { + return Err(AppError::Config(format!( + "Unsupported proxy protocol '{other}'" + ))); } } } else { diff --git a/src-tauri/src/core/terminal_session/mod.rs b/src-tauri/src/core/terminal_session/mod.rs index 7f04489e..b397c08c 100644 --- a/src-tauri/src/core/terminal_session/mod.rs +++ b/src-tauri/src/core/terminal_session/mod.rs @@ -4,182 +4,6 @@ pub(crate) mod local; pub(crate) mod serial; pub(crate) mod telnet; -use crate::core::input::remap_del_to_bs; -use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8}; - -pub(crate) fn terminal_encoding(label: &str) -> &'static Encoding { - let trimmed = label.trim(); - if trimmed.is_empty() { - return UTF_8; - } - Encoding::for_label(trimmed.as_bytes()).unwrap_or(UTF_8) -} - -pub(crate) struct TerminalOutputDecoder { - decoder: Decoder, -} - -impl TerminalOutputDecoder { - pub(crate) fn new(encoding: &str) -> Self { - Self { - decoder: terminal_encoding(encoding).new_decoder_without_bom_handling(), - } - } - - pub(crate) fn decode(&mut self, data: &[u8]) -> String { - let capacity = self - .decoder - .max_utf8_buffer_length(data.len()) - .unwrap_or_else(|| data.len().saturating_mul(4)); - let mut output = String::with_capacity(capacity); - let mut total_read = 0; - while total_read < data.len() { - let (result, read, _) = - self.decoder - .decode_to_string(&data[total_read..], &mut output, false); - total_read += read; - match result { - CoderResult::InputEmpty => break, - CoderResult::OutputFull => { - output.reserve( - self.decoder - .max_utf8_buffer_length(data.len() - total_read) - .unwrap_or_else(|| (data.len() - total_read).saturating_mul(4)) - .max(4), - ); - } - } - } - output - } -} - -pub(crate) fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec { - let target = terminal_encoding(encoding); - if target == UTF_8 { - return data.to_vec(); - } - - let mut result = Vec::with_capacity(data.len()); - let mut i = 0; - while i < data.len() { - if data[i] == 0x1b { - let end = ansi_sequence_end(data, i); - result.extend_from_slice(&data[i..end]); - i = end; - continue; - } - - if data[i].is_ascii() { - result.push(data[i]); - i += 1; - continue; - } - - let start = i; - while i < data.len() && !data[i].is_ascii() && data[i] != 0x1b { - i += 1; - } - - let text = String::from_utf8_lossy(&data[start..i]); - let (encoded, _, _) = target.encode(&text); - result.extend_from_slice(&encoded); - } - - result -} - -pub(crate) fn prepare_terminal_write_input( - mut data: Vec, - encoding: &str, - raw: bool, - backspace_as_bs: bool, -) -> Vec { - if raw { - return data; - } - if backspace_as_bs { - remap_del_to_bs(&mut data); - } - encode_terminal_input(&data, encoding) -} - -fn ansi_sequence_end(data: &[u8], start: usize) -> usize { - let mut i = start + 1; - if i >= data.len() { - return i; - } - - if matches!(data[i], b'[' | b']' | b'(' | b')' | b'#') { - i += 1; - while i < data.len() { - let byte = data[i]; - i += 1; - if (0x40..=0x7e).contains(&byte) { - break; - } - } - return i; - } - - (i + 1).min(data.len()) -} - -#[cfg(test)] -mod tests { - use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input}; - - #[test] - fn write_input_encodes_text_but_preserves_raw_bytes_and_del() { - assert_eq!( - prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true), - vec![0xB2, 0xE2, 0x08] - ); - let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f]; - assert_eq!( - prepare_terminal_write_input(raw.clone(), "GBK", true, true), - raw - ); - } - - #[test] - fn gbk_decoder_preserves_split_multibyte_character() { - let mut decoder = TerminalOutputDecoder::new("GBK"); - assert_eq!(decoder.decode(&[0xB2]), ""); - assert_eq!(decoder.decode(&[0xE2]), "测"); - } - - #[test] - fn gb18030_decoder_preserves_split_multibyte_character() { - let mut decoder = TerminalOutputDecoder::new("GB18030"); - assert_eq!(decoder.decode(&[0xB2]), ""); - assert_eq!(decoder.decode(&[0xE2]), "测"); - } - - #[test] - fn gbk_decoder_handles_utf8_output_expansion() { - let mut decoder = TerminalOutputDecoder::new("GBK"); - let mut input = Vec::new(); - for _ in 0..1024 { - input.extend_from_slice(&[0xB2, 0xE2]); - } - - let output = decoder.decode(&input); - - assert_eq!(output.chars().count(), 1024); - assert!(output.chars().all(|ch| ch == '测')); - } - - #[test] - fn input_encoder_keeps_controls_and_ansi_sequences_raw() { - let input = b"\x1b[200~\x7f\xe6\xb5\x8b\x1b[201~"; - let encoded = encode_terminal_input(input, "GBK"); - assert_eq!( - encoded, - vec![ - 0x1b, b'[', b'2', b'0', b'0', b'~', 0x7f, 0xB2, 0xE2, 0x1b, b'[', b'2', b'0', b'1', - b'~' - ] - ); - } -} +pub(crate) use nyaterm_core::terminal_encoding::{ + TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input, +}; diff --git a/src-tauri/src/core/terminal_session/telnet/manager.rs b/src-tauri/src/core/terminal_session/telnet/manager.rs index 77a9adc8..286ca497 100644 --- a/src-tauri/src/core/terminal_session/telnet/manager.rs +++ b/src-tauri/src/core/terminal_session/telnet/manager.rs @@ -27,8 +27,23 @@ pub async fn create_telnet_session( client_timestamp: None, }); let session_id = uuid::Uuid::new_v4().to_string(); - let addr = format!("{}:{}", host, port); - let stream = match await_telnet_connection(TcpStream::connect(&addr), cancel_rx).await { + let stream = match await_telnet_connection( + async { + crate::core::network::open_tcp_transport( + &app, + &host, + port, + config.network.as_ref(), + owner_window_label.clone(), + ) + .await + .map(|opened| opened.stream) + .map_err(std::io::Error::other) + }, + cancel_rx, + ) + .await + { Ok(stream) => stream, Err(AppError::Cancelled(message)) => { log_event(StructuredLog { diff --git a/src-tauri/src/core/terminal_session/telnet/mod.rs b/src-tauri/src/core/terminal_session/telnet/mod.rs index 810be3c5..6e9a89d5 100644 --- a/src-tauri/src/core/terminal_session/telnet/mod.rs +++ b/src-tauri/src/core/terminal_session/telnet/mod.rs @@ -21,25 +21,10 @@ use crate::observability::{StructuredLog, StructuredLogLevel, log_event, log_rat use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; use tokio::sync::{Mutex as TokioMutex, mpsc, oneshot}; -const IAC: u8 = 255; -const WILL: u8 = 251; -const WONT: u8 = 252; -const DO: u8 = 253; -const DONT: u8 = 254; -const SB: u8 = 250; -const SE: u8 = 240; +pub use nyaterm_core::telnet::*; -const OPT_ECHO: u8 = 1; -const OPT_SUPPRESS_GO_AHEAD: u8 = 3; -const OPT_NAWS: u8 = 31; - -include!("types.rs"); -include!("negotiation.rs"); -include!("line_editor.rs"); -include!("auto_login.rs"); include!("tests.rs"); include!("manager.rs"); include!("session.rs"); diff --git a/src-tauri/src/core/terminal_session/telnet/session.rs b/src-tauri/src/core/terminal_session/telnet/session.rs index f5635443..781563ba 100644 --- a/src-tauri/src/core/terminal_session/telnet/session.rs +++ b/src-tauri/src/core/terminal_session/telnet/session.rs @@ -38,14 +38,14 @@ async fn telnet_session_task( manager: Arc, mut cmd_rx: SessionCommandReceiver, output_control_tx: SessionCommandSender, - stream: TcpStream, + stream: crate::core::network::BoxedTransportStream, config: TelnetSessionConfig, connection_id: Option, encoding: String, startup_command: Option, ) { let backspace_as_bs = config.backspace_mode == "ctrl_h"; - let (mut reader, mut writer) = stream.into_split(); + let (mut reader, mut writer) = tokio::io::split(stream); let output_event = format!("terminal-output-{}", session_id); let closed_event = format!("session-closed-{}", session_id); let recording_mgr: Option> = app @@ -95,6 +95,7 @@ async fn telnet_session_task( let mut buf = [0u8; 4096]; let mut zmodem_detector = ZmodemDetector::new(); let mut output_decoder = TerminalOutputDecoder::new(&encoding_reader); + let mut telnet_decoder = TelnetDecoder::default(); 'reader: loop { while *pause_rx.borrow() { if pause_rx.changed().await.is_err() { @@ -105,10 +106,10 @@ async fn telnet_session_task( Ok(0) => break, Ok(n) => { let visible = if reader_config.raw_tcp_cli { - unescape_iac_iac(&buf[..n]) + buf[..n].to_vec() } else { let neg_tx = negotiate_tx.clone(); - strip_telnet_commands(&buf[..n], &mut |cmd, opt| { + telnet_decoder.decode(&buf[..n], &mut |cmd, opt| { let resp = negotiate_response( cmd, opt, @@ -171,9 +172,7 @@ async fn telnet_session_task( let visible = if zmodem_download_oo_drain_reader.lock().await.is_active() { let mut drain = zmodem_download_oo_drain_reader.lock().await; - drain - .filter(&visible, std::time::Instant::now()) - .to_vec() + drain.filter(&visible, std::time::Instant::now()).to_vec() } else { visible }; @@ -243,9 +242,7 @@ async fn telnet_session_task( { let mut auto = auto_login_for_reader.lock().await; if let Some(auto) = auto.as_mut() { - for action in - auto.handle_text(&text, std::time::Instant::now()) - { + for action in auto.handle_text(&text, std::time::Instant::now()) { let _ = auto_login_tx.send(action); } } diff --git a/src-tauri/src/core/terminal_session/telnet/tests.rs b/src-tauri/src/core/terminal_session/telnet/tests.rs index cab398af..95d7a7ad 100644 --- a/src-tauri/src/core/terminal_session/telnet/tests.rs +++ b/src-tauri/src/core/terminal_session/telnet/tests.rs @@ -1,24 +1,8 @@ #[cfg(test)] mod tests { - use super::{ - DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetEnterMode, TelnetLineEditor, - TelnetAutoLogin, TelnetAutoLoginAction, TelnetAutoLoginConfig, - TelnetAutoLoginCredentials, TelnetSessionConfig, WILL, maybe_build_naws, - await_telnet_connection, escape_telnet_application_data, negotiate_response, normalize_enter_bytes, split_write_chunks, - strip_telnet_commands, - }; + use super::await_telnet_connection; use crate::error::AppError; - use std::time::Instant; use tokio::sync::oneshot; - - #[test] - fn standard_negotiation_responds_by_default() { - assert_eq!( - negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, true), - vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] - ); - } - #[tokio::test] async fn connection_wait_can_be_cancelled_before_connect_finishes() { let (cancel_tx, cancel_rx) = oneshot::channel(); @@ -38,337 +22,11 @@ mod tests { #[tokio::test] async fn connection_wait_returns_connect_result_without_cancellation() { - let result = await_telnet_connection( - std::future::ready(Ok::<_, std::io::Error>(42_u8)), - None, - ) - .await - .expect("connect result"); + let result = + await_telnet_connection(std::future::ready(Ok::<_, std::io::Error>(42_u8)), None) + .await + .expect("connect result"); assert_eq!(result, 42); } - - #[test] - fn send_sga_false_rejects_sga() { - assert_ne!( - negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, false), - vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] - ); - } - - #[test] - fn send_naws_false_rejects_naws_negotiation() { - assert_ne!( - negotiate_response(DO, OPT_NAWS, false, true), - vec![IAC, WILL, OPT_NAWS] - ); - } - - #[test] - fn raw_mode_can_suppress_negotiation_responses() { - let config = TelnetSessionConfig { - raw_tcp_cli: true, - ..Default::default() - }; - let mut responses = Vec::new(); - if !config.raw_tcp_cli { - let _ = strip_telnet_commands(&[IAC, WILL, OPT_SUPPRESS_GO_AHEAD], &mut |cmd, opt| { - responses.push(negotiate_response( - cmd, - opt, - config.send_naws, - config.send_sga, - )); - }); - } - assert!(responses.is_empty()); - } - - #[test] - fn binary_input_escapes_iac_only_for_telnet_transport() { - let mouse_report = b"\x1b[M \xff\x80"; - assert_eq!( - escape_telnet_application_data(mouse_report, false), - b"\x1b[M \xff\xff\x80" - ); - assert_eq!( - escape_telnet_application_data(mouse_report, true), - mouse_report - ); - } - - #[test] - fn send_naws_false_prevents_naws_resize_payload() { - let config = TelnetSessionConfig { - send_naws: false, - ..Default::default() - }; - assert!(maybe_build_naws(80, 24, &config).is_none()); - } - - #[test] - fn raw_mode_prevents_naws_resize_payload() { - let config = TelnetSessionConfig { - raw_tcp_cli: true, - ..Default::default() - }; - assert!(maybe_build_naws(80, 24, &config).is_none()); - } - - #[test] - fn enter_conversion_maps_carriage_return() { - assert_eq!( - normalize_enter_bytes(b"show\r", TelnetEnterMode::Crlf), - b"show\r\n" - ); - assert_eq!( - normalize_enter_bytes(b"show\r", TelnetEnterMode::Cr), - b"show\r" - ); - assert_eq!( - normalize_enter_bytes(b"show\r", TelnetEnterMode::Lf), - b"show\n" - ); - } - - #[test] - fn force_character_at_a_time_preserves_utf8_order() { - let chunks = split_write_chunks("a中\r".as_bytes(), true); - assert_eq!( - chunks, - vec![b"a".to_vec(), "中".as_bytes().to_vec(), b"\r".to_vec()] - ); - let joined: Vec = chunks.into_iter().flatten().collect(); - assert_eq!(joined, "a中\r".as_bytes()); - } - - #[test] - fn strip_telnet_commands_emits_naws_response_request() { - let mut seen = Vec::new(); - let visible = strip_telnet_commands(b"hi\xff\xfd\x1f", &mut |cmd, opt| { - seen.push((cmd, opt)); - }); - assert_eq!(visible, b"hi"); - assert_eq!(seen, vec![(DO, OPT_NAWS)]); - } - - #[test] - fn local_line_editor_backspace_updates_buffer() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7f", TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), "ab"); - assert_eq!(result.display, "abc\x08 \x08"); - assert!(result.writes.is_empty()); - } - - #[test] - fn local_line_editor_sends_buffer_on_enter() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), ""); - assert_eq!(result.writes, vec![b"abd\r".to_vec()]); - assert_eq!(result.display, "abc\x08 \x08d\r\n"); - - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Crlf); - assert_eq!(result.writes, vec![b"abd\r\n".to_vec()]); - - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Lf); - assert_eq!(result.writes, vec![b"abd\n".to_vec()]); - } - - #[test] - fn local_line_editor_backspace_removes_one_utf8_char() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process("中a\u{7f}".as_bytes(), TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), "中"); - assert_eq!(result.display, "中a\x08 \x08"); - - let result = editor.process(b"\x7f", TelnetEnterMode::Cr); - assert_eq!(editor.buffer(), ""); - assert_eq!(result.display, "\x08 \x08"); - } - - #[test] - fn local_line_editor_passes_controls_without_buffering() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"a\x03\x04\x1b[A\x1b[3~", TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), ""); - assert_eq!( - result.writes, - vec![vec![0x03], vec![0x04], b"\x1b[A".to_vec()] - ); - assert_eq!(result.display, "a\x08 \x08"); - } - - fn auto_login() -> TelnetAutoLogin { - TelnetAutoLogin::new( - TelnetAutoLoginConfig::default(), - TelnetAutoLoginCredentials { - username: "admin".to_string(), - password: Some("secret".to_string()), - }, - TelnetEnterMode::Cr, - Instant::now(), - ) - .expect("auto login") - } - - fn sent_payloads(actions: Vec) -> Vec> { - actions - .into_iter() - .filter_map(|action| match action { - TelnetAutoLoginAction::Send(data) => Some(data), - _ => None, - }) - .collect() - } - - #[test] - fn auto_login_detects_split_username_and_password_prompts() { - let mut login = auto_login(); - - assert!(login.handle_text("User", Instant::now()).is_empty()); - assert_eq!( - sent_payloads(login.handle_text("name:", Instant::now())), - vec![b"admin\r".to_vec()] - ); - assert!(login.handle_text("Pass", Instant::now()).is_empty()); - assert_eq!( - sent_payloads(login.handle_text("word:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_detects_chinese_prompts() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("用户名:", Instant::now())), - vec![b"admin\r".to_vec()] - ); - assert_eq!( - sent_payloads(login.handle_text("密码:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_detects_linux_getty_host_login_prompt() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text( - "\r\nLinux 6.8.0-107-generic (kaikai) (pts/3)\r\n\r\nkaikai login: ", - Instant::now() - )), - vec![b"admin\r".to_vec()] - ); - } - - #[test] - fn auto_login_detects_password_prompt_without_colon() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("Input Password", Instant::now())), - vec![b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_sends_wake_enter_once() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("Press RETURN to get started.", Instant::now())), - vec![b"\r".to_vec()] - ); - assert!(login - .handle_text("Press RETURN to get started.", Instant::now()) - .is_empty()); - } - - #[test] - fn auto_login_does_not_treat_last_login_as_username_prompt() { - let mut login = auto_login(); - - assert!(login - .handle_text("Last login: Mon Jul 13 12:00:00", Instant::now()) - .is_empty()); - assert!(login - .handle_text("Previous login: Mon Jul 13 12:00:00", Instant::now()) - .is_empty()); - } - - #[test] - fn auto_login_sends_username_before_password_in_same_chunk() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("Username:\nPassword:", Instant::now())), - vec![b"admin\r".to_vec(), b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_completes_on_shell_prompt_after_credentials() { - let mut login = auto_login(); - let _ = login.handle_text("Username:", Instant::now()); - let _ = login.handle_text("Password:", Instant::now()); - - assert!(login - .handle_text("router#", Instant::now()) - .contains(&TelnetAutoLoginAction::Complete)); - } - - #[test] - fn auto_login_failure_retries_then_disables() { - let mut login = TelnetAutoLogin::new( - TelnetAutoLoginConfig { - max_retries: 1, - ..TelnetAutoLoginConfig::default() - }, - TelnetAutoLoginCredentials { - username: String::new(), - password: Some("secret".to_string()), - }, - TelnetEnterMode::Cr, - Instant::now(), - ) - .expect("auto login"); - - assert_eq!( - sent_payloads(login.handle_text("Password:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - assert!(login - .handle_text("Authentication failed", Instant::now()) - .is_empty()); - assert_eq!( - sent_payloads(login.handle_text("Password:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - assert!(login - .handle_text("Authentication failed", Instant::now()) - .contains(&TelnetAutoLoginAction::Disable)); - } - - #[test] - fn auto_login_user_input_disables_but_automated_input_does_not() { - let mut login = auto_login(); - - assert_eq!(login.handle_user_input(true), None); - assert_eq!( - login.handle_user_input(false), - Some(TelnetAutoLoginAction::Disable) - ); - assert!(login.handle_text("Username:", Instant::now()).is_empty()); - } } diff --git a/src-tauri/src/core/vnc.rs b/src-tauri/src/core/vnc.rs index ea4277f7..c326271d 100644 --- a/src-tauri/src/core/vnc.rs +++ b/src-tauri/src/core/vnc.rs @@ -1,246 +1,56 @@ -use crate::config::{self, ConnectionAuth, ConnectionNetwork, ConnectionType}; -use crate::core::network::open_tcp_transport; +//! Native event/IPC adapter for the shared VNC protocol engine. use crate::error::{AppError, AppResult}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::{HashMap, VecDeque}; +use nyaterm_core::vnc::{ + self as shared, + runtime::{FrameChannel, VncContext}, +}; +pub use nyaterm_core::vnc::{VncConnectConfig, VncInputEvent}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use tauri::async_runtime::JoinHandle; use tauri::ipc::{Channel, InvokeResponseBody}; use tauri::{AppHandle, Emitter}; -use tokio::sync::{Mutex, mpsc, oneshot, watch}; -use tokio::time::{Duration, timeout}; -use vnc::{ - ClientKeyEvent, ClientMouseEvent, PixelFormat, Rect, Screen, VncClient, VncConnector, - VncEncoding, VncError, VncEvent, VncLimits, VncSecurityPolicy, VncServerKey, X11Event, -}; -use zeroize::Zeroizing; -use crate::core::remote_desktop::frame::{ - RemoteDesktopFramePatch, RemoteDesktopPixelFormat, encode_frame_patch, -}; - -const MAX_FRAMEBUFFER_WIDTH: u16 = 7680; -const MAX_FRAMEBUFFER_HEIGHT: u16 = 4320; -const MAX_VNC_CLIPBOARD_BYTES: usize = 1024 * 1024; -const MAX_INPUT_BATCH: usize = 256; -const WORKER_COMMAND_CHANNEL_CAPACITY: usize = 256; -const MAX_PENDING_FRAMES: usize = 2; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); -const RA2_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(75); -const SERVER_KEY_PROMPT_TIMEOUT: Duration = Duration::from_mins(1); -const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); -const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(8); -const UPDATE_REQUEST_INTERVAL: Duration = Duration::from_millis(16); - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VncSessionState { - Connecting, - Authenticating, - Negotiating, - Active, - Reconnecting, - Disconnected, - Failed, -} - -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VncErrorKind { - Transport, - Authentication, - Protocol, - Encoding, - Clipboard, - Internal, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VncStateEvent { - pub session_id: String, - pub state: VncSessionState, - pub message: Option, - pub error_kind: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VncServerKeyVerifyEvent { - pub request_id: String, - pub session_id: String, - pub host: String, - pub port: u16, - pub fingerprint: String, - pub key_bits: u32, - pub known_host_status: String, - pub target_window_label: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VncClipboardEvent { - pub session_id: String, - pub text: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum VncInputEvent { - #[serde(rename = "key")] - Key { keysym: u32, pressed: bool }, - #[serde(rename = "pointer")] - Pointer { - x: u16, - y: u16, - #[serde(alias = "buttonMask")] - button_mask: u8, - }, - #[serde(rename = "release-all-keys")] - ReleaseAllKeys, -} - -#[derive(Debug)] -enum VncWorkerCommand { - Input(VncInputEvent), - Clipboard(String), - FullRefresh, -} - -struct VncServerKeyPending { - session_id: String, - generation: u64, - responder: oneshot::Sender, -} - -#[derive(Debug, Clone)] -pub struct VncConnectConfig { - pub owner_window_label: String, - pub session_id: String, - pub connection_id: String, - pub name: String, - pub host: String, - pub port: u16, - pub username: String, - pub password: Option, - pub security_mode: String, - pub scale_mode: String, - pub clipboard_enabled: bool, - pub reconnect_enabled: bool, - pub reconnect_max_attempts: u32, - pub shared: bool, - pub view_only: bool, - pub network: Option, -} - -struct VncFramebuffer { - width: u16, - height: u16, - rgba: Vec, -} - -impl VncFramebuffer { - fn new(width: u16, height: u16) -> AppResult { - validate_framebuffer_dimensions(width, height)?; - let len = usize::from(width) - .checked_mul(usize::from(height)) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or_else(|| AppError::Config("VNC framebuffer size overflows".to_string()))?; - Ok(Self { - width, - height, - rgba: vec![0; len], - }) - } - - fn apply_rgba(&mut self, rect: Rect, pixels: &[u8]) -> AppResult<()> { - validate_rectangle(rect, self.width, self.height)?; - let row_bytes = usize::from(rect.width) - .checked_mul(4) - .ok_or_else(|| AppError::Config("VNC rectangle row size overflows".to_string()))?; - let required = row_bytes - .checked_mul(usize::from(rect.height)) - .ok_or_else(|| AppError::Config("VNC rectangle payload size overflows".to_string()))?; - if pixels.len() != required { - return Err(AppError::Config( - "VNC rectangle payload length is invalid".to_string(), - )); - } - let framebuffer_stride = usize::from(self.width) * 4; - for row in 0..usize::from(rect.height) { - let src_start = row * row_bytes; - let dst_start = - (usize::from(rect.y) + row) * framebuffer_stride + usize::from(rect.x) * 4; - self.rgba[dst_start..dst_start + row_bytes] - .copy_from_slice(&pixels[src_start..src_start + row_bytes]); - } - Ok(()) - } - - fn patch_bytes(&self, sequence: u64, rect: Rect, pixels: &[u8]) -> AppResult> { - encode_frame_patch(&RemoteDesktopFramePatch { - sequence, - desktop_width: u32::from(self.width), - desktop_height: u32::from(self.height), - x: u32::from(rect.x), - y: u32::from(rect.y), - width: u32::from(rect.width), - height: u32::from(rect.height), - stride: u32::from(rect.width) * 4, - pixel_format: RemoteDesktopPixelFormat::Rgba8888, - payload: pixels, - }) - } - - fn full_frame_bytes(&self, sequence: u64) -> AppResult> { - self.patch_bytes( - sequence, - Rect { - x: 0, - y: 0, - width: self.width, - height: self.height, - }, - &self.rgba, - ) +fn context(app: &AppHandle) -> VncContext { + let events_app = app.clone(); + let transport_app = app.clone(); + VncContext { + events: Arc::new(move |target, event, payload| { + let result = if let Some(target) = target { + events_app.emit_to(target, event, payload) + } else { + events_app.emit(event, payload) + }; + result.map_err(|e| AppError::Channel(e.to_string())) + }), + transport: Arc::new(move |host, port, network, owner| { + let app = transport_app.clone(); + Box::pin(async move { + crate::core::network::open_tcp_transport(&app, &host, port, network.as_ref(), owner) + .await + }) + }), } } - -pub struct VncSession { - pub config: VncConnectConfig, - state: Mutex, - message: Mutex>, - error_kind: Mutex>, - generation: AtomicU64, - frame_sequence: AtomicU64, - frame_attach_id: AtomicU64, - frame_channel: Mutex)>>, - pending_frames: Mutex>>, - framebuffer: Mutex>, - command_sender: Mutex>>, - cancel_sender: Mutex>>, - worker: Mutex>>, - close_requested: AtomicBool, - trust_commit_guard: std::sync::Mutex<()>, - pending_server_keys: Arc>>, +pub fn load_saved_vnc_config( + app: &AppHandle, + id: &str, + owner: String, +) -> AppResult { + shared::load_saved_vnc_config(&context(app), id, owner) } - pub struct VncSessionManager { - sessions: Mutex>>, - pending_server_keys: Arc>>, + inner: Arc, +} +impl Default for VncSessionManager { + fn default() -> Self { + Self::new() + } } - impl VncSessionManager { pub fn new() -> Self { Self { - sessions: Mutex::new(HashMap::new()), - pending_server_keys: Arc::new(Mutex::new(HashMap::new())), + inner: Arc::new(shared::VncSessionManager::new()), } } - pub async fn create_session( self: &Arc, app: AppHandle, @@ -251,1457 +61,45 @@ impl VncSessionManager { "VNC session requires an owner main window".into(), )); } - let session_id = config.session_id.clone(); - let session = Arc::new(VncSession { - config, - state: Mutex::new(VncSessionState::Connecting), - message: Mutex::new(None), - error_kind: Mutex::new(None), - generation: AtomicU64::new(0), - frame_sequence: AtomicU64::new(0), - frame_attach_id: AtomicU64::new(0), - frame_channel: Mutex::new(None), - pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), - framebuffer: Mutex::new(None), - command_sender: Mutex::new(None), - cancel_sender: Mutex::new(None), - worker: Mutex::new(None), - close_requested: AtomicBool::new(false), - trust_commit_guard: std::sync::Mutex::new(()), - pending_server_keys: self.pending_server_keys.clone(), - }); - self.sessions - .lock() - .await - .insert(session_id.clone(), session.clone()); - emit_state(&app, &session, VncSessionState::Connecting, None, None); - tracing::info!( - session_id = %session_id, - connection_id = %session.config.connection_id, - name = %session.config.name, - scale_mode = %session.config.scale_mode, - "Created VNC session" - ); - spawn_session_worker(app.clone(), session).await; - let _ = app.emit("sessions-changed", ()); - Ok(session_id) + self.inner.create_session(context(&app), config).await } - pub async fn attach_frame_channel( &self, app: &AppHandle, - session_id: &str, + id: &str, channel: Channel, ) -> AppResult<()> { - let session = self.get(session_id).await?; - let attach_id = session.frame_attach_id.fetch_add(1, Ordering::AcqRel) + 1; - *session.frame_channel.lock().await = Some((attach_id, channel)); - flush_pending_frames(&session, attach_id).await; - send_full_frame(&session, Some(attach_id)).await?; - let _ = send_worker_command(&session, VncWorkerCommand::FullRefresh).await; - replay_state(app, &session).await; - Ok(()) - } - - pub async fn send_input(&self, session_id: &str, events: Vec) -> AppResult<()> { - let session = self.get(session_id).await?; - if session.config.view_only { - return Err(AppError::Config( - "VNC input is disabled for a view-only session".to_string(), - )); - } - if events.len() > MAX_INPUT_BATCH { - return Err(AppError::Config(format!( - "VNC input batch exceeds {MAX_INPUT_BATCH} events" - ))); - } - let sender = session - .command_sender - .lock() + self.inner + .attach_frame_channel( + &context(app), + id, + FrameChannel(Arc::new(move |frame| { + channel + .send(InvokeResponseBody::Raw(frame)) + .map_err(|e| AppError::Channel(e.to_string())) + })), + ) .await - .clone() - .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; - for event in events { - sender - .send(VncWorkerCommand::Input(event)) - .await - .map_err(|_| AppError::Channel("VNC worker channel closed".to_string()))?; - } - Ok(()) } - - pub async fn set_clipboard_text(&self, session_id: &str, text: String) -> AppResult<()> { - let session = self.get(session_id).await?; - if session.config.view_only || !session.config.clipboard_enabled { - return Err(AppError::Config( - "VNC clipboard sending is disabled".to_string(), - )); - } - if text.len() > MAX_VNC_CLIPBOARD_BYTES || !text.chars().all(|ch| u32::from(ch) <= 0xff) { - return Err(AppError::Config( - "VNC clipboard text must be Latin-1 and no larger than 1 MiB".to_string(), - )); - } - send_worker_command(&session, VncWorkerCommand::Clipboard(text)).await + pub async fn detach_frame_channel(&self, id: &str) -> AppResult<()> { + self.inner.detach_frame_channel(id).await } - - pub async fn reconnect(self: &Arc, app: AppHandle, session_id: &str) -> AppResult<()> { - let session = self.get(session_id).await?; - invalidate_vnc_generation(&session, false); - stop_worker(&session).await; - self.cancel_pending_server_keys_for_session(app.clone(), session_id) - .await; - session.close_requested.store(false, Ordering::Release); - *session.framebuffer.lock().await = None; - session.pending_frames.lock().await.clear(); - session.frame_sequence.store(0, Ordering::Release); - emit_state(&app, &session, VncSessionState::Reconnecting, None, None); - spawn_session_worker(app, session).await; - Ok(()) + pub async fn send_input(&self, id: &str, events: Vec) -> AppResult<()> { + self.inner.send_input(id, events).await } - - pub async fn close(&self, app: &AppHandle, session_id: &str) -> AppResult<()> { - let Some(session) = self.sessions.lock().await.remove(session_id) else { - return Ok(()); - }; - invalidate_vnc_generation(&session, true); - stop_worker(&session).await; - self.cancel_pending_server_keys_for_session(app.clone(), session_id) - .await; - session.frame_attach_id.fetch_add(1, Ordering::AcqRel); - *session.frame_channel.lock().await = None; - session.pending_frames.lock().await.clear(); - *session.framebuffer.lock().await = None; - set_state(&session, VncSessionState::Disconnected, None, None).await; - emit_state(app, &session, VncSessionState::Disconnected, None, None); - let _ = app.emit("sessions-changed", ()); - Ok(()) + pub async fn set_clipboard_text(&self, id: &str, text: String) -> AppResult<()> { + self.inner.set_clipboard_text(id, text).await } - - pub async fn respond_server_key(&self, request_id: &str, accepted: bool) -> AppResult<()> { - let Some(pending) = self.pending_server_keys.lock().await.remove(request_id) else { - return Err(AppError::Cancelled(format!( - "No pending VNC server key request with id '{request_id}'" - ))); - }; - let session = self.get(&pending.session_id).await?; - if session.generation.load(Ordering::Acquire) != pending.generation - || session.close_requested.load(Ordering::Acquire) - { - return Ok(()); - } - let _ = pending.responder.send(accepted); - Ok(()) + pub async fn reconnect(self: &Arc, app: AppHandle, id: &str) -> AppResult<()> { + self.inner.reconnect(context(&app), id).await } - - async fn cancel_pending_server_keys_for_session(&self, app: AppHandle, session_id: &str) { - let mut pending = self.pending_server_keys.lock().await; - let request_ids = pending - .iter() - .filter(|(_, request)| request.session_id == session_id) - .map(|(request_id, _)| request_id.clone()) - .collect::>(); - for request_id in request_ids { - if let Some(request) = pending.remove(&request_id) { - let _ = request.responder.send(false); - let _ = app.emit( - "vnc-server-key-verify-resolved", - serde_json::json!({"requestId": request_id}), - ); - } - } + pub async fn respond_server_key(&self, id: &str, accepted: bool) -> AppResult<()> { + self.inner.respond_server_key(id, accepted).await + } + pub async fn close(&self, app: &AppHandle, id: &str) -> AppResult<()> { + self.inner.close(&context(app), id).await } - pub async fn close_all(&self, app: &AppHandle) { - let ids = self - .sessions - .lock() - .await - .keys() - .cloned() - .collect::>(); - for id in ids { - if let Err(error) = self.close(app, &id).await { - tracing::warn!(session_id = %id, "Failed to close VNC session: {error}"); - } - } - } - - async fn get(&self, session_id: &str) -> AppResult> { - self.sessions - .lock() - .await - .get(session_id) - .cloned() - .ok_or_else(|| { - AppError::SessionNotFound(format!("VNC session '{session_id}' not found")) - }) - } -} - -impl Default for VncSessionManager { - fn default() -> Self { - Self::new() - } -} - -async fn spawn_session_worker(app: AppHandle, session: Arc) { - let (cancel_tx, cancel_rx) = watch::channel(false); - *session.cancel_sender.lock().await = Some(cancel_tx); - let worker_session = session.clone(); - let handle = tauri::async_runtime::spawn(async move { - run_session_worker(app, worker_session, cancel_rx).await; - }); - *session.worker.lock().await = Some(handle); -} - -async fn run_session_worker( - app: AppHandle, - session: Arc, - mut cancel_rx: watch::Receiver, -) { - let mut attempt = 0_u32; - loop { - if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { - return; - } - let generation = session.generation.fetch_add(1, Ordering::AcqRel) + 1; - let state = if attempt == 0 { - VncSessionState::Connecting - } else { - VncSessionState::Reconnecting - }; - set_state(&session, state.clone(), None, None).await; - emit_state(&app, &session, state, None, None); - - match run_protocol_generation(&app, &session, generation, &mut cancel_rx).await { - Ok(()) => return, - Err((kind, message, retryable)) => { - if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { - return; - } - if !retryable - || !session.config.reconnect_enabled - || attempt >= session.config.reconnect_max_attempts - { - set_state( - &session, - VncSessionState::Failed, - Some(message.clone()), - Some(kind), - ) - .await; - emit_state( - &app, - &session, - VncSessionState::Failed, - Some(message), - Some(kind), - ); - let _ = app.emit("sessions-changed", ()); - return; - } - attempt += 1; - let delay = reconnect_delay(attempt); - let sleep = tokio::time::sleep(delay); - tokio::pin!(sleep); - tokio::select! { - _ = &mut sleep => {}, - changed = cancel_rx.changed() => { - if changed.is_err() || *cancel_rx.borrow() { return; } - } - } - } - } - } -} - -async fn run_protocol_generation( - app: &AppHandle, - session: &Arc, - generation: u64, - cancel_rx: &mut watch::Receiver, -) -> Result<(), (VncErrorKind, String, bool)> { - let (host, port) = vnc_connect_target(&session.config); - let transport = tokio::select! { - result = timeout(CONNECT_TIMEOUT, open_tcp_transport( - app, - host, - port, - session.config.network.as_ref(), - Some(session.config.owner_window_label.clone()), - )) => { - result.map_err(|_| (VncErrorKind::Transport, "VNC connection timed out".to_string(), true))? - .map_err(|error| (VncErrorKind::Transport, format!("Unable to connect to the VNC server: {error}"), true))? - } - changed = cancel_rx.changed() => { - let _ = changed; - return Ok(()); - } - }; - let stream = transport.stream; - - set_state(session, VncSessionState::Authenticating, None, None).await; - emit_state(app, session, VncSessionState::Authenticating, None, None); - let password = Zeroizing::new(session.config.password.clone().unwrap_or_default()); - if session.config.security_mode == "vnc-auth" && password.len() > 8 { - return Err(( - VncErrorKind::Authentication, - "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), - false, - )); - } - - let limits = VncLimits { - max_framebuffer_width: MAX_FRAMEBUFFER_WIDTH, - max_framebuffer_height: MAX_FRAMEBUFFER_HEIGHT, - max_framebuffer_pixels: usize::from(MAX_FRAMEBUFFER_WIDTH) - * usize::from(MAX_FRAMEBUFFER_HEIGHT), - max_clipboard_bytes: MAX_VNC_CLIPBOARD_BYTES, - max_rectangles_per_update: 1024, - max_encoded_payload_bytes: 64 * 1024 * 1024, - max_decoded_payload_bytes: usize::from(MAX_FRAMEBUFFER_WIDTH) - * usize::from(MAX_FRAMEBUFFER_HEIGHT) - * 4, - channel_capacity: 32, - ..VncLimits::default() - }; - let auth_password = password.to_string(); - let has_credentials = session.config.password.is_some(); - let handshake_timeout = if session.config.security_mode == "auto" && has_credentials { - RA2_HANDSHAKE_TIMEOUT - } else { - HANDSHAKE_TIMEOUT - }; - let trust_candidate = Arc::new(std::sync::Mutex::new(None::)); - let verifier_trust_candidate = trust_candidate.clone(); - let authenticated_key = Arc::new(std::sync::Mutex::new(None::)); - let notified_key = authenticated_key.clone(); - let verifier_app = app.clone(); - let verifier_session = session.clone(); - let state = VncConnector::new(stream) - .set_auth_method(async move { Ok(auth_password) }) - .set_credentials_available(has_credentials) - .set_username(session.config.username.clone()) - .set_server_key_verifier(move |server_key| { - let app = verifier_app.clone(); - let session = verifier_session.clone(); - let trust_candidate = verifier_trust_candidate.clone(); - async move { - if let Some(fingerprint) = - verify_vnc_server_key(&app, &session, generation, &server_key).await? - { - *trust_candidate.lock().expect("VNC trust candidate lock") = Some(fingerprint); - } - Ok(()) - } - }) - .set_server_key_authenticated(move |key| { - *notified_key.lock().expect("VNC authenticated key lock") = - Some(vnc_key_fingerprint(&key)); - }) - .set_security_policy(security_policy(&session.config.security_mode)) - .set_pixel_format(PixelFormat::rgba()) - .set_limits(limits) - .add_encoding(VncEncoding::DesktopSizePseudo) - .add_encoding(VncEncoding::Zrle) - .add_encoding(VncEncoding::Tight) - .add_encoding(VncEncoding::Raw) - .allow_shared(session.config.shared) - .build() - .map_err(classify_vnc_error)?; - let handshake_result = tokio::select! { - biased; - changed = cancel_rx.changed() => { let _ = changed; None }, - result = timeout(handshake_timeout, state.try_start()) => { Some(result) }, - }; - // Also clean up a prompt if the outer handshake timeout dropped its future. - resolve_vnc_prompts(app, session, generation).await; - let Some(handshake_result) = handshake_result else { - return Ok(()); - }; - let client = handshake_result - .map_err(|_| { - ( - VncErrorKind::Transport, - "VNC protocol negotiation timed out".to_string(), - true, - ) - })? - .and_then(|state| state.finish()) - .map_err(classify_vnc_error)?; - { - // Serialize the trust write with close/reconnect invalidation. No await - // separates the final generation/cancellation check from the storage write. - let _guard = session - .trust_commit_guard - .lock() - .expect("VNC trust commit lock"); - let trust_candidate = trust_candidate.lock().expect("VNC trust candidate lock"); - let authenticated_key = authenticated_key - .lock() - .expect("VNC authenticated key lock"); - commit_vnc_trust( - trust_candidate.as_deref(), - authenticated_key.as_deref(), - generation, - session.generation.load(Ordering::Acquire), - session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow(), - |fingerprint| { - crate::storage::upsert_vnc_known_host( - &session.config.host, - session.config.port, - fingerprint, - ) - }, - ) - .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; - } - - set_state(session, VncSessionState::Negotiating, None, None).await; - emit_state(app, session, VncSessionState::Negotiating, None, None); - let (command_tx, mut command_rx) = mpsc::channel(WORKER_COMMAND_CHANNEL_CAPACITY); - *session.command_sender.lock().await = Some(command_tx); - let mut pressed_keys = Vec::::new(); - let mut poll = tokio::time::interval(EVENT_POLL_INTERVAL); - poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut refresh_due = false; - let refresh_delay = tokio::time::sleep(Duration::from_secs(86_400)); - tokio::pin!(refresh_delay); - - loop { - if session.generation.load(Ordering::Acquire) != generation { - let _ = client.close().await; - return Ok(()); - } - tokio::select! { - changed = cancel_rx.changed() => { - let _ = changed; - release_pressed_keys(&client, &mut pressed_keys).await; - let _ = client.close().await; - return Ok(()); - } - _ = poll.tick() => { - loop { - match client.poll_event().await { - Ok(Some(event)) => { - handle_vnc_event(app, session, generation, event).await?; - refresh_due = true; - refresh_delay.as_mut().reset(tokio::time::Instant::now() + UPDATE_REQUEST_INTERVAL); - } - Ok(None) => break, - Err(error) => return Err(classify_vnc_error(error)), - } - } - } - _ = &mut refresh_delay, if refresh_due => { - client.input(X11Event::Refresh).await.map_err(classify_vnc_error)?; - refresh_due = false; - } - command = command_rx.recv() => { - let Some(command) = command else { - let _ = client.close().await; - return Ok(()); - }; - handle_worker_command(&client, command, &mut pressed_keys).await?; - } - } - } -} - -fn vnc_connect_target(config: &VncConnectConfig) -> (&str, u16) { - (config.host.as_str(), config.port) -} - -fn security_policy(mode: &str) -> VncSecurityPolicy { - match mode { - "none" => VncSecurityPolicy::NoneOnly, - "vnc-auth" => VncSecurityPolicy::VncAuthOnly, - _ => VncSecurityPolicy::Auto, - } -} - -fn vnc_key_fingerprint(key: &VncServerKey) -> String { - format!("SHA256:{}", hex::encode(Sha256::digest(key.encoded()))) -} - -fn invalidate_vnc_generation(session: &VncSession, closing: bool) { - let _guard = session - .trust_commit_guard - .lock() - .expect("VNC trust commit lock"); - session.generation.fetch_add(1, Ordering::AcqRel); - if closing { - session.close_requested.store(true, Ordering::Release); - } -} - -fn commit_vnc_trust( - trust_candidate: Option<&str>, - authenticated_key: Option<&str>, - generation: u64, - current_generation: u64, - cancelled: bool, - save: impl FnOnce(&str) -> AppResult<()>, -) -> AppResult<()> { - if generation == current_generation - && !cancelled - && let (Some(trust_candidate), Some(authenticated_key)) = - (trust_candidate, authenticated_key) - && trust_candidate == authenticated_key - { - save(trust_candidate)?; - } - Ok(()) -} - -async fn resolve_vnc_prompts(app: &AppHandle, session: &VncSession, generation: u64) { - let mut pending = session.pending_server_keys.lock().await; - let ids: Vec<_> = pending - .iter() - .filter(|(_, request)| { - request.session_id == session.config.session_id && request.generation == generation - }) - .map(|(id, _)| id.clone()) - .collect(); - for id in ids { - pending.remove(&id); - let _ = app.emit( - "vnc-server-key-verify-resolved", - serde_json::json!({"requestId": id}), - ); - } -} - -async fn verify_vnc_server_key( - app: &AppHandle, - session: &Arc, - generation: u64, - server_key: &VncServerKey, -) -> Result, VncError> { - if session.generation.load(Ordering::Acquire) != generation - || session.close_requested.load(Ordering::Acquire) - { - return Err(VncError::Ra2ServerKeyRejected( - "stale VNC connection generation".to_string(), - )); - } - - let fingerprint = vnc_key_fingerprint(server_key); - let known_host_status = crate::storage::check_vnc_known_host( - &session.config.host, - session.config.port, - &fingerprint, - ) - .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; - let confirmed_new_trust = confirm_vnc_trust( - known_host_status, - prompt_vnc_server_key( - app, - session, - generation, - server_key, - fingerprint.clone(), - known_host_status, - ), - ) - .await?; - Ok(confirmed_new_trust.then_some(fingerprint)) -} - -async fn confirm_vnc_trust( - known_host_status: crate::storage::KnownHostCheck, - prompt: impl std::future::Future>, -) -> Result { - if known_host_status == crate::storage::KnownHostCheck::Match { - return Ok(false); - } - prompt.await?; - Ok(true) -} - -async fn prompt_vnc_server_key( - app: &AppHandle, - session: &Arc, - generation: u64, - server_key: &VncServerKey, - fingerprint: String, - known_host_status: crate::storage::KnownHostCheck, -) -> Result<(), VncError> { - let request_id = uuid::Uuid::new_v4().to_string(); - let (tx, rx) = oneshot::channel(); - session.pending_server_keys.lock().await.insert( - request_id.clone(), - VncServerKeyPending { - session_id: session.config.session_id.clone(), - generation, - responder: tx, - }, - ); - let payload = VncServerKeyVerifyEvent { - request_id: request_id.clone(), - session_id: session.config.session_id.clone(), - host: session.config.host.clone(), - port: session.config.port, - fingerprint, - key_bits: server_key.bits(), - target_window_label: session.config.owner_window_label.clone(), - known_host_status: match known_host_status { - crate::storage::KnownHostCheck::Match => "match", - crate::storage::KnownHostCheck::HostSeen => "changed", - crate::storage::KnownHostCheck::UnknownHost => "unknown", - } - .to_string(), - }; - app.emit_to( - &session.config.owner_window_label, - "vnc-server-key-verify", - payload, - ) - .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; - - let response = timeout(SERVER_KEY_PROMPT_TIMEOUT, rx).await; - session.pending_server_keys.lock().await.remove(&request_id); - let _ = app.emit( - "vnc-server-key-verify-resolved", - serde_json::json!({"requestId": request_id}), - ); - let Ok(Ok(accepted)) = response else { - return Err(VncError::Ra2ServerKeyRejected( - "verification timed out".to_string(), - )); - }; - if session.generation.load(Ordering::Acquire) != generation - || session.close_requested.load(Ordering::Acquire) - { - return Err(VncError::Ra2ServerKeyRejected( - "stale VNC connection generation".to_string(), - )); - } - if !accepted { - return Err(VncError::Ra2ServerKeyRejected( - "user rejected the server key".to_string(), - )); - } - Ok(()) -} - -async fn handle_vnc_event( - app: &AppHandle, - session: &Arc, - generation: u64, - event: VncEvent, -) -> Result<(), (VncErrorKind, String, bool)> { - if session.generation.load(Ordering::Acquire) != generation { - return Ok(()); - } - match event { - VncEvent::SetResolution(Screen { width, height }) => { - let framebuffer = VncFramebuffer::new(width, height) - .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; - *session.framebuffer.lock().await = Some(framebuffer); - } - VncEvent::RawImage(rect, pixels) => { - let mut framebuffer_guard = session.framebuffer.lock().await; - if framebuffer_guard.is_none() { - let width = rect.x.checked_add(rect.width).ok_or_else(|| { - ( - VncErrorKind::Protocol, - "VNC rectangle bounds overflow".to_string(), - false, - ) - })?; - let height = rect.y.checked_add(rect.height).ok_or_else(|| { - ( - VncErrorKind::Protocol, - "VNC rectangle bounds overflow".to_string(), - false, - ) - })?; - *framebuffer_guard = Some( - VncFramebuffer::new(width, height) - .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?, - ); - } - let framebuffer = framebuffer_guard.as_mut().expect("framebuffer initialized"); - framebuffer - .apply_rgba(rect, &pixels) - .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; - let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); - let frame = framebuffer - .patch_bytes(sequence, rect, &pixels) - .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; - drop(framebuffer_guard); - queue_or_send_frame(session, frame).await; - let was_active = matches!(*session.state.lock().await, VncSessionState::Active); - if !was_active { - set_state(session, VncSessionState::Active, None, None).await; - emit_state(app, session, VncSessionState::Active, None, None); - let _ = app.emit("sessions-changed", ()); - } - } - VncEvent::Text(text) => { - if session.config.clipboard_enabled && text.len() <= MAX_VNC_CLIPBOARD_BYTES { - let _ = app.emit( - format!("vnc-clipboard-{}", session.config.session_id).as_str(), - VncClipboardEvent { - session_id: session.config.session_id.clone(), - text, - }, - ); - } - } - VncEvent::Error(message) => { - return Err(classify_vnc_event_error(message)); - } - VncEvent::JpegImage(_, _) => { - return Err(( - VncErrorKind::Encoding, - "The server sent a Tight JPEG event instead of decoded RGBA pixels".to_string(), - false, - )); - } - VncEvent::Copy(_, _) | VncEvent::SetCursor(_, _) => { - return Err(( - VncErrorKind::Encoding, - "The server sent an unrequested VNC encoding".to_string(), - false, - )); - } - VncEvent::SetPixelFormat(_) | VncEvent::Bell => {} - _ => {} - } - Ok(()) -} - -fn classify_vnc_event_error(message: String) -> (VncErrorKind, String, bool) { - let lower = message.to_ascii_lowercase(); - let kind = if lower.contains("image") - || lower.contains("encoding") - || lower.contains("zrle") - || lower.contains("tight") - || lower.contains("jpeg") - { - VncErrorKind::Encoding - } else { - VncErrorKind::Protocol - }; - (kind, message, false) -} - -async fn handle_worker_command( - client: &VncClient, - command: VncWorkerCommand, - pressed_keys: &mut Vec, -) -> Result<(), (VncErrorKind, String, bool)> { - match command { - VncWorkerCommand::Input(input) => send_vnc_input(client, input, pressed_keys).await, - VncWorkerCommand::Clipboard(text) => { - client - .input(X11Event::CopyText(text)) - .await - .map_err(|error| { - let (_, message, retryable) = classify_vnc_error(error); - (VncErrorKind::Clipboard, message, retryable) - }) - } - VncWorkerCommand::FullRefresh => client - .input(X11Event::FullRefresh) - .await - .map_err(classify_vnc_error), - } -} - -async fn send_vnc_input( - client: &VncClient, - input: VncInputEvent, - pressed_keys: &mut Vec, -) -> Result<(), (VncErrorKind, String, bool)> { - match input { - VncInputEvent::Key { keysym, pressed } => { - if pressed { - if !pressed_keys.contains(&keysym) { - pressed_keys.push(keysym); - } - } else { - pressed_keys.retain(|key| *key != keysym); - } - client - .input(X11Event::KeyEvent(ClientKeyEvent { - keycode: keysym, - down: pressed, - })) - .await - .map_err(classify_vnc_error)?; - } - VncInputEvent::Pointer { x, y, button_mask } => { - client - .input(X11Event::PointerEvent(ClientMouseEvent { - position_x: x, - position_y: y, - bottons: button_mask, - })) - .await - .map_err(classify_vnc_error)?; - } - VncInputEvent::ReleaseAllKeys => release_pressed_keys(client, pressed_keys).await, - } - Ok(()) -} - -async fn release_pressed_keys(client: &VncClient, pressed_keys: &mut Vec) { - for keysym in pressed_keys.drain(..) { - let _ = client - .input(X11Event::KeyEvent(ClientKeyEvent { - keycode: keysym, - down: false, - })) - .await; - } -} - -async fn send_worker_command( - session: &Arc, - command: VncWorkerCommand, -) -> AppResult<()> { - let sender = session - .command_sender - .lock() - .await - .clone() - .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; - sender - .send(command) - .await - .map_err(|_| AppError::Channel("VNC worker channel closed".to_string())) -} - -async fn stop_worker(session: &Arc) { - if let Some(cancel) = session.cancel_sender.lock().await.take() { - let _ = cancel.send(true); - } - *session.command_sender.lock().await = None; - if let Some(mut worker) = session.worker.lock().await.take() { - if timeout(WORKER_SHUTDOWN_TIMEOUT, &mut worker).await.is_err() { - worker.abort(); - let _ = worker.await; - } - } -} - -async fn queue_or_send_frame(session: &Arc, frame: Vec) { - let attach_id = session.frame_attach_id.load(Ordering::Acquire); - let sent = { - let mut channel = session.frame_channel.lock().await; - match channel.as_ref() { - Some((current_attach_id, sender)) if *current_attach_id == attach_id => { - if sender.send(InvokeResponseBody::Raw(frame.clone())).is_ok() { - true - } else { - if channel.as_ref().is_some_and(|(id, _)| *id == attach_id) { - *channel = None; - } - false - } - } - _ => false, - } - }; - if sent { - return; - } - - let mut pending = session.pending_frames.lock().await; - while pending.len() >= MAX_PENDING_FRAMES { - pending.pop_front(); - } - pending.push_back(frame); -} - -async fn flush_pending_frames(session: &Arc, attach_id: u64) { - let channel = session.frame_channel.lock().await; - let Some((current_attach_id, channel)) = channel.as_ref() else { - return; - }; - if *current_attach_id != attach_id { - return; - } - let mut pending = session.pending_frames.lock().await; - while let Some(frame) = pending.pop_front() { - if channel.send(InvokeResponseBody::Raw(frame)).is_err() { - break; - } - } -} - -async fn send_full_frame(session: &Arc, attach_id: Option) -> AppResult<()> { - let frame = { - let framebuffer = session.framebuffer.lock().await; - let Some(framebuffer) = framebuffer.as_ref() else { - return Ok(()); - }; - let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); - framebuffer.full_frame_bytes(sequence)? - }; - if let Some(expected_attach_id) = attach_id { - let mut channel = session.frame_channel.lock().await; - let failed = channel - .as_ref() - .filter(|(current_attach_id, _)| *current_attach_id == expected_attach_id) - .is_some_and(|(_, channel)| { - channel - .send(InvokeResponseBody::Raw(frame.clone())) - .is_err() - }); - if failed - && channel - .as_ref() - .is_some_and(|(current_attach_id, _)| *current_attach_id == expected_attach_id) - { - *channel = None; - } - } else { - queue_or_send_frame(session, frame).await; - } - Ok(()) -} - -async fn set_state( - session: &VncSession, - state: VncSessionState, - message: Option, - error_kind: Option, -) { - *session.state.lock().await = state; - *session.message.lock().await = message; - *session.error_kind.lock().await = error_kind; -} - -fn emit_state( - app: &AppHandle, - session: &VncSession, - state: VncSessionState, - message: Option, - error_kind: Option, -) { - let _ = app.emit( - format!("vnc-state-{}", session.config.session_id).as_str(), - VncStateEvent { - session_id: session.config.session_id.clone(), - state, - message, - error_kind, - }, - ); -} - -async fn replay_state(app: &AppHandle, session: &VncSession) { - emit_state( - app, - session, - session.state.lock().await.clone(), - session.message.lock().await.clone(), - *session.error_kind.lock().await, - ); -} - -fn classify_vnc_error(error: VncError) -> (VncErrorKind, String, bool) { - match error { - VncError::NoPassword | VncError::WrongPassword => ( - VncErrorKind::Authentication, - "VNC authentication failed".to_string(), - false, - ), - VncError::CredentialTooLong { .. } | VncError::Ra2ServerKeyRejected(_) => { - (VncErrorKind::Authentication, error.to_string(), false) - } - VncError::UnsupportedSecurityType - | VncError::RequiredSecurityTypeUnavailable(_) - | VncError::InvalidSecurityType(_) => ( - VncErrorKind::Authentication, - format!( - "The VNC server requires an unsupported security type. Currently supported: None, VNC Authentication, and RA2_256 in Auto mode. Details: {error}" - ), - false, - ), - VncError::InvalidEncoding(_) | VncError::InvalidImageData => { - (VncErrorKind::Encoding, error.to_string(), false) - } - VncError::IoError(ref source) if source.kind() == std::io::ErrorKind::InvalidData => { - (VncErrorKind::Protocol, error.to_string(), false) - } - VncError::IoError(_) => (VncErrorKind::Transport, error.to_string(), true), - VncError::LimitExceeded { .. } - | VncError::InvalidDimensions - | VncError::IntegerOverflow(_) - | VncError::WrongPixelFormat - | VncError::WrongServerMessage - | VncError::InvalidSecurityResult(_) - | VncError::SecurityFailure(_) - | VncError::InvalidRa2KeyLength { .. } - | VncError::InvalidRa2PublicKey - | VncError::InvalidRa2EncryptedRandomLength { .. } - | VncError::InvalidRa2RandomLength(_) - | VncError::Ra2ServerHashMismatch - | VncError::InvalidRa2Subtype(_) - | VncError::Ra2ServerKeyVerifierRequired - | VncError::InvalidRa2RecordLimit(_) - | VncError::Ra2Crypto(_) => (VncErrorKind::Protocol, error.to_string(), false), - _ => (VncErrorKind::Internal, error.to_string(), true), - } -} - -fn reconnect_delay(attempt: u32) -> Duration { - Duration::from_secs(match attempt { - 0 | 1 => 1, - 2 => 2, - 3 => 4, - 4 => 8, - 5 => 15, - _ => 30, - }) -} - -fn validate_framebuffer_dimensions(width: u16, height: u16) -> AppResult<()> { - if width == 0 || height == 0 || width > MAX_FRAMEBUFFER_WIDTH || height > MAX_FRAMEBUFFER_HEIGHT - { - return Err(AppError::Config(format!( - "VNC framebuffer {width}x{height} is outside the supported range" - ))); - } - Ok(()) -} - -fn validate_rectangle(rect: Rect, desktop_width: u16, desktop_height: u16) -> AppResult<()> { - let right = rect - .x - .checked_add(rect.width) - .ok_or_else(|| AppError::Config("VNC rectangle horizontal bounds overflow".to_string()))?; - let bottom = rect - .y - .checked_add(rect.height) - .ok_or_else(|| AppError::Config("VNC rectangle vertical bounds overflow".to_string()))?; - if rect.width == 0 || rect.height == 0 || right > desktop_width || bottom > desktop_height { - return Err(AppError::Config( - "VNC rectangle exceeds framebuffer bounds".to_string(), - )); - } - Ok(()) -} - -pub fn load_saved_vnc_config( - app: &AppHandle, - connection_id: &str, - owner_window_label: String, -) -> AppResult { - let connection = config::load_connection_by_id(app, connection_id)?; - let network = connection.network.clone(); - let password = resolve_vnc_password(app, connection.auth.as_ref())?; - let ConnectionType::Vnc { - host, - port, - username, - security, - display, - clipboard, - reconnect, - shared, - view_only, - } = connection.config - else { - return Err(AppError::Config( - "Connection is not a VNC connection".to_string(), - )); - }; - if matches!(security.mode.as_str(), "vnc-auth") && password.is_none() { - return Err(AppError::Config( - "VNC Authentication requires a password".to_string(), - )); - } - if security.mode == "vnc-auth" - && let Some(password) = password.as_ref() - && password.len() > 8 - { - return Err(AppError::Config( - "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), - )); - } - - Ok(VncConnectConfig { - owner_window_label, - session_id: uuid::Uuid::new_v4().to_string(), - connection_id: connection_id.to_string(), - name: connection.name, - host, - port, - username, - password, - security_mode: security.mode, - scale_mode: display.scale_mode, - clipboard_enabled: clipboard.enabled, - reconnect_enabled: reconnect.enabled, - reconnect_max_attempts: reconnect.max_attempts, - shared, - view_only, - network, - }) -} - -fn resolve_vnc_password( - app: &AppHandle, - auth: Option<&ConnectionAuth>, -) -> AppResult> { - let Some(auth) = auth else { - return Ok(None); - }; - if auth.mode != "password" { - return Ok(None); - } - if let Some(password_id) = auth.password_id.as_deref().filter(|id| !id.is_empty()) { - let account = config::load_saved_account(app, None, Some(password_id))?; - return config::decrypt_account_password(account.as_ref()); - } - crate::utils::crypto::decrypt_optional(&auth.password) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn framebuffer_rejects_oversized_and_out_of_bounds_data() { - assert!(VncFramebuffer::new(7681, 1).is_err()); - let mut framebuffer = VncFramebuffer::new(10, 10).expect("framebuffer"); - assert!( - framebuffer - .apply_rgba( - Rect { - x: 10, - y: 0, - width: 1, - height: 1, - }, - &[0; 4] - ) - .is_err() - ); - } - - #[test] - fn framebuffer_applies_patches_and_builds_full_refreshes() { - let mut framebuffer = VncFramebuffer::new(2, 2).expect("framebuffer"); - framebuffer - .apply_rgba( - Rect { - x: 1, - y: 1, - width: 1, - height: 1, - }, - &[1, 2, 3, 255], - ) - .expect("patch"); - let frame = framebuffer.full_frame_bytes(7).expect("full frame"); - assert_eq!(&frame[0..8], &7_u64.to_le_bytes()); - assert_eq!(&frame[40..44], &16_u32.to_le_bytes()); - assert_eq!(&frame[56..60], &[1, 2, 3, 255]); - } - - #[test] - fn reconnect_delay_is_bounded() { - assert_eq!(reconnect_delay(1), Duration::from_secs(1)); - assert_eq!(reconnect_delay(100), Duration::from_secs(30)); - } - - #[tokio::test] - async fn vnc_tofu_commits_only_confirmed_successful_current_handshakes() { - use crate::storage::{KnownHostCheck, Storage}; - for (name, old_key, accepted, authenticated, current_generation, cancelled, expected) in [ - ( - "unknown-success", - None, - true, - true, - 1, - false, - KnownHostCheck::Match, - ), - ( - "unknown-reject", - None, - false, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "hash-mismatch", - None, - true, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "transport-error", - None, - true, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "timeout", - None, - true, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "changed-success", - Some("SHA256:old"), - true, - true, - 1, - false, - KnownHostCheck::Match, - ), - ( - "changed-reject", - Some("SHA256:old"), - false, - false, - 1, - false, - KnownHostCheck::HostSeen, - ), - ( - "stale-generation", - None, - true, - true, - 2, - false, - KnownHostCheck::UnknownHost, - ), - ( - "cancelled", - None, - true, - true, - 1, - true, - KnownHostCheck::UnknownHost, - ), - ] { - let dir = - std::env::temp_dir().join(format!("vnc-tofu-{name}-{}", uuid::Uuid::new_v4())); - let storage = Storage::open(&dir).unwrap(); - if let Some(old_key) = old_key { - storage - .upsert_vnc_known_host("pi.local", 5900, old_key) - .unwrap(); - } - let status = storage - .check_vnc_known_host("pi.local", 5900, "SHA256:new") - .unwrap(); - let trust_candidate = match confirm_vnc_trust(status, async { - if accepted { - Ok(()) - } else { - Err(VncError::Ra2ServerKeyRejected("rejected".into())) - } - }) - .await - { - Ok(true) => Some("SHA256:new"), - Ok(false) | Err(_) => None, - }; - let authenticated_key = authenticated.then_some("SHA256:new"); - commit_vnc_trust( - trust_candidate, - authenticated_key, - 1, - current_generation, - cancelled, - |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), - ) - .unwrap(); - assert_eq!( - storage - .check_vnc_known_host("pi.local", 5900, "SHA256:new") - .unwrap(), - expected, - "{name}" - ); - if old_key.is_some() && expected != KnownHostCheck::Match { - assert_eq!( - storage - .check_vnc_known_host("pi.local", 5900, "SHA256:old") - .unwrap(), - KnownHostCheck::Match - ); - } - drop(storage); - std::fs::remove_dir_all(dir).unwrap(); - } - } - - #[tokio::test] - async fn matching_vnc_key_does_not_poll_user_confirmation() { - assert!( - !confirm_vnc_trust(crate::storage::KnownHostCheck::Match, async { - panic!("matching key must never prompt"); - }) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn matching_vnc_key_cannot_overwrite_newer_explicit_trust() { - use crate::storage::{KnownHostCheck, Storage}; - - let dir = std::env::temp_dir().join(format!("vnc-tofu-race-{}", uuid::Uuid::new_v4())); - let storage = Storage::open(&dir).unwrap(); - storage - .upsert_vnc_known_host("pi.local", 5900, "SHA256:k1") - .unwrap(); - - let status = storage - .check_vnc_known_host("pi.local", 5900, "SHA256:k1") - .unwrap(); - let trust_candidate = confirm_vnc_trust(status, async { - panic!("matching key must never prompt"); - }) - .await - .unwrap() - .then_some("SHA256:k1"); - assert!(trust_candidate.is_none()); - - storage - .upsert_vnc_known_host("pi.local", 5900, "SHA256:k2") - .unwrap(); - - commit_vnc_trust( - trust_candidate, - Some("SHA256:k1"), - 1, - 1, - false, - |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), - ) - .unwrap(); - - assert_eq!( - storage - .check_vnc_known_host("pi.local", 5900, "SHA256:k2") - .unwrap(), - KnownHostCheck::Match - ); - drop(storage); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn vnc_verification_payload_routes_to_its_session_owner() { - let payload = VncServerKeyVerifyEvent { - request_id: "request".into(), - session_id: "session".into(), - host: "pi.local".into(), - port: 5900, - fingerprint: "key".into(), - key_bits: 1024, - known_host_status: "unknown".into(), - target_window_label: "main-second".into(), - }; - assert_eq!( - serde_json::to_value(payload).unwrap()["targetWindowLabel"], - "main-second" - ); - } - - #[test] - fn security_mode_maps_to_fail_closed_policy() { - assert_eq!(security_policy("none"), VncSecurityPolicy::NoneOnly); - assert_eq!(security_policy("vnc-auth"), VncSecurityPolicy::VncAuthOnly); - assert_eq!(security_policy("auto"), VncSecurityPolicy::Auto); - } - - #[test] - fn connect_target_preserves_ipv6_literals_without_host_port_concatenation() { - let config = VncConnectConfig { - owner_window_label: "main-test".into(), - session_id: "vnc-test".to_string(), - connection_id: "connection-test".to_string(), - name: "IPv6 VNC".to_string(), - host: "::1".to_string(), - port: 5900, - username: String::new(), - password: None, - security_mode: "none".to_string(), - scale_mode: "fit".to_string(), - clipboard_enabled: true, - reconnect_enabled: false, - reconnect_max_attempts: 0, - shared: true, - view_only: false, - network: None, - }; - - assert_eq!(vnc_connect_target(&config), ("::1", 5900)); - } - - #[tokio::test] - async fn pending_frame_queue_keeps_latest_frames_under_pressure() { - let session = Arc::new(VncSession { - config: VncConnectConfig { - owner_window_label: "main-test".into(), - session_id: "vnc-test".to_string(), - connection_id: "connection-test".to_string(), - name: "Test VNC".to_string(), - host: "127.0.0.1".to_string(), - port: 5900, - username: String::new(), - password: None, - security_mode: "none".to_string(), - scale_mode: "fit".to_string(), - clipboard_enabled: true, - reconnect_enabled: false, - reconnect_max_attempts: 0, - shared: true, - view_only: false, - network: None, - }, - state: Mutex::new(VncSessionState::Connecting), - message: Mutex::new(None), - error_kind: Mutex::new(None), - generation: AtomicU64::new(0), - frame_sequence: AtomicU64::new(0), - frame_attach_id: AtomicU64::new(0), - frame_channel: Mutex::new(None), - pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), - framebuffer: Mutex::new(None), - command_sender: Mutex::new(None), - cancel_sender: Mutex::new(None), - worker: Mutex::new(None), - close_requested: AtomicBool::new(false), - trust_commit_guard: std::sync::Mutex::new(()), - pending_server_keys: Arc::new(Mutex::new(HashMap::new())), - }); - - queue_or_send_frame(&session, vec![1]).await; - queue_or_send_frame(&session, vec![2]).await; - queue_or_send_frame(&session, vec![3]).await; - - let pending = session.pending_frames.lock().await; - assert_eq!(pending.len(), MAX_PENDING_FRAMES); - assert_eq!(pending[0], vec![2]); - assert_eq!(pending[1], vec![3]); + self.inner.close_all(&context(app)).await } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e8d9f36..7db50334 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -271,6 +271,7 @@ pub fn run() { cmd::rdp::respond_rdp_certificate, cmd::vnc::create_vnc_session, cmd::vnc::vnc_attach_frame_channel, + cmd::vnc::vnc_detach_frame_channel, cmd::vnc::vnc_input_batch, cmd::vnc::vnc_set_clipboard_text, cmd::vnc::vnc_reconnect, diff --git a/src/App.tsx b/src/App.tsx index ee87c846..d8c2fe3a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2061,7 +2061,7 @@ function App() { toast.info(t("tabCtx.reconnecting")); try { - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_reconnecting_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2095,7 +2095,7 @@ function App() { return; } const errorMessage = getErrorMessage(error); - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_disconnected_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2161,7 +2161,7 @@ function App() { toast.info(t("tabCtx.reconnecting")); try { - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_reconnecting_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2195,7 +2195,7 @@ function App() { return; } const errorMessage = getErrorMessage(error); - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_disconnected_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2344,7 +2344,7 @@ function App() { } try { - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_reconnecting_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2377,7 +2377,7 @@ function App() { return; } const errorMessage = getErrorMessage(error); - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_disconnected_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); diff --git a/src/components/network/ProxyFormContent.tsx b/src/components/network/ProxyFormContent.tsx index a70bde50..50ec12b9 100644 --- a/src/components/network/ProxyFormContent.tsx +++ b/src/components/network/ProxyFormContent.tsx @@ -1,3 +1,4 @@ +import { runtime } from "@/lib/backend/runtime"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActionButton, ActionFooter } from "@/components/ui/action-footer"; @@ -81,6 +82,7 @@ export function ProxyFormContent({ }, [proxy]); const handleSubmit = async () => { + if (runtime === "web" && form.protocol === "proxycommand") { setError(t("web.proxyCommandUnavailable")); return; } if (!form.name.trim()) { setError(t("network.proxyNameRequired")); return; @@ -138,7 +140,7 @@ export function ProxyFormContent({ SOCKS5 HTTP - ProxyCommand + {runtime === "desktop" && ProxyCommand} diff --git a/src/components/panel/NetworkPanel.tsx b/src/components/panel/NetworkPanel.tsx index 14fe6890..2ad0bda0 100644 --- a/src/components/panel/NetworkPanel.tsx +++ b/src/components/panel/NetworkPanel.tsx @@ -1,3 +1,4 @@ +import { runtime } from "@/lib/backend/runtime"; import { randomUUID } from "@/lib/uuid"; import { listen } from "@/lib/backend/api"; import { @@ -564,7 +565,7 @@ export default function NetworkPanel() { const { t } = useTranslation(); const { appSettings, savedConnections, savedGroups, updateUi } = useApp(); const activeTab: NetworkTab = - appSettings.ui.network_panel_active_tab === "proxy" ? "proxy" : "tunnel"; + runtime === "web" || appSettings.ui.network_panel_active_tab === "proxy" ? "proxy" : "tunnel"; const [expandedSections, setExpandedSections] = useState>(new Set()); const [proxies, setProxies] = useState([]); @@ -643,6 +644,7 @@ export default function NetworkPanel() { }, []); const loadTunnelGroups = useCallback(async () => { + if (runtime === "web") return; try { const next = await invoke("get_tunnel_groups"); setTunnelGroups(next); @@ -654,13 +656,14 @@ export default function NetworkPanel() { const loadProxies = useCallback(async () => { try { const next = await invoke("get_proxies"); - setProxies(next); + setProxies(runtime === "web" ? next.filter((proxy) => proxy.protocol !== "proxycommand") : next); } catch (error) { toast.error(String(error)); } }, []); const loadTunnels = useCallback(async () => { + if (runtime === "web") return; try { const next = await invoke("get_tunnels"); setTunnels(next); @@ -670,6 +673,7 @@ export default function NetworkPanel() { }, []); const loadTunnelRuntimeStates = useCallback(async () => { + if (runtime === "web") return; try { const next = await invoke("get_tunnel_runtime_states"); setTunnelRuntimeStates(Object.fromEntries(next.map((state) => [state.tunnelId, state]))); @@ -850,10 +854,10 @@ export default function NetworkPanel() { }} className="w-full" > - - + + {runtime === "desktop" && {t("network.tunnels")} - + } {t("network.proxy")} diff --git a/src/components/sessions/ConnectionRecordingSettings.tsx b/src/components/sessions/ConnectionRecordingSettings.tsx index 1c1b2ea7..95fba367 100644 --- a/src/components/sessions/ConnectionRecordingSettings.tsx +++ b/src/components/sessions/ConnectionRecordingSettings.tsx @@ -1,3 +1,4 @@ +import { supports } from "@/lib/backend/runtime"; import { useTranslation } from "react-i18next"; import { Label } from "@/components/ui/label"; import { @@ -28,6 +29,7 @@ export function ConnectionRecordingSettings({ onModeChange, }: ConnectionRecordingSettingsProps) { const { t } = useTranslation(); + if (!supports("recording")) return null; return (
diff --git a/src/components/sessions/SessionNetworkSection.tsx b/src/components/sessions/SessionNetworkSection.tsx index 0ddcfa37..cdbdfa83 100644 --- a/src/components/sessions/SessionNetworkSection.tsx +++ b/src/components/sessions/SessionNetworkSection.tsx @@ -1,3 +1,4 @@ +import { runtime } from "@/lib/backend/runtime"; import { ChevronsUpDownIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -37,7 +38,7 @@ function ProxyCombobox({ }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const options = proxies.map((proxy) => ({ + const options = proxies.filter((proxy) => runtime === "desktop" || proxy.protocol !== "proxycommand").map((proxy) => ({ id: proxy.id, label: proxy.name, searchText: [proxy.name, proxy.protocol, proxy.host, proxy.port, proxy.username, proxy.command] diff --git a/src/components/sessions/SshForm.tsx b/src/components/sessions/SshForm.tsx index d256e21a..9ef7562d 100644 --- a/src/components/sessions/SshForm.tsx +++ b/src/components/sessions/SshForm.tsx @@ -1179,12 +1179,12 @@ export function SshForm({ diff --git a/src/components/sessions/TelnetForm.tsx b/src/components/sessions/TelnetForm.tsx index 39d49c9c..fbd01620 100644 --- a/src/components/sessions/TelnetForm.tsx +++ b/src/components/sessions/TelnetForm.tsx @@ -1,3 +1,6 @@ +import { SessionNetworkSection } from "@/components/sessions/SessionNetworkSection"; +import type { ConnectionOption } from "@/components/network/shared"; +import type { ProxyConfig } from "@/types/global"; import { getCurrentWindow } from "@/lib/backend/platform/window"; import { Eye, EyeOff } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -36,6 +39,7 @@ type TelnetEnterMode = "crlf" | "cr" | "lf"; type TelnetAuthMode = "none" | "password"; interface TelnetFormProps { + network?: { proxyId: string; setProxyId: (v: string) => void; proxies: ProxyConfig[]; jumpHostId: string; setJumpHostId: (v: string) => void; jumpHostOptions: ConnectionOption[] }; host: string; setHost: (v: string) => void; port: number; @@ -89,6 +93,7 @@ function RequiredMark() { } export function TelnetForm({ + network, host, setHost, port, @@ -583,6 +588,8 @@ export function TelnetForm({ + {network && } + (null); const imeRef = useRef(null); const pressedKeysRef = useRef(new Set()); @@ -152,18 +156,24 @@ function VncPaneHost({ }, [pane.sessionId]); useEffect(() => { - const channel = new Channel((frame) => { + let disposed = false; + const controller = new AbortController(); + let unsubscribe: (() => void) | undefined; + const onFrame = (frame: ArrayBuffer) => { + if (disposed) return; const patch = decodeRemoteDesktopFramePatch(frame); setDesktopSize({ width: patch.desktopWidth, height: patch.desktopHeight }); surfaceRef.current?.drawFrame(patch); - }); + }; if (!pane.connecting && !pane.connectError) { - void invoke("vnc_attach_frame_channel", { - sessionId: pane.sessionId, - frameChannel: channel, - }).catch(() => {}); + void subscribeVncFrames(pane.sessionId, onFrame, (error) => { + if (!disposed) setMessage(error.message); + }, controller.signal).then((stop) => { if (disposed) stop(); else unsubscribe = stop; }).catch((error: Error) => { + if (!disposed) { setState("failed"); setMessage(error.message); } + }); } + return () => { disposed = true; controller.abort(); unsubscribe?.(); }; }, [pane.connectError, pane.connecting, pane.sessionId]); useEffect(() => { @@ -184,7 +194,7 @@ function VncPaneHost({ const unlisten = listen(`vnc-clipboard-${pane.sessionId}`, (event) => { const text = event.payload.text; lastRemoteReceivedRef.current = text; - void writeClipboardText(text).catch(() => {}); + void writeClipboardText(text).catch(() => { if (runtime === "web") setClipboardUnavailable(true); }); }); return () => { void unlisten.then((dispose) => dispose()); @@ -212,7 +222,7 @@ function VncPaneHost({ ); } } catch { - /* clipboard access can be denied while the pane remains usable */ + if (runtime === "web") setClipboardUnavailable(true); } finally { polling = false; } @@ -381,6 +391,7 @@ function VncPaneHost({ }} >
+ {clipboardUnavailable && {t("web.clipboardUnavailable")}} {pane.name} {desktopSize.width > 0 && desktopSize.height > 0 ? ( diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx index 71bb6a20..86ad3a10 100644 --- a/src/context/AppProvider.tsx +++ b/src/context/AppProvider.tsx @@ -1,3 +1,4 @@ +import { runtime } from "@/lib/backend/runtime"; import { randomUUID } from "@/lib/uuid"; import { listen } from "@/lib/backend/api"; import { @@ -56,6 +57,7 @@ import type { Group, PaneSplitDirection, SavedConnection, + SessionInfo, SessionPane, SessionType, SyncGroup, @@ -1115,7 +1117,8 @@ export function AppProvider({ children }: { children: ReactNode }) { const pendingLockedStartupRestoreTabsRef = useRef(null); const restoreSessionsForTabs = useCallback( - (tabsToRestore: Tab[]) => { + async (tabsToRestore: Tab[]) => { + const reusable = runtime === "web" ? await invoke("list_sessions").catch(() => []) : []; const tasks: Promise[] = []; tabsToRestore.forEach((tab) => { const panes = collectSessionPanes(tab.root); @@ -1124,6 +1127,19 @@ export function AppProvider({ children }: { children: ReactNode }) { if (!hasPane(tab.id, pane.id)) return; const cid = pane.connectionId; + const live = reusable.find((session) => session.ready !== false && session.workspace_pane_id === pane.id && session.session_type === pane.type && (session.connection_id ?? undefined) === cid); + if (live) { + tasks.push((async () => { + // Wait for the previous page's socket to release its attachment. + for (let attempt = 0; live.attached && attempt < 120; attempt++) { + await new Promise((resolve) => window.setTimeout(resolve, 100)); + const info = await invoke("get_session_info", { sessionId: live.id }); + live.attached = info.attached; + } + await handleRestoredSessionCreated(tab.id, pane.id, live.id, cid); + })().catch((error) => handleRestoredSessionFailed(tab.id, pane.id, pane.type, cid, error))); + return; + } switch (pane.type) { case "SSH": if (!cid) { @@ -1190,6 +1206,7 @@ export function AppProvider({ children }: { children: ReactNode }) { ownerWindowLabel: getOwnerMainWindowLabel(), connectionId: cid, createRequestId: pane.createRequestId, + recordingScopeId: pane.id, }) .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) .catch((e) => diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ca820d64..2bd9732e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -3170,7 +3170,10 @@ "updateNow": "Update Now" }, "web": { + "clipboardUnavailable": "Clipboard access is unavailable. Use HTTPS and allow clipboard access in your browser.", "password": "Server password", + "proxyCommandUnavailable": "ProxyCommand is only available in the desktop app.", + "sessionTypesHint": "Web mode supports SSH, Telnet, and VNC. Use the desktop app for local terminals, serial, and RDP.", "signIn": "Sign in", "signInFailed": "Unable to sign in. Check your password and server connection." }, diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index ae6e3dc6..287603cc 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -3162,7 +3162,10 @@ "updateNow": "지금 업데이트" }, "web": { + "clipboardUnavailable": "클립보드에 접근할 수 없습니다. HTTPS를 사용하고 브라우저의 클립보드 접근을 허용하세요.", "password": "서버 비밀번호", + "proxyCommandUnavailable": "ProxyCommand는 데스크톱 앱에서만 사용할 수 있습니다.", + "sessionTypesHint": "웹 모드는 SSH, Telnet 및 VNC를 지원합니다. 로컬 터미널, 직렬 포트 및 RDP는 데스크톱 앱을 사용하세요.", "signIn": "로그인", "signInFailed": "로그인하지 못했습니다. 비밀번호와 서버 연결을 확인하세요." }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9d6cb239..523dc3d4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3168,7 +3168,10 @@ "updateNow": "立即更新" }, "web": { + "clipboardUnavailable": "无法访问剪贴板。请使用 HTTPS 并允许浏览器访问剪贴板。", "password": "服务端密码", + "proxyCommandUnavailable": "ProxyCommand 仅适用于桌面版。", + "sessionTypesHint": "Web 模式支持 SSH、Telnet 和 VNC。本地终端、串口和 RDP 请使用桌面版。", "signIn": "登录", "signInFailed": "登录失败,请检查密码与服务端连接。" }, diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 55a5dee6..8cdc3641 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -3163,7 +3163,10 @@ "updateNow": "立即更新" }, "web": { + "clipboardUnavailable": "無法存取剪貼簿。請使用 HTTPS 並允許瀏覽器存取剪貼簿。", "password": "伺服器密碼", + "proxyCommandUnavailable": "ProxyCommand 僅適用於桌面版。", + "sessionTypesHint": "Web 模式支援 SSH、Telnet 和 VNC。本機終端、序列埠和 RDP 請使用桌面版。", "signIn": "登入", "signInFailed": "登入失敗,請檢查密碼與伺服器連線。" }, diff --git a/src/lib/appSessionFactory.test.ts b/src/lib/appSessionFactory.test.ts index edade5cd..8c7322ec 100644 --- a/src/lib/appSessionFactory.test.ts +++ b/src/lib/appSessionFactory.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { setOwnerMainWindowLabel } from "./windowManager"; import type { SavedConnection, TerminalSessionPane } from "@/types/global"; -import { createSessionForConnection, createSessionForPane } from "./appSessionFactory"; +import { createTemporarySession, createSessionForConnection, createSessionForPane } from "./appSessionFactory"; const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); @@ -84,13 +84,24 @@ describe("VNC owner window routing", () => { expect(invokeMock).toHaveBeenNthCalledWith(1, "create_vnc_session", { connectionId: "pi", createRequestId: "create-request", + recordingScopeId: undefined, ownerWindowLabel: owner, }); expect(invokeMock).toHaveBeenNthCalledWith(2, "create_vnc_session", { connectionId: "pi", createRequestId: "recreate-request", + recordingScopeId: "pane", ownerWindowLabel: owner, }); }, ); }); + + +it("retains the temporary Telnet route and encoding when connecting and reconnecting", async () => { + const temporary = { protocol: "telnet" as const, name: "temporary", host: "host", port: 23, network: { proxy_id: "proxy", proxy_jump_id: "jump" }, encoding: "GBK" }; + await createTemporarySession(temporary, "temporary-create"); + await createSessionForPane({ id: "pane-telnet", type: "Telnet", temporaryConfig: temporary }, "temporary-reconnect"); + expect(invokeMock).toHaveBeenCalledWith("create_telnet_session", expect.objectContaining({ network: temporary.network, encoding: "GBK", createRequestId: "temporary-create" })); + expect(invokeMock).toHaveBeenCalledWith("create_telnet_session", expect.objectContaining({ network: temporary.network, encoding: "GBK", createRequestId: "temporary-reconnect", recordingScopeId: "pane-telnet" })); +}); diff --git a/src/lib/appSessionFactory.ts b/src/lib/appSessionFactory.ts index 940865c2..2d266ca7 100644 --- a/src/lib/appSessionFactory.ts +++ b/src/lib/appSessionFactory.ts @@ -130,6 +130,7 @@ export async function createSessionForConnection( ownerWindowLabel: getOwnerMainWindowLabel(), connectionId: connection.id, createRequestId, + recordingScopeId, }); case "rdp": return invoke("create_rdp_session", { @@ -160,6 +161,8 @@ export async function createTemporarySession( host: config.host, port: config.port, name: config.name, + ...(config.network ? { network: config.network } : {}), + ...(config.encoding ? { encoding: config.encoding } : {}), createRequestId, recordingScopeId, startupCommand: buildStartupCommandPayload(startupCommand), @@ -232,6 +235,8 @@ export function createSessionForPane( host: pane.temporaryConfig.host, port: pane.temporaryConfig.port, name: pane.temporaryConfig.name, + ...(pane.temporaryConfig.network ? { network: pane.temporaryConfig.network } : {}), + ...(pane.temporaryConfig.encoding ? { encoding: pane.temporaryConfig.encoding } : {}), createRequestId, recordingScopeId: pane.id, startupCommand: buildStartupCommandPayload(startupCommand), @@ -264,6 +269,7 @@ export function createSessionForPane( ownerWindowLabel: getOwnerMainWindowLabel(), connectionId: pane.connectionId, createRequestId, + recordingScopeId: pane.id, }); case "RDP": if (!pane.connectionId) throw new Error("Missing RDP connection id"); diff --git a/src/lib/backend/events.ts b/src/lib/backend/events.ts index e4ff5e8e..0659c69f 100644 --- a/src/lib/backend/events.ts +++ b/src/lib/backend/events.ts @@ -8,7 +8,7 @@ let channel: BroadcastChannel | undefined; export function deliver(event: string, payload: unknown): void { if ( !handlers.get(event)?.size && - /^(terminal-output-|session-closed-|connection-error-)/.test(event) + /^(terminal-output-|session-closed-|session-error-|connection-error-)/.test(event) ) { const bytes = (payload as { bytes?: number } | null)?.bytes ?? 0; const queue = pending.get(event) ?? []; diff --git a/src/lib/backend/http.ts b/src/lib/backend/http.ts index 17c94700..de7713b8 100644 --- a/src/lib/backend/http.ts +++ b/src/lib/backend/http.ts @@ -1,5 +1,6 @@ import { backendURL, requireCapability } from "./runtime"; import { deliver } from "./events"; +import { closeBrowserVnc, closeAllBrowserVnc, sendBrowserVnc } from "./vnc"; let csrf: string | undefined; let source: EventSource | undefined; @@ -9,7 +10,7 @@ const sockets = new Map< { socket: WebSocket; ready: Promise; closed: boolean } >(); const nativeCommands = - /^(create_local_session|create_serial_session|create_telnet_session|create_rdp_session|create_vnc_session|.*zmodem.*|.*serial_modem.*|.*local_file.*|.*watcher.*|open_local.*|install_plugin|import_plugin)$/; + /^(create_local_session|create_serial_session|create_rdp_session|.*zmodem.*|.*serial_modem.*|.*local_file.*|.*watcher.*|open_local.*|install_plugin|import_plugin)$/; export async function request( path: string, @@ -56,6 +57,12 @@ export function startEvents(): Promise { if (event.event === "ready") { clearTimeout(timer); resolve(); + } else if (event.event.startsWith("connection-error-")) { + const payload = event.payload as { error?: string } | string; + deliver( + event.event.replace("connection-error-", "session-error-"), + typeof payload === "string" ? payload : payload.error, + ); } else deliver(event.event, event.payload); }; source.addEventListener("expired", () => window.location.reload()); @@ -98,8 +105,7 @@ async function attach(id: string): Promise { } } else { const event = JSON.parse(data); - if (event.type === "error") - deliver(`connection-error-${id}`, event.error); + if (event.type === "error") deliver(`session-error-${id}`, event.error); if (event.type === "closed" || event.type === "error") { state.closed = true; deliver(`session-closed-${id}`, null); @@ -144,6 +150,7 @@ export async function httpInvoke( command: string, args: Record = {}, ): Promise { + if (command === "get_default_local_shell") requireCapability("localShell"); if (nativeCommands.test(command)) requireCapability("nativeFiles"); if (command === "read_clipboard_text") return (await navigator.clipboard.readText()) as T; @@ -160,7 +167,6 @@ export async function httpInvoke( if ( [ "claim_external_open_requests", - "get_proxies", "get_tunnels", "get_otp_entries", "get_local_shells", @@ -179,16 +185,24 @@ export async function httpInvoke( active.socket.close(); } sockets.clear(); + closeAllBrowserVnc(); window.location.reload(); return undefined as T; } case "create_ssh_session": - case "create_temporary_ssh_session": { + case "create_temporary_ssh_session": + case "create_telnet_session": + case "create_vnc_session": { await startEvents(); - const result = await request<{ session_id: string }>( - "api/sessions", - args, - ); + const result = await request<{ session_id: string }>("api/sessions", { + ...args, + type: + command === "create_telnet_session" + ? "telnet" + : command === "create_vnc_session" + ? "vnc" + : "ssh", + }); return result.session_id as T; } case "attach_session": @@ -209,6 +223,15 @@ export async function httpInvoke( JSON.stringify({ type: "resize", cols: args.cols, rows: args.rows }), ); return undefined as T; + case "vnc_input_batch": + await sendBrowserVnc(id, { type: "input", events: args.events }); + return undefined as T; + case "vnc_set_clipboard_text": + await sendBrowserVnc(id, { type: "clipboard", text: args.text }); + return undefined as T; + case "close_vnc_session": + closeBrowserVnc(id); + break; case "close_session": { const active = sockets.get(id); if (active) { diff --git a/src/lib/backend/platform/webviewWindow.ts b/src/lib/backend/platform/webviewWindow.ts index 7f1af141..9d9aeeb8 100644 --- a/src/lib/backend/platform/webviewWindow.ts +++ b/src/lib/backend/platform/webviewWindow.ts @@ -30,6 +30,7 @@ class BrowserWindow { !new Set([ "settings", "new-session", + "proxy", "quick-command", "file-editor", "file-preview", diff --git a/src/lib/backend/runtime.ts b/src/lib/backend/runtime.ts index b3604ac9..90509e77 100644 --- a/src/lib/backend/runtime.ts +++ b/src/lib/backend/runtime.ts @@ -1,5 +1,8 @@ export type Capability = | "ssh" + | "telnet" + | "vnc" + | "networkProxy" | "sftp" | "ai" | "settings" @@ -32,6 +35,9 @@ export const runtime = : "web"; const webCapabilities = new Set([ "ssh", + "telnet", + "vnc", + "networkProxy", "sftp", "ai", "settings", @@ -61,6 +67,7 @@ const webPanels = new Set([ "aiAssistant", "settings", "plugins", + "network", ]); export function supportsPanel(id: string): boolean { return runtime === "desktop" || webPanels.has(id); diff --git a/src/lib/backend/vnc.ts b/src/lib/backend/vnc.ts new file mode 100644 index 00000000..b56f8346 --- /dev/null +++ b/src/lib/backend/vnc.ts @@ -0,0 +1,140 @@ +import { backendURL } from "./runtime"; +import { deliver } from "./events"; + +interface Stream { + socket?: WebSocket; + ready: Promise; + closed: boolean; + everOpened: boolean; + retry?: number; + stop: () => void; + onFrame: (frame: ArrayBuffer) => void; + onError: (error: Error) => void; +} +const streams = new Map(); + +function connect(id: string, stream: Stream): Promise { + const url = backendURL(`api/sessions/${encodeURIComponent(id)}/vnc`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(url); + socket.binaryType = "arraybuffer"; + stream.socket = socket; + let opened = false; + const ready = new Promise((resolve, reject) => { + socket.onopen = () => { + if (stream.closed) { + socket.close(); + return; + } + opened = true; + stream.everOpened = true; + resolve(); + }; + socket.onerror = () => reject(new Error("VNC WebSocket unavailable")); + socket.onmessage = ({ data }) => { + if (stream.closed || stream.socket !== socket) return; + try { + if (typeof data !== "string") stream.onFrame(data); + else { + const value = JSON.parse(data); + if (value.type === "event") deliver(value.event, value.payload); + if (value.type === "error") stream.onError(new Error(value.error)); + } + } catch (error) { + stream.onError( + error instanceof Error ? error : new Error(String(error)), + ); + } + }; + socket.onclose = () => { + if (!opened) reject(new Error("VNC WebSocket closed")); + if (stream.closed || stream.socket !== socket || !stream.everOpened) + return; + deliver(`vnc-state-${id}`, { + sessionId: id, + state: "reconnecting", + message: "", + }); + stream.retry = window.setTimeout(() => { + if (stream.closed) return; + stream.ready = connect(id, stream); + void stream.ready.catch(stream.onError); + }, 1000); + }; + }); + return ready; +} +export async function subscribeBrowserVnc( + id: string, + onFrame: Stream["onFrame"], + onError: Stream["onError"], + signal?: AbortSignal, +): Promise<() => void> { + closeBrowserVnc(id); + const stream: Stream = { + ready: Promise.resolve(), + closed: false, + everOpened: false, + onFrame, + onError, + stop: () => {}, + }; + const stop = () => { + stream.closed = true; + window.clearTimeout(stream.retry); + stream.socket?.close(); + signal?.removeEventListener("abort", stop); + if (streams.get(id) === stream) streams.delete(id); + }; + stream.stop = stop; + if (signal?.aborted) + throw new DOMException("VNC subscription cancelled", "AbortError"); + signal?.addEventListener("abort", stop, { once: true }); + streams.set(id, stream); + // A previous socket can still be detaching at the server (React StrictMode, + // pane moves). Retry initial attachment without marking the VNC session failed. + stream.ready = (async () => { + for (let attempt = 0; ; attempt++) { + if (stream.closed) + throw new DOMException("VNC subscription cancelled", "AbortError"); + try { + await connect(id, stream); + return; + } catch (error) { + stream.socket?.close(); + if (stream.closed || attempt >= 4) throw error; + await new Promise((resolve) => window.setTimeout(resolve, 250)); + } + } + })(); + try { + await stream.ready; + } catch (error) { + stop(); + throw error; + } + return stop; +} + +export async function sendBrowserVnc( + id: string, + value: unknown, +): Promise { + const stream = streams.get(id); + if (!stream || stream.closed) throw new Error("VNC display is detached"); + await stream.ready; + const socket = stream.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) + throw new Error("VNC is disconnected"); + if (socket.bufferedAmount > 1024 * 1024) + throw new Error("VNC input queue is full"); + socket.send(JSON.stringify(value)); +} +export function closeBrowserVnc(id: string): void { + const stream = streams.get(id); + if (!stream) return; + stream.stop(); +} +export function closeAllBrowserVnc(): void { + for (const id of streams.keys()) closeBrowserVnc(id); +} diff --git a/src/lib/backend/web.test.tsx b/src/lib/backend/web.test.tsx index b1be330a..e482a7a3 100644 --- a/src/lib/backend/web.test.tsx +++ b/src/lib/backend/web.test.tsx @@ -8,6 +8,7 @@ vi.mock("@/i18n", () => ({ default: { t: (key: string) => key } })); class FakeSocket { static OPEN = 1; static instances: FakeSocket[] = []; + static failedOpenings = 0; readyState = 1; bufferedAmount = 0; binaryType = ""; @@ -18,7 +19,13 @@ class FakeSocket { sent: unknown[] = []; constructor(public url: URL) { FakeSocket.instances.push(this); - queueMicrotask(() => this.onopen?.()); + if (FakeSocket.failedOpenings > 0) { + FakeSocket.failedOpenings--; + queueMicrotask(() => { + this.onerror?.(); + this.close(); + }); + } else queueMicrotask(() => this.onopen?.()); } send(value: unknown) { this.sent.push(value); @@ -48,6 +55,7 @@ beforeEach(() => { vi.stubGlobal("BroadcastChannel", undefined); vi.stubGlobal("fetch", fetchMock); FakeSocket.instances = []; + FakeSocket.failedOpenings = 0; fetchMock .mockReset() .mockImplementation( @@ -131,6 +139,9 @@ describe("browser backend boundary", () => { await expect(httpInvoke("create_local_session")).rejects.toThrow( "Capability unavailable", ); + await expect(httpInvoke("get_default_local_shell")).rejects.toThrow( + "Capability unavailable in Web mode: localShell", + ); expect(fetchMock).not.toHaveBeenCalled(); deliver("terminal-output-absent", { data: "", bytes: 2 * 1024 * 1024 }); expect(() => @@ -274,3 +285,139 @@ describe("browser backend boundary", () => { ).toBeTruthy(); }); }); + +describe("Web Telnet and VNC", () => { + it("restores VNC frames and input after a failed reconnect handshake", async () => { + const { subscribeBrowserVnc, sendBrowserVnc } = await import("./vnc"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const frame = vi.fn(); + const onError = vi.fn(); + const stop = await subscribeBrowserVnc("retry", frame, onError); + try { + FakeSocket.failedOpenings = 1; + FakeSocket.instances[0].close(); + await vi.advanceTimersByTimeAsync(1000); + expect(onError).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1000); + const recovered = FakeSocket.instances[2]; + const bytes = new ArrayBuffer(44); + recovered.onmessage?.({ data: bytes }); + expect(frame).toHaveBeenCalledWith(bytes); + await sendBrowserVnc("retry", { type: "input", events: [] }); + expect(recovered.sent).toEqual([ + JSON.stringify({ type: "input", events: [] }), + ]); + stop(); + await vi.advanceTimersByTimeAsync(3000); + expect(FakeSocket.instances).toHaveLength(3); + } finally { + stop(); + vi.useRealTimers(); + } + }); + it("enables the supported protocol and proxy capabilities only", async () => { + const { supports } = await import("./runtime"); + for (const name of ["ssh", "telnet", "vnc", "networkProxy"] as const) + expect(supports(name)).toBe(true); + for (const name of ["remoteDesktop", "localShell", "serial"] as const) + expect(supports(name)).toBe(false); + }); + it("maps Telnet and VNC creation and loads real proxy configuration", async () => { + const { httpInvoke } = await import("./http"); + await httpInvoke("create_telnet_session", { + host: "telnet.test", + port: 23, + }); + expect( + JSON.parse( + fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[1].body, + ), + ).toEqual({ host: "telnet.test", port: 23, type: "telnet" }); + await httpInvoke("create_vnc_session", { connectionId: "vnc-saved" }); + expect( + JSON.parse( + fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[1].body, + ), + ).toEqual({ connectionId: "vnc-saved", type: "vnc" }); + await httpInvoke("get_proxies"); + expect( + fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[0].pathname, + ).toBe("/nyaterm/api/commands/get_proxies"); + }); + it("replays VNC messages and keeps a replacement safe from stale cleanup", async () => { + const { subscribeBrowserVnc, sendBrowserVnc, closeAllBrowserVnc } = + await import("./vnc"); + const { browserListen } = await import("./events"); + const oldController = new AbortController(); + const old = subscribeBrowserVnc( + "vnc", + vi.fn(), + vi.fn(), + oldController.signal, + ).catch(() => {}); + const frame = vi.fn(); + const current = subscribeBrowserVnc("vnc", frame, vi.fn()); + await old; + const stop = await current; + const socket = FakeSocket.instances[FakeSocket.instances.length - 1]!; + const state = vi.fn(); + const unlisten = await browserListen("vnc-state-vnc", state); + const bytes = new ArrayBuffer(44); + socket.onmessage?.({ data: bytes }); + socket.onmessage?.({ + data: JSON.stringify({ + type: "event", + event: "vnc-state-vnc", + payload: { state: "active" }, + }), + }); + expect(frame).toHaveBeenCalledWith(bytes); + expect(state).toHaveBeenCalledWith( + expect.objectContaining({ payload: { state: "active" } }), + ); + oldController.abort(); + await sendBrowserVnc("vnc", { type: "input", events: [] }); + expect(socket.sent).toContain( + JSON.stringify({ type: "input", events: [] }), + ); + expect(socket.url.search).toBe(""); + stop(); + expect(socket.readyState).toBe(3); + unlisten(); + closeAllBrowserVnc(); + }); + it("cancels a VNC attachment before its socket opens", async () => { + const { subscribeBrowserVnc } = await import("./vnc"); + const controller = new AbortController(); + const result = subscribeBrowserVnc( + "cancelled", + vi.fn(), + vi.fn(), + controller.signal, + ); + controller.abort(); + await expect(result).rejects.toThrow(); + expect(FakeSocket.instances[0].readyState).toBe(3); + }); +}); + +it("delivers Web connection failures to the existing terminal error listener", async () => { + const { httpInvoke } = await import("./http"); + const { browserListen } = await import("./events"); + await httpInvoke("attach_session", { sessionId: "failure" }); + const socket = FakeSocket.instances[0]; + const failed = vi.fn(); + const unlisten = await browserListen("session-error-failure", failed); + socket.onmessage?.({ + data: JSON.stringify({ + type: "error", + error: "Proxy credentials could not be decrypted", + }), + }); + expect(failed).toHaveBeenCalledWith( + expect.objectContaining({ + payload: "Proxy credentials could not be decrypted", + }), + ); + unlisten(); +}); diff --git a/src/lib/vncFrames.ts b/src/lib/vncFrames.ts new file mode 100644 index 00000000..6c0b78dd --- /dev/null +++ b/src/lib/vncFrames.ts @@ -0,0 +1,63 @@ +import { Channel } from "@tauri-apps/api/core"; +import { runtime } from "./backend/runtime"; +import { subscribeBrowserVnc } from "./backend/vnc"; +import { invoke } from "./invoke"; + +const owners = new Map(); +const operations = new Map>(); +function enqueue( + id: string, + operation: () => Promise, +): Promise { + const next = (operations.get(id) ?? Promise.resolve()) + .catch(() => {}) + .then(operation); + operations.set(id, next); + void next + .finally(() => { + if (operations.get(id) === next) operations.delete(id); + }) + .catch(() => {}); + return next; +} +export async function subscribeVncFrames( + sessionId: string, + onFrame: (frame: ArrayBuffer) => void, + onError: (error: Error) => void, + signal?: AbortSignal, +): Promise<() => void> { + if (runtime === "web") + return subscribeBrowserVnc(sessionId, onFrame, onError, signal); + const owner = {}; + owners.set(sessionId, owner); + const channel = new Channel((frame) => { + if (owners.get(sessionId) === owner && !signal?.aborted) onFrame(frame); + }); + const stop = () => { + signal?.removeEventListener("abort", stop); + channel.onmessage = () => {}; + if (owners.get(sessionId) !== owner) return; + owners.delete(sessionId); + void enqueue(sessionId, () => + invoke("vnc_detach_frame_channel", { sessionId }), + ).catch(onError); + }; + signal?.addEventListener("abort", stop, { once: true }); + if (signal?.aborted) { + stop(); + return stop; + } + try { + await enqueue(sessionId, async () => { + if (owners.get(sessionId) === owner && !signal?.aborted) + await invoke("vnc_attach_frame_channel", { + sessionId, + frameChannel: channel, + }); + }); + } catch (error) { + stop(); + throw error; + } + return stop; +} diff --git a/src/pages/NewSessionPage.test.tsx b/src/pages/NewSessionPage.test.tsx index 186e4b7e..d09b71ab 100644 --- a/src/pages/NewSessionPage.test.tsx +++ b/src/pages/NewSessionPage.test.tsx @@ -14,6 +14,7 @@ const { sshFormMock, telnetFormMock, translateMock, + supportsMock, } = vi.hoisted(() => ({ closeMock: vi.fn(), emitMock: vi.fn(), @@ -24,6 +25,7 @@ const { serialFormMock: vi.fn(), sshFormMock: vi.fn(), telnetFormMock: vi.fn(), + supportsMock: vi.fn(() => true), translateMock: (key: string, fallback?: unknown) => typeof fallback === "string" ? fallback : key, })); @@ -43,6 +45,10 @@ vi.mock("@/context/AppContext", () => ({ })); vi.mock("@/lib/invoke", () => ({ invoke: invokeMock })); +vi.mock("@/lib/backend/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + supports: supportsMock, +})); vi.mock("@tauri-apps/api/event", () => ({ emit: emitMock })); vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ close: closeMock }), @@ -268,6 +274,8 @@ const vncConnection: SavedConnection = { describe("NewSessionPage", () => { beforeEach(() => { + supportsMock.mockReset(); + supportsMock.mockReturnValue(true); window.history.replaceState({}, "", `/?edit=${rdpConnection.id}`); closeMock.mockReset(); closeMock.mockResolvedValue(undefined); @@ -328,6 +336,31 @@ describe("NewSessionPage", () => { }); }); + it("skips local shell discovery in Web mode and explains unavailable session types", async () => { + window.history.replaceState({}, "", "/"); + supportsMock.mockImplementation((capability?: string) => ["ssh", "telnet", "vnc", "networkProxy"].includes(capability ?? "")); + render(); + + await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("get_saved_connections")); + expect(invokeMock).not.toHaveBeenCalledWith("get_default_local_shell"); + expect(screen.getByText("web.sessionTypesHint")).toBeTruthy(); + const sshTab = screen.getByRole("tab", { name: "SSH" }); + expect((sshTab as HTMLButtonElement).disabled).toBe(false); + for (const name of ["dialog.localTerminal", "dialog.serial", "RDP"]) { + const tab = screen.getByRole("tab", { name }); + expect((tab as HTMLButtonElement).disabled).toBe(true); + fireEvent.mouseDown(tab, { button: 0, ctrlKey: false }); + } + expect(sshTab.getAttribute("aria-selected")).toBe("true"); + for (const name of ["Telnet", "VNC"]) { + const tab = screen.getByRole("tab", { name }); + expect((tab as HTMLButtonElement).disabled).toBe(false); + fireEvent.mouseDown(tab, { button: 0, ctrlKey: false }); + await waitFor(() => expect(tab.getAttribute("aria-selected")).toBe("true")); + } + expect(invokeMock).not.toHaveBeenCalledWith("list_serial_ports"); + }); + it("restores an RDP jump host and keeps it when saving without changes", async () => { render(); diff --git a/src/pages/NewSessionPage.tsx b/src/pages/NewSessionPage.tsx index 2d52aa59..ed55adf8 100644 --- a/src/pages/NewSessionPage.tsx +++ b/src/pages/NewSessionPage.tsx @@ -303,18 +303,20 @@ export default function NewSessionPage() { const [recordingMode, setRecordingMode] = useState("transcript"); useEffect(() => { - invoke("get_default_local_shell") - .then((value) => { - const resolvedShell = value.trim(); - if (!resolvedShell) return; - setDefaultLocalShell(resolvedShell); - if (!editId) { - setShellPath((current) => - current === FALLBACK_LOCAL_SHELL ? resolvedShell : current, - ); - } - }) - .catch(() => undefined); + if (supports("localShell")) { + invoke("get_default_local_shell") + .then((value) => { + const resolvedShell = value.trim(); + if (!resolvedShell) return; + setDefaultLocalShell(resolvedShell); + if (!editId) { + setShellPath((current) => + current === FALLBACK_LOCAL_SHELL ? resolvedShell : current, + ); + } + }) + .catch(() => undefined); + } invoke("get_groups") .then(setGroups) .catch((e) => setError(getErrorMessage(e))); @@ -393,6 +395,8 @@ export default function NewSessionPage() { setSftpSettings(normalizeSftpSettings(found.sftp)); setRemoteDynamicTabTitle(found.dynamic_tab_title ?? false); } else if (found.type === "telnet") { + setProxyId(found.network?.proxy_id || ""); + setJumpHostId(found.network?.proxy_jump_id || ""); setHost(found.host || ""); setTelnetPort(found.port || 23); setUsername(found.username || ""); @@ -993,7 +997,7 @@ export default function NewSessionPage() { ? "vnc" : "serial"; const network = - currentTab === "ssh" || currentTab === "rdp" || currentTab === "vnc" + currentTab === "ssh" || currentTab === "telnet" || currentTab === "rdp" || currentTab === "vnc" ? (() => { const nextNetwork: NonNullable = {}; if (proxyId) { @@ -1145,6 +1149,7 @@ export default function NewSessionPage() { port: telnetPort, username: normalizedUsername, auth, + network, backspace_mode: telnetBackspaceMode, raw_tcp_cli: telnetRawTcpCli, enter_mode: telnetEnterMode, @@ -1289,7 +1294,7 @@ export default function NewSessionPage() { {t("dialog.localTerminal")} @@ -1310,13 +1315,18 @@ export default function NewSessionPage() { RDP VNC + {!supports("localShell") && ( +

+ {t("web.sessionTypesHint")} +

+ )}
@@ -1732,6 +1742,7 @@ export default function NewSessionPage() {