Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd34edfc1c | ||
|
|
4fc7704954 |
No files matched your search
@@ -17,6 +17,12 @@ ShellRS 的第一个公开版本。
|
||||
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
|
||||
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
|
||||
|
||||
## [0.1.0-beta.8]
|
||||
|
||||
### 修复
|
||||
|
||||
- 从堡垒机打开的连接登录后立即断开。
|
||||
|
||||
## [0.1.0-beta.7]
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 弹通知说明原因。
|
||||
- 堡垒机要调用的程序:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user