diff --git a/CLAUDE.md b/CLAUDE.md index 0c04174..5a30b01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 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 发布版里没有控制台,所以错误一律由正在运行的那个显示。 diff --git a/docs/manual.md b/docs/manual.md index 7e63fe3..81dca35 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -134,7 +134,8 @@ ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名 ``` - 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),不分大小写;别的选项忽略。只支持 `ssh://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。 -- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接、右侧栏的工具)和保存的主机一样。 +- **临时连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和从它打开的 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。 +- **只用终端这一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话,所以临时连接不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。 - 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。 - ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。 - 堡垒机要调用的程序: diff --git a/src/host/login.rs b/src/host/login.rs index d4c85e3..20aee45 100644 --- a/src/host/login.rs +++ b/src/host/login.rs @@ -44,6 +44,12 @@ pub struct HostLogin { pub password: SecretRef, /// How the connection reaches the host. pub route: LoginRoute, + /// The connection carries the terminal and nothing beside it: no + /// channel of its own for the system probe or the right sidebar's + /// commands. A bastion host that opened ShellRS with a link serves one + /// session per connection and ends it when another channel comes and + /// goes, as Xshell and OpenSSH never make it do. + pub shell_only: bool, } /// How a connection reaches a host, with every jump host's login looked up. @@ -149,6 +155,7 @@ impl HostLogin { method, key_path: None, route: LoginRoute::Direct, + shell_only: false, } } @@ -177,6 +184,7 @@ impl HostLogin { key_path, password, route: LoginRoute::Direct, + shell_only: false, } } diff --git a/src/host/store.rs b/src/host/store.rs index 7750cbd..479cd50 100644 --- a/src/host/store.rs +++ b/src/host/store.rs @@ -879,9 +879,11 @@ impl HostStore { .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 - // the same `user@host:port`. + // 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; } login } @@ -2585,6 +2587,8 @@ mod tests { assert_eq!(login.method, LoginMethod::Password); assert_eq!(login.route, LoginRoute::Direct); assert_eq!(login.password, SecretRef::transient(id.0)); + assert!(login.shell_only); + assert!(!store.login(saved).unwrap().shell_only); assert_eq!( store .secrets() diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs index e9ab0ac..74c9fd5 100644 --- a/src/ssh/connection.rs +++ b/src/ssh/connection.rs @@ -57,6 +57,13 @@ impl From<&HostLogin> for SshConnectionConfig { } } +impl SshConnectionConfig { + /// See [`HostLogin::shell_only`]. + pub(crate) fn shell_only(&self) -> bool { + self.login.shell_only + } +} + /// Where the SSH agent listens. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum AgentLocation { diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index de76f77..d696a0c 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -38,6 +38,9 @@ const EXEC_TIMEOUT: Duration = Duration::from_secs(10); /// dropped. const EXEC_OUTPUT_LIMIT: usize = 1 << 20; +/// The answer to a command for a terminal that carries nothing beside it. +const SHELL_ONLY: &str = "这个终端经由堡垒机打开,只能使用终端本身,不能另外执行命令"; + /// Production remote-terminal adapter for the shared SSH connector. pub struct SshTerminalTransportProvider { connector: SshConnector, @@ -189,8 +192,15 @@ impl SshTerminalTransport { // it after `Started` keeps the terminal from waiting on a round trip, // and a host that refuses or ignores the probe simply keeps whatever // mark the host already had. + // Not through a bastion host that opened a link: another channel + // ends its session (see `HostLogin::shell_only`). + let shell_only = self.config.shell_only(); let mut probe = HostOsProbe::new(); - let mut probe_channel = open_probe(&handle, probe.command()).await; + let mut probe_channel = if shell_only { + None + } else { + open_probe(&handle, probe.command()).await + }; // Round trips are measured with `keepalive@openssh.com`, which every // server answers (a refusal is an answer too), so this is the SSH-level @@ -242,6 +252,9 @@ impl SshTerminalTransport { router.abort(); return Ok(()); } + Some(TerminalTransportCommand::Exec(request)) if shell_only => { + let _ = request.reply.send(Err(SHELL_ONLY.into())); + } Some(TerminalTransportCommand::Exec(request)) => exec_queue.push_back(request), Some(TerminalTransportCommand::PromptReply { .. }) => {} }, @@ -1691,6 +1704,42 @@ mod tests { assert_eq!(execs, ["echo one", "echo two"]); } + #[test] + fn a_terminal_a_bastion_host_opened_carries_nothing_beside_it() { + let Some(server) = start_server_replying(TestAuth::Password, tool_reply) else { + eprintln!( + "loopback sockets are unavailable in this sandbox; skipping integration body" + ); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + let mut login = HostLogin::of(&password_host(server.port), None); + login.shell_only = true; + + let report = connect( + login, + &known_hosts, + Arc::new(NoSecretStore), + true, + &["echo one"], + trust_and_type_password, + ); + + // No probe, and a tool's command answered here without asking the + // server; the round trip is still measured. + assert_eq!(report.execs, vec![Err(SHELL_ONLY.to_string())]); + assert_eq!(report.host_os, None); + assert!(report.latency.is_some()); + let execs = server + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .execs + .clone(); + assert!(execs.is_empty(), "{execs:?}"); + } + #[test] fn a_host_that_has_no_uname_is_asked_again_the_windows_way() { let Some(server) = start_server_replying(TestAuth::Password, windows_probe_reply) else { diff --git a/src/workspace/tool_sidebar.rs b/src/workspace/tool_sidebar.rs index 0ebcad5..7b36efe 100644 --- a/src/workspace/tool_sidebar.rs +++ b/src/workspace/tool_sidebar.rs @@ -45,6 +45,13 @@ impl ToolKind { }) } + /// Whether the tool runs commands on a channel of its own beside the + /// terminal, which a terminal a bastion host opened cannot have. Command + /// snippets only type into the terminal. + pub fn runs_commands(self) -> bool { + !matches!(self, ToolKind::Snippets) + } + /// Whether the tool works on a host running `os`. A host not identified /// yet gets every tool, and is told if one cannot work there. pub fn works_on(self, os: Option) -> bool { diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index 467c8b1..1f414b9 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -56,19 +56,18 @@ impl Workspace { } /// The tools the switch offers for the SSH terminal in front; none - /// without one. + /// without one. A terminal a bastion host opened with a link gets only + /// those that run nothing beside it. pub(super) fn offered_tools(&self, cx: &App) -> Vec { let Some(terminal) = self.tool_terminal(cx) else { return Vec::new(); }; - let os = self - .store - .read(cx) - .host(terminal.host) - .and_then(|host| host.os); + 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); ToolKind::ALL .into_iter() - .filter(|tool| tool.works_on(os)) + .filter(|tool| tool.works_on(os) && !(shell_only && tool.runs_commands())) .collect() } diff --git a/tests/ui/connection.rs b/tests/ui/connection.rs index 78434cd..fe6dd32 100644 --- a/tests/ui/connection.rs +++ b/tests/ui/connection.rs @@ -625,8 +625,15 @@ async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut ); // Not saved: no row in the tree. assert!(window.try_find(("host-row", LINK_HOST.0)).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()); + for tool in ["tool-history", "tool-docker", "tool-monitor"] { + assert!(window.try_find(tool).is_none(), "{tool}"); + } }) .unwrap(); + 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));