Compare commits

..
Author SHA1 Message Date
XuzandClaude Opus 5.5 fd34edfc1c 版本号改为 0.1.0-beta.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:40:05 +08:00
XuzandClaude Opus 5.5 4fc7704954 临时连接只用终端这一个通道
用户的堡垒机在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会
话:正替终端登录目标机时到来,终端报「No more authentication methods
available」;探测跑完通道一关,刚出提示符就退出。Xshell 和 OpenSSH 只开终
端一个通道。HostLogin 加 shell_only(临时主机置上):不做系统探测,工具的
命令直接拒绝、不发给服务器,右侧栏只给不执行命令的命令片段。延迟测量是全局
请求,照常。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:39:46 +08:00
12 changed files with 101 additions and 12 deletions

No files matched your search

+6
View File
@@ -17,6 +17,12 @@ ShellRS 的第一个公开版本。
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
## [0.1.0-beta.8]
### 修复
- 从堡垒机打开的连接登录后立即断开。
## [0.1.0-beta.7]
### 修复
+1
View File
@@ -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 发布版里没有控制台,所以错误一律由正在运行的那个显示。
Generated
+1 -1
View File
@@ -7686,7 +7686,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.0-beta.7"
version = "0.1.0-beta.8"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.0-beta.7"
version = "0.1.0-beta.8"
edition = "2024"
publish = false
description = "SSH client and host manager: terminal, SFTP, port forwarding"
+2 -1
View File
@@ -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 弹通知说明原因。
- 堡垒机要调用的程序:
+8
View File
@@ -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,
}
}
+5 -1
View File
@@ -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()
+7
View File
@@ -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 {
+50 -1
View File
@@ -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 {
+7
View File
@@ -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<HostOs>) -> bool {
+6 -7
View File
@@ -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<ToolKind> {
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()
}
+7
View File
@@ -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));