Compare commits

...
Author SHA1 Message Date
XuzandClaude Opus 5.5 ac6f8eaeba 版本号改为 0.1.0-beta.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:44:49 +08:00
XuzandClaude Opus 5.5 2feae2fe3b 外部连接:sftp:// 链接打开 SFTP 标签,兼容 WinSCP 的调用方式
堡垒机像调 WinSCP 一样拉起 ShellRS(`ShellRS sftp://用户[:密码]@地址[:端口]`,
前面可以有 `/sessionname=名称`),开一个不保存的 SFTP 标签,左侧栏收起;
其余规则和 ssh:// 的外部连接一样。WinSCP 的其他开关忽略,链接里的路径暂不认。

补了 SFTP 不带密码、只凭账号(堡垒机令牌)登录的协议测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:44:17 +08:00
XuzandClaude Opus 5.5 d5370f8bac 外部连接:改名并在打开时收起左侧栏
命名(用户定的):命令行链接打开的叫「外部连接」(不保存、只开终端);以后在
ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。代码里共同的「不
保存」统一叫 temporary(HostStore 的 temporary / is_temporary、
SecretRef::Temporary、TemporarySecretStore,和 CLI 的 JSON 字段一致),「只开
终端」改由来源决定:HostStore::is_external,login_of 只对它置 shell_only,右
侧栏也看它。shellrs list 表格写「(外部连接)」。

外部连接打开时左侧栏开着就收起(同 ⌘B),终端占满窗口;链接有误时不收。
命令片段面板本来就列出全部片段,外部连接照常都能看到;以后做适用范围时外部
和临时连接只显示「所有主机」的,规则记在 CLAUDE.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:17:03 +08:00
22 changed files with 481 additions and 177 deletions

No files matched your search

