外部 CLI:shellrs list 也列出临时连接
堡垒机打开的临时连接排在保存的主机后面,JSON 里 "temporary": true,表格的 分组一栏写「(临时连接)」。标签开着期间 ID 不变;每条命令照常新建连接,用 内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录。旧版应用的回答缺 这个字段时读成保存的主机,不升协议版本。skill 里写明这些。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
87c5cfb120
commit
3c0871eb4e
9 files changed
+111
-31
No files matched your search
@@ -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 测试,改动后要在真实窗口里试。
|
||||
|
||||
+3
-2
@@ -118,7 +118,7 @@ shellrs upload <ID> <本地路径> <远程路径>
|
||||
shellrs download <ID> <远程路径> <本地路径>
|
||||
```
|
||||
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 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 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
|
||||
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
|
||||
|
||||
## 标签页
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+5
-1
@@ -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()
|
||||
|
||||
+7
-1
@@ -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<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.
|
||||
#[serde(default)]
|
||||
pub temporary: bool,
|
||||
}
|
||||
|
||||
impl HostInfo {
|
||||
|
||||
+26
-15
@@ -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<Self> {
|
||||
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.
|
||||
|
||||
+50
-4
@@ -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<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));
|
||||
|
||||
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<HostInfo> = 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]
|
||||
|
||||
+16
-2
@@ -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.
|
||||
|
||||
@@ -1513,13 +1513,10 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// 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;
|
||||
|
||||
Reference in new issue
Block a user