只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。 --terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出 交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和 执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。 外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。 shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
17 KiB
Rust
449 lines
17 KiB
Rust
//! The workspace's share of the external CLI: coming forward when ShellRS
|
|
//! is opened again, the changes to hosts and credentials a `shellrs`
|
|
//! command asks for, and the `exec --terminal` runs. The request threads
|
|
//! cannot reach the store or the terminals; the changes and the runs wait
|
|
//! in the server until they are taken up here.
|
|
|
|
use std::time::Duration;
|
|
|
|
use gpui_kit::*;
|
|
|
|
use crate::analytics::Counter;
|
|
use crate::cli::{
|
|
CliChange, CliError, CliUse, CredentialDeleted, CredentialFields, ErrorCode, HostDeleted,
|
|
HostFields, Reply,
|
|
};
|
|
use crate::cli::{
|
|
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
|
|
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
|
|
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
|
|
};
|
|
use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore};
|
|
use crate::i18n::t;
|
|
use crate::secrets::{SecretRef, SharedSecretStore};
|
|
use crate::terminal::{RunFailure, RunHandle, TerminalLifecycle, TerminalPanel};
|
|
|
|
use super::workspace_view::Workspace;
|
|
|
|
/// How often the app looks for what the CLI server has heard.
|
|
const CLI_POLL: Duration = Duration::from_millis(100);
|
|
|
|
impl Workspace {
|
|
/// Bring the window forward whenever ShellRS is opened while it is
|
|
/// already running, and make the changes the CLI asks for. The second
|
|
/// copy of ShellRS only passes the word on and exits: two of them on
|
|
/// one data directory would each keep their own copy of the hosts and
|
|
/// write over the other's changes.
|
|
///
|
|
/// Asked on a timer, like every other worker: the thread that hears the
|
|
/// request never wakes the window itself. A change is answered once
|
|
/// made, the server's snapshot already showing it, and only then is the
|
|
/// next one taken.
|
|
pub(super) fn serve_cli(&self, window: &mut Window, cx: &mut Context<Self>) {
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
loop {
|
|
cx.background_executor().timer(CLI_POLL).await;
|
|
let taken = this.update_in(cx, |this, window, cx| {
|
|
let server = this.cli_server.as_ref()?;
|
|
let links = server.take_activation();
|
|
let change = server.take_change();
|
|
for usage in server.take_usage() {
|
|
this.count(match usage {
|
|
CliUse::Exec => Counter::CliExec,
|
|
CliUse::ExecTerminal => Counter::CliExecTerminal,
|
|
CliUse::Upload => Counter::CliUpload,
|
|
CliUse::Download => Counter::CliDownload,
|
|
CliUse::Sync => Counter::CliSync,
|
|
CliUse::Hosts => Counter::CliHosts,
|
|
CliUse::Credentials => Counter::CliCredentials,
|
|
});
|
|
}
|
|
if let Some(links) = links {
|
|
crate::app::bring_forward(window, cx);
|
|
for link in links {
|
|
this.open_link(link, window, cx);
|
|
}
|
|
}
|
|
change.map(|(change, reply)| (this.apply_cli_change(change, window, cx), reply))
|
|
});
|
|
match taken {
|
|
Ok(Some((outcome, reply))) => reply.send(outcome.await),
|
|
Ok(None) => {}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
/// Hand each `exec --terminal` run the CLI server has heard to its
|
|
/// host's terminal. Asked on a timer of its own: the poller above waits
|
|
/// for each change to be made, and a run must not wait behind a
|
|
/// keychain write.
|
|
pub(super) fn serve_cli_runs(&self, window: &mut Window, cx: &mut Context<Self>) {
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
loop {
|
|
cx.background_executor().timer(CLI_POLL).await;
|
|
let served = this.update(cx, |this, cx| {
|
|
let Some(server) = this.cli_server.as_ref() else {
|
|
return;
|
|
};
|
|
let runs: Vec<_> = std::iter::from_fn(|| server.take_run()).collect();
|
|
for (host, run) in runs {
|
|
if let Err(failure) = this.start_cli_run(&host, run.clone(), cx) {
|
|
run.fail(failure);
|
|
}
|
|
}
|
|
});
|
|
if served.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
/// Type an `exec --terminal` run into the terminal of the host the CLI
|
|
/// calls `host`: the one the right sidebar follows when it is this
|
|
/// host's, else the host's newest connected one.
|
|
pub fn start_cli_run(&self, host: &str, run: RunHandle, cx: &App) -> Result<(), RunFailure> {
|
|
let host = find_host(self.store.read(cx), host)
|
|
.map_err(|_| RunFailure::HostNotFound)?
|
|
.id;
|
|
let connected = |panel: &&Entity<TerminalPanel>| {
|
|
matches!(panel.read(cx).lifecycle(cx), TerminalLifecycle::Running)
|
|
};
|
|
let panel = self
|
|
.tool_terminal(cx)
|
|
.filter(|terminal| terminal.host == host)
|
|
.and_then(|terminal| self.terminals.get(&terminal.id))
|
|
.filter(connected)
|
|
.or_else(|| {
|
|
self.terminals
|
|
.values()
|
|
.filter(|panel| panel.read(cx).host_id() == host)
|
|
.filter(connected)
|
|
.max_by_key(|panel| panel.read(cx).id().0)
|
|
});
|
|
match panel {
|
|
Some(panel) => panel.read(cx).terminal().read(cx).run_command(run, cx),
|
|
None if self.terminal(host, cx).is_some() => Err(RunFailure::NotConnected),
|
|
None => Err(RunFailure::NoTerminal),
|
|
}
|
|
}
|
|
|
|
/// Make a change a `shellrs` command asks for, checked as the host and
|
|
/// credential forms check theirs. Its outcome is the reply the command
|
|
/// prints; on success, the host or credential as it is now.
|
|
pub fn apply_cli_change(
|
|
&mut self,
|
|
change: CliChange,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Reply, CliError>> {
|
|
let store = self.store.read(cx);
|
|
match change {
|
|
CliChange::CreateHost(fields) => self.save_host_from_cli(None, fields, cx),
|
|
CliChange::UpdateHost { host, fields } => match find_saved_host(store, &host) {
|
|
Ok(id) => self.save_host_from_cli(Some(id), fields, cx),
|
|
Err(error) => Task::ready(Err(error)),
|
|
},
|
|
CliChange::DeleteHost { host, force } => {
|
|
Task::ready(self.delete_host_from_cli(&host, force, window, cx))
|
|
}
|
|
CliChange::CreateCredential(fields) => self.save_credential_from_cli(None, fields, cx),
|
|
CliChange::UpdateCredential { credential, fields } => {
|
|
match find_credential(store, &credential) {
|
|
Ok(credential) => {
|
|
let id = credential.id;
|
|
self.save_credential_from_cli(Some(id), fields, cx)
|
|
}
|
|
Err(error) => Task::ready(Err(error)),
|
|
}
|
|
}
|
|
CliChange::DeleteCredential { credential } => {
|
|
Task::ready(self.delete_credential_from_cli(&credential, cx))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn save_host_from_cli(
|
|
&mut self,
|
|
existing: Option<HostId>,
|
|
fields: HostFields,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Reply, CliError>> {
|
|
let plan = match plan_host(fields, existing, self.store.read(cx)) {
|
|
Ok(plan) => plan,
|
|
Err(error) => return Task::ready(Err(error)),
|
|
};
|
|
let store = self.store.clone();
|
|
let secrets = store.read(cx).secrets();
|
|
cx.spawn(async move |_, cx| {
|
|
// Read what moves with the host before the change, after which
|
|
// the store may delete the entry it moves from.
|
|
let password = secret_value(plan.password, &secrets, cx).await;
|
|
let proxy_password = secret_value(plan.proxy_password, &secrets, cx).await;
|
|
let (id, mut failures, writes) = store.update(cx, |store, cx| {
|
|
let (id, failures) = store.capture_failures(cx, |store, cx| {
|
|
let mut draft = plan.draft;
|
|
draft.group = place_group(store, plan.group, cx);
|
|
match existing {
|
|
Some(id) => {
|
|
store.update(id, draft, cx);
|
|
id
|
|
}
|
|
None => store.insert(draft, cx),
|
|
}
|
|
});
|
|
let writes: Vec<_> = [password, proxy_password]
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|(secret, value)| store.write_secret(secret, value, cx))
|
|
.collect();
|
|
(id, failures, writes)
|
|
});
|
|
for write in writes {
|
|
if let Err(error) = write.await {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
if !failures.is_empty() {
|
|
return Err(save_failed(failures));
|
|
}
|
|
let (details, refs) = store
|
|
.read_with(cx, |store, _| {
|
|
store
|
|
.host(id)
|
|
.map(|host| (host_details(host, store), host_secrets(host, store)))
|
|
})
|
|
.ok_or_else(|| {
|
|
CliError::new(ErrorCode::HostNotFound, t!("cli.change.host_gone"))
|
|
})?;
|
|
let details = cx
|
|
.background_executor()
|
|
.spawn(async move {
|
|
with_saved_passwords(details, &refs, |secret| is_saved(&secrets, secret))
|
|
})
|
|
.await;
|
|
Ok(Reply::Host(details))
|
|
})
|
|
}
|
|
|
|
/// Delete a saved host as the host list does, closing its tabs; a
|
|
/// host with tabs open only with `force`, since nobody is asked.
|
|
fn delete_host_from_cli(
|
|
&mut self,
|
|
host: &str,
|
|
force: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<Reply, CliError> {
|
|
let store = self.store.read(cx);
|
|
let id = find_saved_host(store, host)?;
|
|
let Some(host) = store.host(id) else {
|
|
return Err(CliError::new(
|
|
ErrorCode::HostNotFound,
|
|
t!("cli.change.host_missing"),
|
|
));
|
|
};
|
|
let deleted = HostDeleted {
|
|
host: host_details(host, store),
|
|
forwards: store.forwards_of(id).count() as u64,
|
|
jump_users: store.jump_users(&[id]) as u64,
|
|
};
|
|
if !force && self.has_tabs(id, cx) {
|
|
return Err(CliError::new(
|
|
ErrorCode::HostInUse,
|
|
t!("cli.change.host_in_use", name = deleted.host.name),
|
|
));
|
|
}
|
|
self.close_host_tabs(id, window, cx);
|
|
let (_, failures) = self.store.update(cx, |store, cx| {
|
|
store.capture_failures(cx, |store, cx| store.remove(id, cx))
|
|
});
|
|
if !failures.is_empty() {
|
|
return Err(save_failed(failures));
|
|
}
|
|
Ok(Reply::HostDeleted(deleted))
|
|
}
|
|
|
|
fn save_credential_from_cli(
|
|
&mut self,
|
|
existing: Option<CredentialId>,
|
|
fields: CredentialFields,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Reply, CliError>> {
|
|
let plan = match plan_credential(fields, existing, self.store.read(cx)) {
|
|
Ok(plan) => plan,
|
|
Err(error) => return Task::ready(Err(error)),
|
|
};
|
|
let store = self.store.clone();
|
|
let secrets = store.read(cx).secrets();
|
|
let saved = store.update(cx, |store, cx| save_credential(store, existing, plan, cx));
|
|
let (id, mut failures, writes) = match saved {
|
|
Ok(saved) => saved,
|
|
Err(error) => return Task::ready(Err(error)),
|
|
};
|
|
cx.spawn(async move |_, cx| {
|
|
for write in writes {
|
|
if let Err(error) = write.await {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
if !failures.is_empty() {
|
|
return Err(save_failed(failures));
|
|
}
|
|
let (details, refs) = store
|
|
.read_with(cx, |store, _| {
|
|
store.credential(id).map(|credential| {
|
|
(
|
|
credential_details(credential, store),
|
|
credential_secrets(credential),
|
|
)
|
|
})
|
|
})
|
|
.ok_or_else(credential_gone)?;
|
|
let details = cx
|
|
.background_executor()
|
|
.spawn(async move {
|
|
with_saved_credential_secrets(details, &refs, |secret| {
|
|
is_saved(&secrets, secret)
|
|
})
|
|
})
|
|
.await;
|
|
Ok(Reply::Credential(details))
|
|
})
|
|
}
|
|
|
|
/// Delete a credential as the credential list does; the hosts using it
|
|
/// log in on their own from now on, and are listed in the reply.
|
|
fn delete_credential_from_cli(
|
|
&mut self,
|
|
credential: &str,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<Reply, CliError> {
|
|
let store = self.store.read(cx);
|
|
let found = find_credential(store, credential)?;
|
|
let (id, details) = (found.id, credential_details(found, store));
|
|
let (released, failures) = self.store.update(cx, |store, cx| {
|
|
store.capture_failures(cx, |store, cx| store.remove_credential(id, cx))
|
|
});
|
|
if !failures.is_empty() {
|
|
return Err(save_failed(failures));
|
|
}
|
|
let store = self.store.read(cx);
|
|
Ok(Reply::CredentialDeleted(CredentialDeleted {
|
|
credential: details,
|
|
released: released
|
|
.iter()
|
|
.filter_map(|id| store.host(*id))
|
|
.map(|host| host_info(host, store))
|
|
.collect(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
/// The group a host goes into, made first when it is new.
|
|
fn place_group(
|
|
store: &mut HostStore,
|
|
plan: GroupPlan,
|
|
cx: &mut Context<HostStore>,
|
|
) -> Option<GroupId> {
|
|
match plan {
|
|
GroupPlan::Existing(group) => group,
|
|
GroupPlan::Create { parent, names } => names.into_iter().fold(parent, |parent, name| {
|
|
Some(store.insert_group(GroupDraft::new(name, parent), cx))
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Write the key file, then the credential, then start writing its
|
|
/// secrets: the order the credential form keeps, so the credential never
|
|
/// names a key file that is not there.
|
|
fn save_credential(
|
|
store: &mut HostStore,
|
|
existing: Option<CredentialId>,
|
|
plan: CredentialPlan,
|
|
cx: &mut Context<HostStore>,
|
|
) -> Result<SavedCredential, CliError> {
|
|
let mut draft = plan.draft;
|
|
if let Some(pasted) = &plan.private_key {
|
|
let path = store
|
|
.save_private_key(existing, pasted.text())
|
|
.map_err(|error| {
|
|
CliError::new(
|
|
ErrorCode::SaveFailed,
|
|
t!("cli.change.key_not_saved", error = error),
|
|
)
|
|
})?;
|
|
draft = draft.with_key_path(path);
|
|
}
|
|
let (id, failures) = store.capture_failures(cx, |store, cx| match existing {
|
|
Some(id) => {
|
|
store.update_credential(id, draft, cx);
|
|
id
|
|
}
|
|
None => store.insert_credential(draft, cx),
|
|
});
|
|
let Some(credential) = store.credential(id).cloned() else {
|
|
return Err(credential_gone());
|
|
};
|
|
let mut writes = Vec::new();
|
|
if let Some(value) = plan.password.value() {
|
|
writes.push(store.write_secret(credential.password_secret(), value, cx));
|
|
}
|
|
if let (Some(path), Some(value)) = (credential.key_path.as_deref(), plan.passphrase.value()) {
|
|
writes.push(store.write_secret(SecretRef::passphrase(path), value, cx));
|
|
}
|
|
Ok((id, failures, writes))
|
|
}
|
|
|
|
type SavedCredential = (
|
|
CredentialId,
|
|
Vec<SharedString>,
|
|
Vec<Task<Result<(), SharedString>>>,
|
|
);
|
|
|
|
/// What to write to `secret`, `None` for nothing and `Some(None)` to
|
|
/// delete it: a value moving with the host is read from where it was.
|
|
async fn secret_value(
|
|
change: Option<(SecretRef, SecretChange)>,
|
|
secrets: &SharedSecretStore,
|
|
cx: &mut AsyncApp,
|
|
) -> Option<(SecretRef, Option<String>)> {
|
|
let (secret, change) = change?;
|
|
let value = match change {
|
|
SecretChange::Carry(from) => {
|
|
let secrets = secrets.clone();
|
|
let value = cx
|
|
.background_executor()
|
|
.spawn(async move { secrets.get(&from).ok().flatten() })
|
|
.await?;
|
|
Some(value.to_string())
|
|
}
|
|
change => change.value()?,
|
|
};
|
|
Some((secret, value))
|
|
}
|
|
|
|
fn is_saved(secrets: &SharedSecretStore, secret: &SecretRef) -> bool {
|
|
secrets.get(secret).is_ok_and(|value| value.is_some())
|
|
}
|
|
|
|
fn credential_gone() -> CliError {
|
|
CliError::new(
|
|
ErrorCode::CredentialNotFound,
|
|
t!("cli.change.credential_gone"),
|
|
)
|
|
}
|
|
|
|
/// Every failure, one after the other.
|
|
fn save_failed(failures: Vec<SharedString>) -> CliError {
|
|
let message = failures
|
|
.into_iter()
|
|
.reduce(|before, next| t!("cli.change.failures", before = before, next = next))
|
|
.unwrap_or_default();
|
|
CliError::new(ErrorCode::SaveFailed, message)
|
|
}
|