+7
View File
@@ -17,6 +17,13 @@ ShellRS 的第一个公开版本。
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
## [0.1.0-beta.9]
### 新增
- 堡垒机可以像调用 WinSCP 一样,用 `sftp://` 链接打开 ShellRS 的 SFTP。
- 从堡垒机打开时,左侧主机栏自动收起,终端或 SFTP 占满窗口。
## [0.1.0-beta.8]
### 修复
+15 -13
View File
@@ -8,7 +8,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接拉起 ShellRS,开一个不保存的「临时连接」(见下面「临时连接不保存」)。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;以后要做的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
@@ -42,11 +42,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://用户[:密码]@地址[:端口]`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。**算法按 OpenSSH 的默认来**(`ssh_config`:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 `ecdh-sha2-nistp256` 连上了。所以 `KEX_ORDER` 照 OpenSSH 的顺序加上 `ecdh-sha2-nistp*`,`MIN_DH_GROUP_BITS` 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。`route_tests` 的 `a_server_with_only_2048_bit_groups_or_nist_curves_is_reached` 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 `describe_login_error`(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、`NoCommonAlgo` 时写明服务器支持哪些算法),找不到时给出整条错误链(`{error:#}`),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算Line truncated
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。`TransientSecretStore`(`transient.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Transient { host }`(临时连接链接里的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `SERVICE` = `shellrs`。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。`TemporarySecretStore`(`temporary.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Temporary { host }`(不保存的连接的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `SERVICE` = `shellrs`。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
@@ -64,10 +64,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。
- `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。
- `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的临时连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_link_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`,或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`、WinSCP 的开关(`/sessionname=` 当标签名,`/newinstance`、`/ini=` 等列在 `WINSCP_SWITCHES` 里的忽略;只认列出的名字,所以 `/tmp/x` 这种路径不算),或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
- `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。
- `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `<data_dir>/cli.sock`,Windows 上是 `\\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。`connect` 按平台实现,读帧循环 `talk` 共用。进度行用 `\r` 加补空格覆盖,不用 `\x1b[2K`(老 conhost 不认)。
- `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。
@@ -134,12 +134,14 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 `Shared::activation`(`Mutex<Option<Vec<OpenLink>>>`);请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,是 `Some` 就 `bring_forward`,再按顺序 `open_link`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
- **临时连接不保存。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去(用户定的:和 Xshell 的 `-url` 一样是临时的;只做 `ssh://`、不做 `sftp://`;只认命令行参数,不注册系统的 `ssh://` 链接、不改安装包)。
- `HostStore` 把它放在单独的 `transient` 里:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `transient_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(临时连接)」;它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
- **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 对临时主机置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools`)。延迟测量是全局请求、不开通道,照常。
- 登录用「密码」方式,`login_of` 把密码引用换成 `SecretRef::Transient { host }`:链接里的密码只在 `TransientSecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。
- 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_link_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_transient`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。
- 链接解析失败(不是 `ssh://`、缺用户名 / 地址、端口不对)弹「无法打开 SSH 链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。
- **外部连接不保存,只开一个标签。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去;按 WinSCP 的写法(`ShellRS sftp://…`,JumpServer 的模板就是单独一个链接,前面可以有 `/sessionname=名称`)则开一个 SFTP 标签(`open_link` 按 `SshLink::kind` 调 `connect_host` 或 `open_explorer`),其余规则两种一样(用户定的:和 Xshell 的 `-url` 一样是临时的;只认命令行参数,不注册系统的 `ssh://` / `sftp://` 链接、不改安装包;`sftp://` 链接里的路径暂不认,`scp://` 不做)。
- `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(外部连接)」(做临时连接时要让 `HostInfo` 分得出两种);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
- **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 只对 `is_external` 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools` 看 `is_external`)。延迟测量是全局请求、不开通道,照常。SFTP 标签本来就只开一个通道(`sftp/client.rs` 的 sftp 子系统,延迟同样是全局请求),所以 `sftp://` 的外部连接不用另外处理;以后给 SFTP 加别的通道(执行命令之类)要对 `is_external` 关掉。
- 登录用「密码」方式,`login_of` 把临时主机的密码引用换成 `SecretRef::Temporary { host }`:链接里的密码只在 `TemporarySecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。
- 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_temporary_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_temporary`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。
- **打开时标签占满窗口**(用户要的「终端默认全屏」「SFTP 全屏显示」,选的是收起左侧栏):`open_link` 成功后左侧 Dock 开着就 `toggle_dock` 收起,和 ⌘B 一样,用户按 ⌘B 或双击标签展开;解析失败什么也不打开时不收。不用 Dock 的放大,也不动窗口本身。
- **命令片段照常全有**:片段面板列出库里的全部片段,不看是哪台主机(`store.snippets()`),界面只会建「所有主机」范围的片段。以后做「适用范围」(分组 / 主机)的筛选时,外部连接和临时连接不属于任何分组、也不是保存的主机,只显示「所有主机」的(用户定的)。
- 链接解析失败(不是 `ssh://` / `sftp://`、缺用户名 / 地址、端口不对)弹「无法打开链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。
- `tests/cli_bin.rs` 的 `shellrs_opened_with_a_link_hands_it_to_the_running_app` 用真的 `shellrs` 二进制交接链接(含中文 `-newtab`,Windows CI 上也跑),子进程 20 秒不退就杀掉,免得交接坏了起出一个窗口。
- **macOS 上关窗口是隐藏,不是退出。** ShellRS 只有一个窗口,在跑的东西(连接、传输、端口转发、外部 CLI)都归窗口里的工作区;GPUI 在 macOS 上默认不随最后一个窗口退出(`QuitMode::Default`),真关掉窗口会停掉这一切,却留下一个没有窗口、点 Dock 图标也唤不回的进程。所以 `main` 给窗口挂 `app::hide_when_closed`(`on_window_should_close` 里 `cx.hide()` 并返回 `false`),点 Dock 图标(`Application::on_reopen`)和再次打开 ShellRS(`come_forward_when_opened_again`)都走 `app::bring_forward`:先 `NSApplication::unhide`(只 `activate` 不一定让隐藏的窗口重新出现),再激活窗口。⌘Q 才退出。Windows / Linux 关窗口即退出(GPUI 的默认)。测试平台的 `hide()` 是 `unimplemented!()`,所以这条只挂在生产路径上,没有 UI 测试,改动后要在真实窗口里试。
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。
@@ -167,7 +169,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `HostLogin`(`HostStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板主机的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;主机表单测试连接时用 `HostStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Host`。`HostLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的主机重连),主机 `update` 和 `update_credential` 都在改之前取一遍**全部主机**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 `Host.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`;库里这一行的 `username` 是 NULL、`auth` 是 `credential`),store 在保存主机(`normalized`)、改凭据和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `host.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `HostStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。唯一不进钥匙串的是临时连接链接里的密码(`SecretRef::Transient`):只在 `TransientSecretStore` 的内存里,随临时主机一起忘掉。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。唯一不进钥匙串的是不保存的连接的密码(`SecretRef::Temporary`):只在 `TemporarySecretStore` 的内存里,随临时主机一起忘掉。
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 UI 测试的夹具。
- **对外只给 `PublicId`,不给 `HostId`。** 主机右键「复制 ID」复制的是 `Host.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `hosts.public_id`(可为空,`UNIQUE`)。新建和复制主机时由 `HostStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建主机时必须带上原来的 `public_id`,`update_host` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
@@ -201,4 +203,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
## 测试约定
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的临时连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
Generated
+1 -1
View File
@@ -7686,7 +7686,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.0-beta.8"
version = "0.1.0-beta.9"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.0-beta.8"
version = "0.1.0-beta.9"
edition = "2024"
publish = false
description = "SSH client and host manager: terminal, SFTP, port forwarding"
+11 -8
View File
@@ -118,31 +118,34 @@ shellrs upload <ID> <本地路径> <远程路径>
shellrs download <ID> <远程路径> <本地路径>
```
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接(`"temporary": true`),见「从堡垒机打开(临时连接)」。
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的外部连接(`"temporary": true`),见「从堡垒机打开(外部连接)」。
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
## 从堡垒机打开(临时连接)
## 从堡垒机打开(外部连接)
JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS:把 ShellRS 设成 SSH 客户端,参数照 Xshell 的写法填。ShellRS 收到后打开一个终端标签连过去:
JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS 打开终端,也可以像调用 WinSCP 一样调用它打开 SFTP:把 ShellRS 设成 SSH 客户端或 SFTP 客户端,参数照 Xshell 或 WinSCP 的写法填。ShellRS 收到 `ssh://` 链接就打开一个终端标签连过去,收到 `sftp://` 链接就打开一个 SFTP 标签:
```sh
ShellRS ssh://用户[:密码]@地址[:端口]
ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名
ShellRS sftp://用户[:密码]@地址[:端口]
ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
```
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),不分大小写;别的选项忽略。只支持 `ssh://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。
- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。
- **只用终端这一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话,所以临时连接不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),以及 WinSCP 的 `/sessionname=标签名`(也可以写成 `-sessionname=`),不分大小写;`/newinstance`、`/ini=nul` 等别的选项忽略。只支持 `ssh://` 和 `sftp://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。`sftp://` 链接里的路径(WinSCP 用它指定打开哪个目录)目前不认,远程一侧照常打开主目录。
- **标签占满窗口**:打开外部连接时,左侧的主机列表自动收起(同 ⌘B),按 ⌘B(其他平台 Ctrl+B)或双击标签即可展开。
- **外部连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。
- **只用一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话。SFTP 标签本来就只开一个;终端则不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。
- 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://` 或 `sftp://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。
- 堡垒机要调用的程序:
- Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。
- macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。
- Linux:AppImage 本身。
- 外部 CLI 的 `shellrs list` 也列出临时连接(JSON 里 `"temporary": true`,表格的分组一栏写「(临时连接)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的临时连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
- 外部 CLI 的 `shellrs list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(外部连接)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
## 标签页
+3 -1
View File
@@ -253,8 +253,10 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
[
host.id.clone(),
host.name.clone(),
// Every temporary host is an external one so far; a 临时连接
// typed into ShellRS, when it comes, needs telling apart here.
if host.temporary {
"(临时连接)".to_string()
"(外部连接)".to_string()
} else {
host.group.clone().unwrap_or_default()
},
+81 -9
View File
@@ -1,5 +1,7 @@
//! The links ShellRS is opened with, the way bastion hosts open Xshell:
//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`.
//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`;
//! and WinSCP: `ShellRS sftp://user@host:port`, or with
//! `/sessionname=名称` in front.
//!
//! Only told apart from the `shellrs` command's arguments here; the link
//! itself is read by the app (`host::SshLink`), which says what is wrong
@@ -17,7 +19,7 @@ pub struct OpenLink {
/// May hold a password. Empty when the arguments named no link, so the
/// app can say so: a second ShellRS on Windows has no console to.
pub url: String,
/// What to call the tab: Xshell's `-newtab`.
/// What to call the tab: Xshell's `-newtab`, WinSCP's `/sessionname`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab: Option<String>,
}
@@ -54,17 +56,35 @@ const URL_OPTION: &str = "-url";
const TAB_OPTION: &str = "-newtab";
const WINDOW_OPTION: &str = "-newwin";
/// WinSCP's switches, written `/name[=value]` or `-name[=value]`:
/// `/sessionname=名称` names the tab, the others mean nothing to ShellRS
/// (`/newinstance` asks for a window of its own, `/ini=nul` not to read
/// WinSCP's settings, `/privatekey=` and `/hostkey=` are ShellRS's to
/// know).
const WINSCP_SWITCHES: &[&str] = &[
SESSION_NAME,
"newinstance",
"privatekey",
"hostkey",
"passphrase",
"rawsettings",
"ini",
"log",
"timeout",
];
const SESSION_NAME: &str = "sessionname";
/// The link `args` (without the program) open, or `None` when they are the
/// `shellrs` command's. Only the first argument decides, so `shellrs exec
/// web "curl http://x"` stays a command: a link launch starts with one of
/// Xshell's options or with the link itself.
/// Xshell's options, one of WinSCP's switches or the link itself.
pub fn link_arguments(args: &[OsString]) -> Option<OpenLink> {
let args: Vec<String> = args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let first = args.first()?;
if !is_option(first) && !looks_like_link(first) {
if !is_option(first) && winscp_switch(first).is_none() && !looks_like_link(first) {
return None;
}
let mut given = None;
@@ -82,11 +102,11 @@ pub fn link_arguments(args: &[OsString]) -> Option<OpenLink> {
.peek()
.is_some_and(|next| !next.starts_with('-') && !looks_like_link(next))
{
let name = args.next().unwrap_or_default();
let name = name.trim();
if !name.is_empty() {
tab = Some(name.to_string());
}
tab = tab_name(&args.next().unwrap_or_default()).or(tab);
}
} else if let Some((switch, value)) = winscp_switch(&arg) {
if switch == SESSION_NAME {
tab = value.and_then(tab_name).or(tab);
}
} else if option == WINDOW_OPTION || arg.starts_with('-') {
// Ignored, like any option ShellRS does not know.
@@ -104,6 +124,26 @@ fn is_option(arg: &str) -> bool {
[URL_OPTION, TAB_OPTION, WINDOW_OPTION].contains(&arg.to_lowercase().as_str())
}
/// The name and value of one of WinSCP's switches, the name in lower case.
fn winscp_switch(arg: &str) -> Option<(String, Option<&str>)> {
let switch = arg.strip_prefix(['/', '-'])?;
let (name, value) = match switch.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (switch, None),
};
let name = name.to_lowercase();
WINSCP_SWITCHES
.contains(&name.as_str())
.then_some((name, value))
}
/// A tab's name as given, without the spaces or quotes around it; `None`
/// when that leaves nothing.
fn tab_name(given: &str) -> Option<String> {
let name = given.trim().trim_matches('"').trim();
(!name.is_empty()).then(|| name.to_string())
}
/// `scheme://…`, whatever the scheme: the app says which ones it opens.
fn looks_like_link(arg: &str) -> bool {
let Some((scheme, _)) = arg.split_once("://") else {
@@ -162,6 +202,36 @@ mod tests {
assert_eq!(link(&["-newwin", "-folder", "x", url]), open(url, None));
}
#[test]
fn winscps_switches_are_understood_too() {
// How JumpServer opens WinSCP: the link alone.
let url = "sftp://b478e26f:secret@172.16.0.28:12024";
assert_eq!(link(&[url]), open(url, None));
assert_eq!(
link(&["/sessionname=生产库", url]),
open(url, Some("生产库"))
);
assert_eq!(
link(&[
"/newinstance",
"/ini=nul",
"-SessionName=\"文件 服务器\"",
url
]),
open(url, Some("文件 服务器"))
);
assert_eq!(
link(&[
"/rawsettings",
"Compression=1",
url,
"/hostkey=ssh-ed25519 255 x"
]),
open(url, None)
);
assert_eq!(link(&["/sessionname=生产库"]), open("", Some("生产库")));
}
#[test]
fn options_without_a_link_still_open_so_the_app_can_say_why() {
assert_eq!(link(&["-newtab", "生产库"]), open("", Some("生产库")));
@@ -180,6 +250,8 @@ mod tests {
&["--version"],
&["ssh-not-a-link"],
&["://nothing-before"],
&["/tmp/sessionname"],
&["/usr/bin/sftp://x"],
] {
assert_eq!(link(args), None, "{args:?}");
}
+5 -5
View File
@@ -159,7 +159,7 @@ pub enum Reply {
},
}
/// A host as the CLI lists it: a saved one, or one opened from a link.
/// A host as the CLI lists it: a saved one, or a temporary one.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostInfo {
/// The host's public ID, the one 复制 ID copies.
@@ -172,10 +172,10 @@ pub struct HostInfo {
pub port: u16,
/// The detected operating system, as stored (`ubuntu`, `macos`, …).
pub os: Option<String>,
/// Opened from a bastion host's link (临时连接) rather than saved: listed
/// while its tab is open, under one ID for as long; opened again, it is
/// another host with another ID. Missing from an older app's answer,
/// which listed saved hosts only.
/// Connected to without being saved (so far always an 外部连接, which a
/// bastion host's link opened): listed while its tab is open, under one
/// ID for as long; opened again, it is another host with another ID.
/// Missing from an older app's answer, which listed saved hosts only.
#[serde(default)]
pub temporary: bool,
}
+1 -1
View File
@@ -118,7 +118,7 @@ impl CliTarget {
)
});
let temporary = store
.transient_hosts()
.temporary_hosts()
.iter()
.map(|host| Self::new(host, store.login_of(host), None).temporary());
saved.chain(temporary).collect()
+3 -3
View File
@@ -383,14 +383,14 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
AuthKind::Password,
None,
));
let link = store.insert_transient_unnotified(
let link = store.insert_external_unnotified(
SshLink::parse("ssh://token:secret@10.0.0.9:2222", Some("堡垒机")).unwrap(),
);
let targets = CliTarget::all(&store);
let ids: Vec<HostId> = targets.iter().map(|target| target.host().id).collect();
assert_eq!(ids, [saved, link]);
// With the link's password, which only memory holds.
assert_eq!(targets[1].login().password, SecretRef::transient(link.0));
assert_eq!(targets[1].login().password, SecretRef::temporary(link.0));
let fixture = fixture();
fixture.server.set_targets(targets);
@@ -420,7 +420,7 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
// A table says so where the group goes.
let (_, table, _) = run(&fixture.socket, Request::List { query: None }, false);
assert!(
table.lines().nth(2).unwrap().contains("(临时连接)"),
table.lines().nth(2).unwrap().contains("(外部连接)"),
"{table}"
);
}
+1 -1
View File
@@ -28,5 +28,5 @@ pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_qu
pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key};
pub use secret_fields::SecretFields;
pub use snippet::*;
pub use ssh_link::SshLink;
pub use ssh_link::{LinkKind, SshLink};
pub use store::{HostStore, HostStoreEvent};
+56 -9
View File
@@ -1,6 +1,7 @@
//! `ssh://user[:password]@host[:port]`: how a bastion host opens Xshell, and
//! so ShellRS, on a host the user has not saved. ShellRS connects to it as a
//! 临时连接 that lives only as long as its tabs.
//! so ShellRS, on a host the user has not saved; `sftp://…` the same way is
//! how it opens WinSCP. ShellRS connects to it as an 外部连接: not saved,
//! gone with its tabs, a terminal or an SFTP tab alone.
use std::fmt;
@@ -13,8 +14,18 @@ use super::{AuthKind, HostDraft};
/// The port when the link names none.
const SSH_PORT: u16 = 22;
/// What a link opens, by its scheme.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkKind {
/// `ssh://`, as for Xshell: a terminal.
Ssh,
/// `sftp://`, as for WinSCP: an SFTP tab.
Sftp,
}
/// What a link says to connect to.
pub struct SshLink {
pub kind: LinkKind,
/// The tab's name: what the bastion host called it (Xshell's
/// `-newtab`), or else the address.
pub name: String,
@@ -32,9 +43,15 @@ impl SshLink {
pub fn parse(link: &str, tab: Option<&str>) -> Result<Self, String> {
let link = link.trim();
if link.is_empty() {
return Err("启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口]。".into());
return Err(
"启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。"
.into(),
);
}
let malformed = || "链接的格式不对,应为 ssh://用户[:密码]@地址[:端口]。".to_string();
let malformed = || {
"链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。"
.to_string()
};
// Without `://`, `user:password@host` would read as the scheme
// `user`, and the error would show it.
if !link.contains("://") {
@@ -45,9 +62,15 @@ impl SshLink {
ParseError::EmptyHost => "链接里没有地址。".to_string(),
_ => malformed(),
})?;
if url.scheme() != "ssh" {
return Err(format!("只支持 ssh:// 链接,不支持 {}://。", url.scheme()));
}
let kind = match url.scheme() {
"ssh" => LinkKind::Ssh,
"sftp" => LinkKind::Sftp,
scheme => {
return Err(format!(
"只支持 ssh:// 和 sftp:// 链接,不支持 {scheme}://。"
));
}
};
// `ssh` is not one of the schemes the URL standard knows, so an IPv4
// address and a domain both come back as an opaque domain, which is
// percent-encoded when it is not ASCII.
@@ -67,7 +90,10 @@ impl SshLink {
};
let user = decode(url.username())?;
if user.is_empty() {
return Err("链接里没有用户名,应为 ssh://用户@地址[:端口]。".into());
return Err(format!(
"链接里没有用户名,应为 {}://用户@地址[:端口]。",
url.scheme()
));
}
let password = match url.password() {
Some(password) => Some(Zeroizing::new(decode(password)?)),
@@ -79,6 +105,7 @@ impl SshLink {
.filter(|tab| !tab.is_empty())
.map_or_else(|| address.clone(), str::to_string);
Ok(Self {
kind,
name,
address,
port,
@@ -104,6 +131,7 @@ impl SshLink {
impl fmt::Debug for SshLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshLink")
.field("kind", &self.kind)
.field("name", &self.name)
.field("address", &self.address)
.field("port", &self.port)
@@ -137,6 +165,7 @@ mod tests {
assert_eq!(link.port, 12024);
assert!(link.password.is_none());
assert_eq!(link.name, "172.16.0.28");
assert_eq!(link.kind, LinkKind::Ssh);
let draft = link.draft();
assert_eq!(draft.address.as_ref(), "172.16.0.28");
@@ -170,6 +199,22 @@ mod tests {
);
}
#[test]
fn an_sftp_link_is_read_alike_and_opens_sftp() {
// How JumpServer opens WinSCP.
let link = parse("sftp://b478e26f:p%40ss@172.16.0.28:12024");
assert_eq!(link.kind, LinkKind::Sftp);
assert_eq!(link.user, "b478e26f");
assert_eq!(link.address, "172.16.0.28");
assert_eq!(link.port, 12024);
assert_eq!(link.password.as_deref().map(String::as_str), Some("p@ss"));
assert_eq!(link.draft().auth, AuthKind::Password);
// The port is SSH's when not given, and a path is passed over.
let link = parse("SFTP://root@files.example/srv/data/");
assert_eq!(link.kind, LinkKind::Sftp);
assert_eq!((link.address.as_str(), link.port), ("files.example", 22));
}
#[test]
fn the_user_ends_at_the_last_at_sign() {
// How JumpServer names the account and the asset in one user name.
@@ -197,8 +242,10 @@ mod tests {
for (link, expected) in [
("", "没有要连接的地址"),
(" ", "没有要连接的地址"),
("sftp://root:secret@h", "只支持 ssh://"),
("scp://root:secret@h", "只支持 ssh:// 和 sftp://"),
("ftp://root:secret@h", "只支持 ssh:// 和 sftp://"),
("ssh://h:22", "没有用户名"),
("sftp://h:22", "应为 sftp://用户@地址"),
("ssh://:secret@h", "没有用户名"),
("ssh://root:secret@", "没有地址"),
("ssh://root:secret@h:0", "端口无效"),
+82 -61
View File
@@ -1,11 +1,11 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui_kit::{Context, EventEmitter, SharedString};
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TransientSecretStore};
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore};
use super::database::now_seconds;
use super::private_key::{is_kept_in, write_key_file};
@@ -43,16 +43,23 @@ mod snippets;
/// the system keychain through `secrets`, on a background thread, and report
/// failures through the same event.
///
/// A host opened from an `ssh://` link (a 临时连接, see [`SshLink`]) is kept
/// apart from the saved ones: found by id like any host, so its tabs work as
/// a saved host's do, but never listed, never written to the database and
/// gone with its last tab. Its link's password stays in `secrets`' memory.
/// A temporary host is one connected to without saving it, kept apart from
/// the saved ones: found by id like any host, so its tabs work as a saved
/// host's do, but never listed, never written to the database and gone with
/// its last tab; its password stays in `secrets`' memory. The only kind so
/// far is the external one, which a bastion host opened with an `ssh://` or
/// `sftp://` link (外部连接, see [`SshLink`]) and whose terminals get one
/// channel alone. A
/// 临时连接 typed into ShellRS, to come, will be temporary with every
/// feature.
pub struct HostStore {
groups: Vec<HostGroup>,
hosts: Vec<Host>,
/// Hosts opened from an `ssh://` link: not saved, not listed. Their ids
/// come from `next_host_id` like a saved host's, so they never meet.
transient: Vec<Host>,
/// Hosts connected to without saving them: not listed. Their ids come
/// from `next_host_id` like a saved host's, so they never meet.
temporary: Vec<Host>,
/// The temporary hosts a link from outside opened (外部连接).
external: HashSet<HostId>,
next_host_id: u64,
next_group_id: u64,
active: Option<HostId>,
@@ -76,7 +83,7 @@ pub struct HostStore {
/// Where passwords go: the keychain given to `with_secrets`, under a
/// layer that keeps the links' passwords in memory. Defaults to a store
/// that keeps nothing, so unit tests never touch the machine's keychain.
secrets: Arc<TransientSecretStore>,
secrets: Arc<TemporarySecretStore>,
/// Where the private keys pasted into or generated by the credential
/// form are kept. `None` without a database to remember them, which is
/// also how most tests run: the form then offers key files only.
@@ -108,7 +115,8 @@ impl HostStore {
Self {
groups: Vec::new(),
hosts: Vec::new(),
transient: Vec::new(),
temporary: Vec::new(),
external: HashSet::new(),
next_host_id: 1,
next_group_id: 1,
active: None,
@@ -123,7 +131,7 @@ impl HostStore {
snippet_categories: Vec::new(),
next_snippet_category_id: 1,
database: None,
secrets: Arc::new(TransientSecretStore::new(Arc::new(NoSecretStore))),
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
key_dir: None,
}
}
@@ -131,7 +139,7 @@ impl HostStore {
/// Attach the system keychain. `main` does this once at startup; the UI
/// tests attach an in-memory store instead.
pub fn with_secrets(mut self, secrets: SharedSecretStore) -> Self {
self.secrets = Arc::new(TransientSecretStore::new(secrets));
self.secrets = Arc::new(TemporarySecretStore::new(secrets));
self
}
@@ -186,12 +194,13 @@ impl HostStore {
snippet_categories,
groups,
hosts,
transient: Vec::new(),
temporary: Vec::new(),
external: HashSet::new(),
active: None,
recent,
bookmarks,
database: Some(database),
secrets: Arc::new(TransientSecretStore::new(Arc::new(NoSecretStore))),
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
key_dir: None,
})
}
@@ -269,15 +278,15 @@ impl HostStore {
&self.groups
}
/// The saved hosts. Never the ones opened from a link.
/// The saved hosts. Never the temporary ones.
pub fn hosts(&self) -> &[Host] {
&self.hosts
}
/// A saved host, or one opened from a link.
/// A saved host, or a temporary one.
pub fn host(&self, id: HostId) -> Option<&Host> {
self.saved_host(id)
.or_else(|| self.transient.iter().find(|host| host.id == id))
.or_else(|| self.temporary.iter().find(|host| host.id == id))
}
/// A saved host only: what the database's foreign keys may point at.
@@ -288,56 +297,65 @@ impl HostStore {
fn host_mut(&mut self, id: HostId) -> Option<&mut Host> {
self.hosts
.iter_mut()
.chain(self.transient.iter_mut())
.chain(self.temporary.iter_mut())
.find(|host| host.id == id)
}
/// The hosts opened from a link, oldest first. Only the external CLI
/// lists them; nothing else that lists hosts does.
pub fn transient_hosts(&self) -> &[Host] {
&self.transient
/// The temporary hosts, oldest first. Only the external CLI lists them;
/// nothing else that lists hosts does.
pub fn temporary_hosts(&self) -> &[Host] {
&self.temporary
}
/// Whether `id` was opened from a link and is not saved.
pub fn is_transient(&self, id: HostId) -> bool {
self.transient.iter().any(|host| host.id == id)
/// Whether `id` is connected to without being saved.
pub fn is_temporary(&self, id: HostId) -> bool {
self.temporary.iter().any(|host| host.id == id)
}
/// Connect to what an `ssh://` link names without saving it: a host the
/// tabs find by id, kept in memory until [`Self::remove_transient`],
/// with the link's password in memory too.
pub fn insert_transient(&mut self, link: SshLink, cx: &mut Context<Self>) -> HostId {
let id = self.insert_transient_unnotified(link);
/// Whether `id` is a temporary host a link from outside opened (外部连接),
/// whose terminals get their own channel alone.
pub fn is_external(&self, id: HostId) -> bool {
self.external.contains(&id)
}
/// Connect to what an `ssh://` or `sftp://` link from outside names
/// without saving it (外部连接): a temporary host the tabs find by id,
/// kept in memory until [`Self::remove_temporary`], with the link's
/// password in memory too.
pub fn insert_external(&mut self, link: SshLink, cx: &mut Context<Self>) -> HostId {
let id = self.insert_external_unnotified(link);
cx.notify();
id
}
pub fn insert_transient_unnotified(&mut self, link: SshLink) -> HostId {
pub fn insert_external_unnotified(&mut self, link: SshLink) -> HostId {
let id = HostId(self.next_host_id);
self.next_host_id += 1;
let mut host = Host::new(id, link.draft());
// What the external CLI knows it by while it lasts.
host.public_id = self.unused_public_id();
self.transient.push(host);
self.temporary.push(host);
self.external.insert(id);
if let Some(password) = link.password {
self.secrets.remember(id.0, password);
}
id
}
/// Forget a host opened from a link, with its password and the
/// bookmarks its SFTP tabs made. Its tabs are gone by now.
pub fn remove_transient(&mut self, id: HostId, cx: &mut Context<Self>) {
if self.remove_transient_unnotified(id) {
/// Forget a temporary host, with its password and the bookmarks its SFTP
/// tabs made. Its tabs are gone by now.
pub fn remove_temporary(&mut self, id: HostId, cx: &mut Context<Self>) {
if self.remove_temporary_unnotified(id) {
cx.notify();
}
}
pub fn remove_transient_unnotified(&mut self, id: HostId) -> bool {
let Some(index) = self.transient.iter().position(|host| host.id == id) else {
pub fn remove_temporary_unnotified(&mut self, id: HostId) -> bool {
let Some(index) = self.temporary.iter().position(|host| host.id == id) else {
return false;
};
self.transient.remove(index);
self.temporary.remove(index);
self.external.remove(&id);
self.secrets.forget(id.0);
self.bookmarks.retain(|(host, _), _| *host != id);
if self.active == Some(id) {
@@ -451,7 +469,7 @@ impl HostStore {
PublicId::generate_unused(|id| {
self.hosts
.iter()
.chain(&self.transient)
.chain(&self.temporary)
.any(|host| &host.public_id == id)
})
}
@@ -659,7 +677,7 @@ impl HostStore {
}
// A link's host is not in the database: its bookmarks live as long
// as it does.
if !self.is_transient(id) {
if !self.is_temporary(id) {
self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path));
}
cx.notify();
@@ -688,7 +706,7 @@ impl HostStore {
if !self.remove_bookmark_unnotified(id, side, path) {
return false;
}
if !self.is_transient(id) {
if !self.is_temporary(id) {
self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path));
}
cx.notify();
@@ -722,7 +740,7 @@ impl HostStore {
if !self.move_bookmark_unnotified(id, side, path, to) {
return false;
}
if !self.is_transient(id) {
if !self.is_temporary(id) {
self.persist("调整书签顺序", cx, |db| {
db.set_bookmark_order(id, side, self.bookmarks(id, side))
});
@@ -878,13 +896,13 @@ impl HostStore {
let mut login = self
.direct_login(host)
.with_route(self.route_login(&host.route));
// A link's password is the link's own, never a saved host's for
// A temporary host's password is its own, never a saved host's for
// the same `user@host:port`; and a bastion host that opened a link
// gets the terminal alone.
if self.is_transient(host.id) {
login.password = SecretRef::transient(host.id.0);
login.shell_only = true;
if self.is_temporary(host.id) {
login.password = SecretRef::temporary(host.id.0);
}
login.shell_only = self.is_external(host.id);
login
}
@@ -1262,7 +1280,7 @@ impl HostStore {
if !self.set_state_unnotified(id, state) {
return;
}
if state.is_connected() && !self.is_transient(id) {
if state.is_connected() && !self.is_temporary(id) {
self.persist("记录最近连接", cx, |db| {
db.touch_connected(id, now_seconds())
});
@@ -1274,7 +1292,7 @@ impl HostStore {
/// saved host to the front of the recent list; a link's host never
/// joins it.
pub fn set_state_unnotified(&mut self, id: HostId, state: ConnectionState) -> bool {
let transient = self.is_transient(id);
let temporary = self.is_temporary(id);
let Some(host) = self.host_mut(id) else {
return false;
};
@@ -1282,7 +1300,7 @@ impl HostStore {
return false;
}
host.state = state;
if state.is_connected() && !transient {
if state.is_connected() && !temporary {
self.recent.retain(|recent| *recent != id);
self.recent.insert(0, id);
self.recent.truncate(MAX_RECENT);
@@ -1296,7 +1314,7 @@ impl HostStore {
if !self.set_host_os_unnotified(id, os) {
return;
}
if !self.is_transient(id) {
if !self.is_temporary(id) {
self.persist("记录主机系统", cx, |db| db.set_host_os(id, os));
}
cx.notify();
@@ -1417,7 +1435,7 @@ impl HostStore {
(SecretRef::Proxy { .. }, true) => "代理的密码未能写入系统钥匙串",
(SecretRef::Proxy { .. }, false) => "代理的密码未能从系统钥匙串删除",
// Kept in memory, which does not fail.
(SecretRef::Transient { .. }, _) => "临时连接的密码未能记下",
(SecretRef::Temporary { .. }, _) => "这次连接的密码未能记下",
};
cx.spawn(async move |this, cx| {
let result = cx
@@ -2571,10 +2589,12 @@ mod tests {
let keychain = Arc::new(crate::secrets::InMemorySecretStore::default());
let mut store = HostStore::empty().with_secrets(keychain.clone());
let saved = store.insert_unnotified(draft("web", None));
let id = store.insert_transient_unnotified(link("ssh://token:p%40ss@10.0.0.9:2222"));
let id = store.insert_external_unnotified(link("ssh://token:p%40ss@10.0.0.9:2222"));
assert_ne!(id, saved);
assert!(store.is_transient(id));
assert!(!store.is_transient(saved));
assert!(store.is_temporary(id));
assert!(store.is_external(id));
assert!(!store.is_temporary(saved));
assert!(!store.is_external(saved));
assert_eq!(store.hosts().len(), 1);
assert_eq!(store.host(id).unwrap().name.as_ref(), "堡垒机");
@@ -2586,7 +2606,7 @@ mod tests {
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::transient(id.0));
assert_eq!(login.password, SecretRef::temporary(id.0));
assert!(login.shell_only);
assert!(!store.login(saved).unwrap().shell_only);
assert_eq!(
@@ -2608,9 +2628,10 @@ mod tests {
assert!(store.add_bookmark_unnotified(id, BookmarkSide::Remote, "/tmp"));
store.active = Some(id);
assert!(store.remove_transient_unnotified(id));
assert!(!store.remove_transient_unnotified(id));
assert!(store.remove_temporary_unnotified(id));
assert!(!store.remove_temporary_unnotified(id));
assert!(store.host(id).is_none());
assert!(!store.is_external(id));
assert!(store.active().is_none());
assert!(store.bookmarks(id, BookmarkSide::Remote).is_empty());
assert!(store.secrets().get(&login.password).unwrap().is_none());
@@ -2621,7 +2642,7 @@ mod tests {
#[test]
fn a_saved_host_cannot_lean_on_a_link_host() {
let mut store = HostStore::empty();
let id = store.insert_transient_unnotified(link("ssh://token@10.0.0.9"));
let id = store.insert_external_unnotified(link("ssh://token@10.0.0.9"));
let behind = store.insert_unnotified(
HostDraft::new("db", "10.0.0.8", 22, "root", AuthKind::Password, None)
.with_route(jump_through(&[id])),
@@ -2655,7 +2676,7 @@ mod tests {
});
let id = store.update(cx, |store, cx| {
let id = store.insert_transient(link("ssh://token@10.0.0.9"), cx);
let id = store.insert_external(link("ssh://token@10.0.0.9"), cx);
store.set_state(id, ConnectionState::Connected, cx);
store.set_host_os(id, Some(HostOs::Debian), cx);
store.add_bookmark(id, BookmarkSide::Remote, "/var/log", cx);
+9 -10
View File
@@ -5,7 +5,7 @@
mod keychain;
mod memory;
mod transient;
mod temporary;
use std::path::Path;
use std::sync::Arc;
@@ -15,7 +15,7 @@ use zeroize::Zeroizing;
pub use keychain::KeychainSecretStore;
pub use memory::{InMemorySecretStore, NoSecretStore};
pub use transient::TransientSecretStore;
pub use temporary::TemporarySecretStore;
/// 钥匙串里的服务名,所有条目共用。改了会让已保存的秘密全部失联。
pub const SERVICE: &str = "shellrs";
@@ -44,10 +44,9 @@ pub enum SecretRef {
host: String,
port: u16,
},
/// 临时连接(堡垒机用 `ssh://` 链接打开的、不保存的主机)链接里带的密码,
/// 按这台临时主机的 id 归属。只在 [`TransientSecretStore`] 的内存里,
/// 从不进钥匙串。
Transient { host: u64 },
/// 不保存的连接(外部连接,以后还有临时连接)的密码,按这台临时主机的 id
/// 归属。只在 [`TemporarySecretStore`] 的内存里,从不进钥匙串。
Temporary { host: u64 },
}
impl SecretRef {
@@ -79,8 +78,8 @@ impl SecretRef {
}
}
pub fn transient(host: u64) -> Self {
Self::Transient { host }
pub fn temporary(host: u64) -> Self {
Self::Temporary { host }
}
/// 钥匙串条目的账户名。前缀区分种类,在 Keychain Access 里直接可读。
@@ -90,7 +89,7 @@ impl SecretRef {
Self::Passphrase { key_path } => format!("passphrase:{key_path}"),
Self::Credential { keychain_id } => format!("credential:{keychain_id}"),
Self::Proxy { user, host, port } => format!("proxy:{user}@{host}:{port}"),
Self::Transient { host } => format!("transient:{host}"),
Self::Temporary { host } => format!("temporary:{host}"),
}
}
}
@@ -157,7 +156,7 @@ mod tests {
SecretRef::passphrase("root@10.0.1.12:22"),
SecretRef::credential("root@10.0.1.12:22"),
SecretRef::proxy("root", "10.0.1.12", 22),
SecretRef::transient(22),
SecretRef::temporary(22),
]
.map(|secret| secret.account());
for (ix, account) in accounts.iter().enumerate() {
@@ -6,17 +6,17 @@ use zeroize::Zeroizing;
use super::{SecretRef, SecretStore, SharedSecretStore};
/// 钥匙串外面包的一层:临时连接的密码([`SecretRef::Transient`])只记在这层
/// 内存里,其余的原样交给里面的钥匙串。
/// 钥匙串外面包的一层:不保存的连接(外部连接)的密码([`SecretRef::Temporary`])
/// 只记在这层内存里,其余的原样交给里面的钥匙串。
///
/// `HostStore` 把注入的秘密存储包成这样,连接器读到的也是它,所以终端和 SFTP
/// 用得上链接里的密码,而这个密码不会落进钥匙串;没有钥匙串的机器上照样能用。
pub struct TransientSecretStore {
pub struct TemporarySecretStore {
keychain: SharedSecretStore,
passwords: Mutex<HashMap<u64, Zeroizing<String>>>,
}
impl TransientSecretStore {
impl TemporarySecretStore {
pub fn new(keychain: SharedSecretStore) -> Self {
Self {
keychain,
@@ -41,17 +41,17 @@ impl TransientSecretStore {
}
}
impl SecretStore for TransientSecretStore {
impl SecretStore for TemporarySecretStore {
fn get(&self, secret: &SecretRef) -> Result<Option<Zeroizing<String>>> {
match secret {
SecretRef::Transient { host } => Ok(self.passwords().get(host).cloned()),
SecretRef::Temporary { host } => Ok(self.passwords().get(host).cloned()),
_ => self.keychain.get(secret),
}
}
fn set(&self, secret: &SecretRef, value: &str) -> Result<()> {
match secret {
SecretRef::Transient { host } => {
SecretRef::Temporary { host } => {
self.remember(*host, Zeroizing::new(value.to_string()));
Ok(())
}
@@ -61,7 +61,7 @@ impl SecretStore for TransientSecretStore {
fn delete(&self, secret: &SecretRef) -> Result<()> {
match secret {
SecretRef::Transient { host } => {
SecretRef::Temporary { host } => {
self.forget(*host);
Ok(())
}
@@ -84,8 +84,8 @@ mod tests {
#[test]
fn link_passwords_stay_in_memory_and_the_rest_reaches_the_keychain() {
let keychain = Arc::new(InMemorySecretStore::default());
let store = TransientSecretStore::new(keychain.clone());
let link = SecretRef::transient(7);
let store = TemporarySecretStore::new(keychain.clone());
let link = SecretRef::temporary(7);
store.remember(7, Zeroizing::new("token".into()));
assert_eq!(
store.get(&link).unwrap().as_deref().map(String::as_str),
@@ -101,7 +101,7 @@ mod tests {
Some("hunter2")
);
// Another host's password is not the link's.
assert!(store.get(&SecretRef::transient(8)).unwrap().is_none());
assert!(store.get(&SecretRef::temporary(8)).unwrap().is_none());
store.forget(7);
assert!(store.get(&link).unwrap().is_none());
@@ -110,10 +110,10 @@ mod tests {
#[test]
fn without_a_keychain_link_passwords_still_work() {
let store = TransientSecretStore::new(Arc::new(NoSecretStore));
let store = TemporarySecretStore::new(Arc::new(NoSecretStore));
assert!(!store.is_available());
store.set(&SecretRef::transient(1), "token").unwrap();
assert!(store.get(&SecretRef::transient(1)).unwrap().is_some());
store.set(&SecretRef::temporary(1), "token").unwrap();
assert!(store.get(&SecretRef::temporary(1)).unwrap().is_some());
assert!(
store
.set(&SecretRef::password("root", "h", 22), "x")
+70 -11
View File
@@ -2,8 +2,8 @@
use super::*;
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret, Latency},
host::{AuthKind, Host, HostDraft, HostId, HostLogin},
secrets::InMemorySecretStore,
host::{AuthKind, Host, HostDraft, HostId, HostLogin, HostStore, SshLink},
secrets::{InMemorySecretStore, SharedSecretStore},
ssh::SshConnector,
};
use russh::{
@@ -61,8 +61,19 @@ impl Drop for Handler {
self.ended.fetch_add(1, Ordering::SeqCst);
}
}
/// Who the server lets in without asking anything: a bastion host's token,
/// which is the user name alone.
const TOKEN_USER: &str = "b478e26f-811b-4a90-81c3-74929127898a";
impl server::Handler for Handler {
type Error = anyhow::Error;
async fn auth_none(&mut self, user: &str) -> Result<server::Auth, Self::Error> {
Ok(if user == TOKEN_USER {
server::Auth::Accept
} else {
server::Auth::reject()
})
}
async fn auth_password(
&mut self,
user: &str,
@@ -244,14 +255,6 @@ impl Drop for Worker {
}
}
fn worker(port: u16, data: &std::path::Path) -> Worker {
let provider = SshSftpTransportProvider::new(
SshConnector::new(
data.join("known_hosts"),
Arc::new(InMemorySecretStore::default()),
),
data.join("upload-resume"),
data.join("download-resume"),
);
let host = Host::new(
HostId(1),
HostDraft::new(
@@ -263,7 +266,19 @@ fn worker(port: u16, data: &std::path::Path) -> Worker {
None,
),
);
let transport = provider.create(&HostLogin::of(&host, None));
worker_for(
&HostLogin::of(&host, None),
Arc::new(InMemorySecretStore::default()),
data,
)
}
fn worker_for(login: &HostLogin, secrets: SharedSecretStore, data: &std::path::Path) -> Worker {
let provider = SshSftpTransportProvider::new(
SshConnector::new(data.join("known_hosts"), secrets),
data.join("upload-resume"),
data.join("download-resume"),
);
let transport = provider.create(login);
let (commands, rx) = async_channel::unbounded();
let (tx, events) = async_channel::unbounded();
let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap());
@@ -438,6 +453,50 @@ fn a_connection_reports_its_round_trip_right_after_connecting() {
});
}
/// A bastion host lets WinSCP, and so ShellRS, in on its token alone: an
/// `sftp://token@…` link without a password connects without asking for
/// one, as Xshell's `ssh://` does.
#[test]
fn a_link_without_a_password_connects_where_the_server_asks_for_none() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let Some(server) = server(temp.path().into(), 0, false).await else {
return;
};
let mut store = HostStore::empty();
let link = format!("sftp://{TOKEN_USER}@127.0.0.1:{}", server.port);
let host = store.insert_external_unnotified(SshLink::parse(&link, None).unwrap());
let worker = worker_for(&store.login(host).unwrap(), store.secrets(), temp.path());
loop {
let event = tokio::time::timeout(Duration::from_secs(25), worker.events.recv())
.await
.expect("worker timed out")
.unwrap();
match event {
SftpEvent::Prompt(prompt) => {
assert!(
matches!(prompt.kind(), ConnectionPromptKind::UnknownHost(_)),
"asked for more than trusting the host key"
);
worker
.commands
.send(SftpCommand::PromptReply {
request_id: prompt.request_id(),
reply: ConnectionPromptReply::TrustAndSave,
})
.await
.unwrap();
}
SftpEvent::Connected { .. } => break,
SftpEvent::Disconnected(error) => panic!("connect failed: {error}"),
_ => {}
}
}
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
assert_eq!(server.shells.load(Ordering::SeqCst), 0);
});
}
#[test]
fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() {
runtime().block_on(async {
+2 -2
View File
@@ -129,7 +129,7 @@ impl TerminalPanel {
host_is_ip: store
.host(self.host_id)
.is_some_and(|host| host.address_is_ip()),
editable: !store.is_transient(self.host_id),
editable: !store.is_temporary(self.host_id),
group: self.tab_group.clone(),
panel: cx.entity_id(),
}
@@ -317,7 +317,7 @@ struct TabMenu {
id: RemoteTerminalId,
host_id: HostId,
host_is_ip: bool,
/// A saved host; one opened from a link has nothing to edit.
/// A saved host; a temporary one has nothing to edit.
editable: bool,
group: Option<WeakEntity<TabGroup>>,
panel: EntityId,
+29 -16
View File
@@ -1,46 +1,59 @@
//! The workspace's share of the links ShellRS is opened with (临时连接):
//! The workspace's share of the links ShellRS is opened with (外部连接):
//! a bastion host runs `ShellRS ssh://user@host:port`, the way it runs
//! Xshell, and ShellRS connects without saving the host. The host goes with
//! its last tab.
//! Xshell, or `ShellRS sftp://…`, the way it runs WinSCP, and ShellRS
//! connects without saving the host. The host goes with its last tab.
use gpui_kit::component::{WindowExt as _, notification::Notification};
use gpui_kit::component::{WindowExt as _, dock::DockPlacement, notification::Notification};
use gpui_kit::*;
use crate::cli::OpenLink;
use crate::host::{HostId, SshLink};
use crate::host::{HostId, LinkKind, SshLink};
use super::workspace_view::Workspace;
impl Workspace {
/// Connect to what `link` names in a terminal tab of its own, or say
/// what is wrong with it.
/// Connect to what `link` names in a tab of its own across the window,
/// a terminal or an SFTP tab by the link's scheme, or say what is wrong
/// with it.
pub fn open_link(&mut self, link: OpenLink, window: &mut Window, cx: &mut Context<Self>) {
match SshLink::parse(&link.url, link.tab.as_deref()) {
Ok(link) => {
let kind = link.kind;
let host = self
.store
.update(cx, |store, cx| store.insert_transient(link, cx));
self.connect_host(host, window, cx);
.update(cx, |store, cx| store.insert_external(link, cx));
match kind {
LinkKind::Ssh => self.connect_host(host, window, cx),
LinkKind::Sftp => self.open_explorer(host, window, cx),
}
// The tab is what the bastion host opened ShellRS for: the
// hosts make way, as with ⌘B, which brings them back.
if self.dock_area.read(cx).is_dock_open(DockPlacement::Left) {
self.dock_area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Left, window, cx);
});
}
}
Err(problem) => {
window.push_notification(Notification::error(problem).title("无法打开链接"), cx)
}
Err(problem) => window
.push_notification(Notification::error(problem).title("无法打开 SSH 链接"), cx),
}
}
/// Forget a host opened from a link once its last tab has closed,
/// with whatever it was still asking.
pub(super) fn forget_unused_link_host(
/// Forget a temporary host once its last tab has closed, with whatever
/// it was still asking.
pub(super) fn forget_unused_temporary_host(
&mut self,
host: HostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.store.read(cx).is_transient(host) || self.has_tabs(host, cx) {
if !self.store.read(cx).is_temporary(host) || self.has_tabs(host, cx) {
return;
}
self.cancel_prompts_for_host(host, window, cx);
self.store
.update(cx, |store, cx| store.remove_transient(host, cx));
.update(cx, |store, cx| store.remove_temporary(host, cx));
}
}
+3 -3
View File
@@ -56,15 +56,15 @@ impl Workspace {
}
/// The tools the switch offers for the SSH terminal in front; none
/// without one. A terminal a bastion host opened with a link gets only
/// those that run nothing beside it.
/// without one. An external connection (a bastion host's link) gets only
/// those that run nothing beside the terminal.
pub(super) fn offered_tools(&self, cx: &App) -> Vec<ToolKind> {
let Some(terminal) = self.tool_terminal(cx) else {
return Vec::new();
};
let store = self.store.read(cx);
let os = store.host(terminal.host).and_then(|host| host.os);
let shell_only = store.is_transient(terminal.host);
let shell_only = store.is_external(terminal.host);
ToolKind::ALL
.into_iter()
.filter(|tool| tool.works_on(os) && !(shell_only && tool.runs_commands()))
+11 -3
View File
@@ -1039,7 +1039,15 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
let host_id = action.0;
self.open_explorer(action.0, window, cx);
}
pub(super) fn open_explorer(
&mut self,
host_id: HostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.store.read(cx).host(host_id).is_none() {
return;
}
@@ -2000,7 +2008,7 @@ fn new_terminal_panel(
this.store
.update(cx, |store, cx| store.set_active(None, cx));
}
this.forget_unused_link_host(*host_id, window, cx);
this.forget_unused_temporary_host(*host_id, window, cx);
}
TerminalPanelEvent::StatusChanged(_, host_id) => {
this.refresh_host_connection_state(*host_id, cx);
@@ -2110,7 +2118,7 @@ fn new_explorer_panel(
this.set_active_tab(None, window, cx);
}
this.refresh_host_connection_state(*host_id, cx);
this.forget_unused_link_host(*host_id, window, cx);
this.forget_unused_temporary_host(*host_id, window, cx);
}
ExplorerPanelEvent::StateChanged(id, host_id) => {
if let Some(panel) = this.explorers.get(id) {
+10
View File
@@ -194,6 +194,12 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
Some(0)
);
assert_eq!(open_shellrs(data_dir.path(), &[token]), Some(0));
// As for WinSCP.
let winscp = "sftp://deploy:p%40ss@10.0.0.9:2222";
assert_eq!(
open_shellrs(data_dir.path(), &["/sessionname=文件", winscp]),
Some(0)
);
assert_eq!(
server.take_activation(),
Some(vec![
@@ -205,6 +211,10 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
url: token.into(),
tab: None,
},
OpenLink {
url: winscp.into(),
tab: Some("文件".into()),
},
])
);
}
+66 -5
View File
@@ -616,15 +616,17 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::transient(LINK_HOST.0));
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("terminal-tab", LINK_TERMINAL)).label(),
Some("db-prod")
);
// Not saved: no row in the tree.
// Not saved, and across the window: the hosts made way.
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
assert_eq!(window.find("show-hosts").checked(), Some(false));
assert!(window.try_find("host-search").is_none());
// The bastion host gets the terminal alone: of the tools, only the
// snippets, which type into it.
assert!(window.try_find("tool-snippets").is_some());
@@ -636,14 +638,14 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
assert!(login.shell_only);
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_transient(LINK_HOST));
assert!(store.is_temporary(LINK_HOST));
assert!(store.hosts().is_empty());
assert!(store.recent_hosts().next().is_none());
// The link's password is in memory, not in the keychain.
assert_eq!(
store
.secrets()
.get(&SecretRef::transient(LINK_HOST.0))
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.as_deref()
.map(String::as_str),
@@ -697,7 +699,7 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
assert!(
store
.secrets()
.get(&SecretRef::transient(LINK_HOST.0))
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.is_none()
);
@@ -709,6 +711,63 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut
assert!(saved.bookmarks(LINK_HOST, BookmarkSide::Remote).is_empty());
}
#[gpui_kit::test]
async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContext) {
use shellrs::app::CloseExplorer;
let remote = Arc::new(RecordingRemoteProvider::default());
let (handle, workspace) = open_workspace_with_credentials(
cx,
HostStore::empty(),
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
// How JumpServer opens WinSCP: the link alone.
open_link(
cx,
handle,
&workspace,
"sftp://token:p%40ss@10.0.0.9:2222",
None,
);
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 10.0.0.9")
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("explorer-tab", SFTP_TAB)).label(),
Some("10.0.0.9 · SFTP")
);
// An SFTP tab alone, across the window.
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
assert_eq!(window.find("show-hosts").checked(), Some(false));
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
})
.unwrap();
assert!(remote.logins().is_empty());
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_external(LINK_HOST));
let login = store.login(LINK_HOST).unwrap();
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "token")
);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
});
// It goes with its tab.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx);
})
.unwrap();
cx.run_until_parked();
store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none()));
}
#[gpui_kit::test]
async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_credentials(
@@ -722,6 +781,8 @@ async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppC
window.render_frame(cx);
assert_eq!(window.notifications(cx).len(), 1);
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
// Nothing opened, so the hosts stay.
assert_eq!(window.find("show-hosts").checked(), Some(true));
})
.unwrap();
workspace.read_with(cx, |workspace, cx| {