From 3c0871eb4edb3b7c2397c5b6447786f0162e1f4e Mon Sep 17 00:00:00 2001 From: Xuz Date: Sat, 3 Oct 2026 11:44:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=96=E9=83=A8=20CLI=EF=BC=9Ashellrs=20list?= =?UTF-8?q?=20=E4=B9=9F=E5=88=97=E5=87=BA=E4=B8=B4=E6=97=B6=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 堡垒机打开的临时连接排在保存的主机后面,JSON 里 "temporary": true,表格的 分组一栏写「(临时连接)」。标签开着期间 ID 不变;每条命令照常新建连接,用 内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录。旧版应用的回答缺 这个字段时读成保存的主机,不升协议版本。skill 里写明这些。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +-- docs/manual.md | 5 +-- src/cli/SKILL.md | 1 + src/cli/client.rs | 6 +++- src/cli/protocol.rs | 8 ++++- src/cli/server.rs | 41 ++++++++++++++++--------- src/cli/tests.rs | 54 ++++++++++++++++++++++++++++++--- src/host/store.rs | 18 +++++++++-- src/workspace/workspace_view.rs | 5 +-- 9 files changed, 111 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c7b56c4..44820ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,9 +135,9 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `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()` 不返回它,所以主机树、开始页、外部 CLI、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。 + - `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`。 - 登录用「密码」方式,`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`、`on_copy_host_id` 只认保存的主机。 + - 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_link_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_transient`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。 - 链接解析失败(不是 `ssh://`、缺用户名 / 地址、端口不对)弹「无法打开 SSH 链接」通知,文字不带链接原文(可能有密码)。第二个进程在 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 测试,改动后要在真实窗口里试。 diff --git a/docs/manual.md b/docs/manual.md index ce1efc6..7e63fe3 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -118,7 +118,7 @@ shellrs upload <本地路径> <远程路径> shellrs download <远程路径> <本地路径> ``` -- `` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 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。 @@ -134,13 +134,14 @@ ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名 ``` - 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),不分大小写;别的选项忽略。只支持 `ssh://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。 -- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库,外部 CLI 也看不到;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接、右侧栏的工具)和保存的主机一样。 +- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接、右侧栏的工具)和保存的主机一样。 - 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。 - ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://`、没有用户名或地址、端口不对)时,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 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。 - 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。 ## 标签页 diff --git a/src/cli/SKILL.md b/src/cli/SKILL.md index 91d356f..e07c3a4 100644 --- a/src/cli/SKILL.md +++ b/src/cli/SKILL.md @@ -65,6 +65,7 @@ The exit code is in `$LASTEXITCODE`. - `shellrs exec` takes a host id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code. - The remote command gets no stdin, so interactive programs (editors, pagers, password prompts) will not work. Use non-interactive flags such as `--yes`, `-y`, or `--no-pager`. - Long-running tasks should use tmux, nohup, or systemd on the remote host. +- Hosts with `"temporary": true` in `shellrs list` were opened in ShellRS from a bastion host's link rather than saved. Their id stays the same while their tab is open in ShellRS, so keep using it for every command; there is no need to list before each one. Once the tab is closed the id is gone (`host_not_found`), and opening the host from the bastion again gives it a new id: list again only then. Every command logs in to them again, which a bastion host may refuse if it gave out a one-time login; report that to the user instead of retrying. - `shellrs upload` takes a host id, a local file or folder path, and a remote destination path. `shellrs download` takes a host id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten. - Destination paths follow scp: if the destination exists and is a directory, the source keeps its name inside that directory; otherwise the destination path is the final file or folder path, and its parent directory must exist. diff --git a/src/cli/client.rs b/src/cli/client.rs index 4952f90..bcd6d66 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -253,7 +253,11 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> { [ host.id.clone(), host.name.clone(), - host.group.clone().unwrap_or_default(), + if host.temporary { + "(临时连接)".to_string() + } else { + host.group.clone().unwrap_or_default() + }, host.address(), host.os .as_deref() diff --git a/src/cli/protocol.rs b/src/cli/protocol.rs index 4c4210f..34938bf 100644 --- a/src/cli/protocol.rs +++ b/src/cli/protocol.rs @@ -159,7 +159,7 @@ pub enum Reply { }, } -/// A saved host as the CLI lists it. +/// A host as the CLI lists it: a saved one, or one opened from a link. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct HostInfo { /// The host's public ID, the one 复制 ID copies. @@ -172,6 +172,12 @@ pub struct HostInfo { pub port: u16, /// The detected operating system, as stored (`ubuntu`, `macos`, …). pub os: Option, + /// 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. + #[serde(default)] + pub temporary: bool, } impl HostInfo { diff --git a/src/cli/server.rs b/src/cli/server.rs index ebfb8b5..2a357cd 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -79,12 +79,19 @@ impl CliTarget { host: host.address.to_string(), port: host.port, os: host.os.map(|os| os.as_str().to_string()), + temporary: false, }, host: host.clone(), login, } } + /// Marked as opened from a link, not saved. + fn temporary(mut self) -> Self { + self.info.temporary = true; + self + } + pub fn host(&self) -> &Host { &self.host } @@ -94,23 +101,27 @@ impl CliTarget { &self.login } - /// Every host in `store`, with its group path. + /// Every host in `store`, with its group path, then the ones opened + /// from a link. A request logs in to those afresh too, with the link's + /// user and password; a bastion host may refuse a login it gave out for + /// one use. pub fn all(store: &HostStore) -> Vec { - store - .hosts() + let saved = store.hosts().iter().map(|host| { + let names = host + .group + .map(|id| store.group_names(id)) + .unwrap_or_default(); + Self::new( + host, + store.login_of(host), + (!names.is_empty()).then(|| names.join("/")), + ) + }); + let temporary = store + .transient_hosts() .iter() - .map(|host| { - let names = host - .group - .map(|id| store.group_names(id)) - .unwrap_or_default(); - Self::new( - host, - store.login_of(host), - (!names.is_empty()).then(|| names.join("/")), - ) - }) - .collect() + .map(|host| Self::new(host, store.login_of(host), None).temporary()); + saved.chain(temporary).collect() } /// The host tree's search, plus the group path and the ID. diff --git a/src/cli/tests.rs b/src/cli/tests.rs index f82f7dc..ca802b9 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -17,6 +17,7 @@ use super::{Cli, Command}; use super::{ConsoleText, normalize_command}; use crate::app::cli_endpoint; use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink}; +use crate::secrets::SecretRef; use crate::ssh::ExecStream; /// Records what it was asked and answers from a script. @@ -372,7 +373,7 @@ fn opening_shellrs_with_links_hands_them_to_the_running_one_in_order() { } #[test] -fn a_host_opened_from_a_link_is_not_the_commands() { +fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() { let mut store = HostStore::empty(); let saved = store.insert_unnotified(HostDraft::new( "web", @@ -382,10 +383,55 @@ fn a_host_opened_from_a_link_is_not_the_commands() { AuthKind::Password, None, )); - store.insert_transient_unnotified(SshLink::parse("ssh://token@10.0.0.9", None).unwrap()); + let link = store.insert_transient_unnotified( + SshLink::parse("ssh://token:secret@10.0.0.9:2222", Some("堡垒机")).unwrap(), + ); let targets = CliTarget::all(&store); - assert_eq!(targets.len(), 1); - assert_eq!(targets[0].host().id, saved); + let ids: Vec = 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)); + + let fixture = fixture(); + fixture.server.set_targets(targets); + let (code, stdout, _) = run(&fixture.socket, Request::List { query: None }, true); + assert_eq!(code, 0); + let hosts: Vec = serde_json::from_str(&stdout).unwrap(); + let listed: Vec<(&str, bool)> = hosts + .iter() + .map(|host| (host.name.as_str(), host.temporary)) + .collect(); + assert_eq!(listed, [("web", false), ("堡垒机", true)]); + let temporary = store.host(link).unwrap().public_id.to_string(); + assert_eq!(hosts[1].id, temporary); + assert_eq!(hosts[1].address(), "token@10.0.0.9:2222"); + + // Reached by that ID like a saved host. + let (code, _, _) = run( + &fixture.socket, + Request::Exec { + host: temporary, + command: "uptime".into(), + }, + false, + ); + assert_eq!(code, 3); + + // 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}" + ); +} + +#[test] +fn an_older_apps_list_reads_as_saved_hosts() { + let host: HostInfo = serde_json::from_str( + r#"{"id":"VmLkf1snMOuPKJ07","name":"web","group":null,"user":"root","host":"10.0.1.12","port":22,"os":null}"#, + ) + .unwrap(); + assert!(!host.temporary); } #[test] diff --git a/src/host/store.rs b/src/host/store.rs index 6ae30ae..7750cbd 100644 --- a/src/host/store.rs +++ b/src/host/store.rs @@ -292,6 +292,12 @@ impl HostStore { .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 + } + /// 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) @@ -309,7 +315,10 @@ impl HostStore { pub fn insert_transient_unnotified(&mut self, link: SshLink) -> HostId { let id = HostId(self.next_host_id); self.next_host_id += 1; - self.transient.push(Host::new(id, link.draft())); + 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); if let Some(password) = link.password { self.secrets.remember(id.0, password); } @@ -439,7 +448,12 @@ impl HostStore { /// A [`PublicId`] no host in the store has yet. fn unused_public_id(&self) -> PublicId { - PublicId::generate_unused(|id| self.hosts.iter().any(|s| &s.public_id == id)) + PublicId::generate_unused(|id| { + self.hosts + .iter() + .chain(&self.transient) + .any(|host| &host.public_id == id) + }) } /// Replace the editable fields of a host; connection state is kept. diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index af1ea48..3f0e63f 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -1513,13 +1513,10 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - // The CLI knows saved hosts only: a link's host has no ID to give. let Some(public_id) = self .store .read(cx) - .hosts() - .iter() - .find(|host| host.id == action.0) + .host(action.0) .map(|host| host.public_id.clone()) else { return;