From 378a2cf9d86c8b64b0e27fb16197c68f426825c2 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 16 Oct 2024 21:09:31 -0600 Subject: [PATCH] Allow passing args to ssh (#19336) This is useful for passing a custom identity file, jump hosts, etc. Unlike with the v1 feature, we won't support `gh`/`gcloud` ssh wrappers (yet?). I think the right way of supporting those would be to let extensions provide remote projects. Closes #19118 Release Notes: - SSH remoting: restored ability to set arguments for SSH --- Cargo.lock | 1 + crates/recent_projects/src/dev_servers.rs | 122 ++++++++++-------- crates/recent_projects/src/recent_projects.rs | 4 +- crates/recent_projects/src/ssh_connections.rs | 32 +++-- crates/remote/Cargo.toml | 1 + crates/remote/src/ssh_session.rs | 85 ++++++++++++ crates/workspace/src/persistence/model.rs | 11 +- crates/zed/src/main.rs | 21 ++- crates/zed/src/zed/open_listener.rs | 28 +++- 9 files changed, 216 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84b6a3e471..32b4ca2290 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9126,6 +9126,7 @@ dependencies = [ "rpc", "serde", "serde_json", + "shlex", "smol", "tempfile", "thiserror", diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 75b78f1654..eec007431e 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -13,19 +13,19 @@ use futures::channel::oneshot; use futures::future::Shared; use futures::FutureExt; use gpui::canvas; -use gpui::pulsating_between; use gpui::AsyncWindowContext; use gpui::ClipboardItem; use gpui::Task; use gpui::WeakView; use gpui::{ - Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, - FocusableView, FontWeight, Model, PromptLevel, ScrollHandle, View, ViewContext, + AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, FontWeight, + Model, PromptLevel, ScrollHandle, View, ViewContext, }; use picker::Picker; use project::terminals::wrap_for_ssh; use project::terminals::SshCommand; use project::Project; +use remote::SshConnectionOptions; use rpc::proto::DevServerStatus; use settings::update_settings_file; use settings::Settings; @@ -65,8 +65,9 @@ pub struct DevServerProjects { struct CreateDevServer { address_editor: View, - creating: Option>>, + address_error: Option, ssh_prompt: Option>, + _creating: Option>>, } impl CreateDevServer { @@ -77,8 +78,9 @@ impl CreateDevServer { }); Self { address_editor, - creating: None, + address_error: None, ssh_prompt: None, + _creating: None, } } } @@ -378,34 +380,22 @@ impl DevServerProjects { } fn create_ssh_server(&mut self, editor: View, cx: &mut ViewContext) { - let host = get_text(&editor, cx); - if host.is_empty() { + let input = get_text(&editor, cx); + if input.is_empty() { return; } - let mut host = host.trim_start_matches("ssh "); - let mut username: Option = None; - let mut port: Option = None; - - if let Some((u, rest)) = host.split_once('@') { - host = rest; - username = Some(u.to_string()); - } - if let Some((rest, p)) = host.split_once(':') { - host = rest; - port = p.parse().ok() - } - - if let Some((rest, p)) = host.split_once(" -p") { - host = rest; - port = p.trim().parse().ok() - } - - let connection_options = remote::SshConnectionOptions { - host: host.to_string(), - username: username.clone(), - port, - password: None, + let connection_options = match SshConnectionOptions::parse_command_line(&input) { + Ok(c) => c, + Err(e) => { + self.mode = Mode::CreateDevServer(CreateDevServer { + address_editor: editor, + address_error: Some(format!("could not parse: {:?}", e).into()), + ssh_prompt: None, + _creating: None, + }); + return; + } }; let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx)); @@ -417,6 +407,7 @@ impl DevServerProjects { ) .prompt_err("Failed to connect", cx, |_, _| None); + let address_editor = editor.clone(); let creating = cx.spawn(move |this, mut cx| async move { match connection.await { Some(_) => this @@ -436,18 +427,31 @@ impl DevServerProjects { .log_err(), None => this .update(&mut cx, |this, cx| { - this.mode = Mode::CreateDevServer(CreateDevServer::new(cx)); + address_editor.update(cx, |this, _| { + this.set_read_only(false); + }); + this.mode = Mode::CreateDevServer(CreateDevServer { + address_editor, + address_error: None, + ssh_prompt: None, + _creating: None, + }); cx.notify() }) .log_err(), }; None }); - let mut state = CreateDevServer::new(cx); - state.address_editor = editor; - state.ssh_prompt = Some(ssh_prompt.clone()); - state.creating = Some(creating); - self.mode = Mode::CreateDevServer(state); + + editor.update(cx, |this, _| { + this.set_read_only(true); + }); + self.mode = Mode::CreateDevServer(CreateDevServer { + address_editor: editor, + address_error: None, + ssh_prompt: Some(ssh_prompt.clone()), + _creating: Some(creating), + }); } fn view_server_options( @@ -547,9 +551,6 @@ impl DevServerProjects { return; } - state.address_editor.update(cx, |this, _| { - this.set_read_only(true); - }); self.create_ssh_server(state.address_editor.clone(), cx); } Mode::EditNickname(state) => { @@ -812,6 +813,7 @@ impl DevServerProjects { port: connection_options.port, projects: vec![], nickname: None, + args: connection_options.args.unwrap_or_default(), }) }); } @@ -825,10 +827,7 @@ impl DevServerProjects { state.address_editor.update(cx, |editor, cx| { if editor.text(cx).is_empty() { - editor.set_placeholder_text( - "Enter the command you use to SSH into this server: e.g., ssh me@my.server", - cx, - ); + editor.set_placeholder_text("ssh user@example -p 2222", cx); } }); @@ -854,27 +853,38 @@ impl DevServerProjects { .map(|this| { if let Some(ssh_prompt) = ssh_prompt { this.child(h_flex().w_full().child(ssh_prompt)) + } else if let Some(address_error) = &state.address_error { + this.child( + h_flex().p_2().w_full().gap_2().child( + Label::new(address_error.clone()) + .size(LabelSize::Small) + .color(Color::Error), + ), + ) } else { - let color = Color::Muted.color(cx); this.child( h_flex() .p_2() .w_full() - .items_center() - .justify_center() - .gap_2() + .gap_1() .child( - div().size_1p5().rounded_full().bg(color).with_animation( - "pulse-ssh-waiting-for-connection", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.2, 0.5)), - move |this, progress| this.bg(color.opacity(progress)), - ), + Label::new( + "Enter the command you use to SSH into this server.", + ) + .color(Color::Muted) + .size(LabelSize::Small), ) .child( - Label::new("Waiting for connection…") - .size(LabelSize::Small), + Button::new("learn-more", "Learn more…") + .label_size(LabelSize::Small) + .size(ButtonSize::None) + .color(Color::Accent) + .style(ButtonStyle::Transparent) + .on_click(|_, cx| { + cx.open_url( + "https://zed.dev/docs/remote-development", + ); + }), ), ) } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index d763d24234..5fea5d3a5c 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -21,7 +21,7 @@ use picker::{ use rpc::proto::DevServerStatus; use serde::Deserialize; use settings::Settings; -use ssh_connections::SshSettings; +pub use ssh_connections::SshSettings; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -384,11 +384,13 @@ impl PickerDelegate for RecentProjectsDelegate { ..Default::default() }; + let args = SshSettings::get_global(cx).args_for(&ssh_project.host, ssh_project.port, &ssh_project.user); let connection_options = SshConnectionOptions { host: ssh_project.host.clone(), username: ssh_project.user.clone(), port: ssh_project.port, password: None, + args, }; let paths = ssh_project.paths.iter().map(PathBuf::from).collect(); diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index 06fb08b3fe..c8043acc48 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -32,6 +32,23 @@ impl SshSettings { pub fn ssh_connections(&self) -> impl Iterator { self.ssh_connections.clone().into_iter().flatten() } + + pub fn args_for( + &self, + host: &str, + port: Option, + user: &Option, + ) -> Option> { + self.ssh_connections() + .filter_map(|conn| { + if conn.host == host && &conn.username == user && conn.port == port { + Some(conn.args) + } else { + None + } + }) + .next() + } } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -45,6 +62,9 @@ pub struct SshConnection { /// Name to use for this server in UI. #[serde(skip_serializing_if = "Option::is_none")] pub nickname: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub args: Vec, } impl From for SshConnectionOptions { fn from(val: SshConnection) -> Self { @@ -53,6 +73,7 @@ impl From for SshConnectionOptions { username: val.username, port: val.port, password: None, + args: Some(val.args), } } } @@ -151,11 +172,9 @@ impl Render for SshPrompt { v_flex() .key_context("PasswordPrompt") .size_full() - .justify_center() .child( h_flex() .p_2() - .justify_center() .flex_wrap() .child(if self.error_message.is_some() { Icon::new(IconName::XCircle) @@ -174,24 +193,19 @@ impl Render for SshPrompt { ) .into_any_element() }) - .child( - div() - .ml_1() - .child(Label::new("SSH Connection").size(LabelSize::Small)), - ) .child( div() .text_ellipsis() .overflow_x_hidden() .when_some(self.error_message.as_ref(), |el, error| { - el.child(Label::new(format!("-{}", error)).size(LabelSize::Small)) + el.child(Label::new(format!("{}", error)).size(LabelSize::Small)) }) .when( self.error_message.is_none() && self.status_message.is_some(), |el| { el.child( Label::new(format!( - "-{}", + "-{}…", self.status_message.clone().unwrap() )) .size(LabelSize::Small), diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml index 63c12cf117..b8c5f34cc5 100644 --- a/crates/remote/Cargo.toml +++ b/crates/remote/Cargo.toml @@ -29,6 +29,7 @@ prost.workspace = true rpc = { workspace = true, features = ["gpui"] } serde.workspace = true serde_json.workspace = true +shlex.workspace = true smol.workspace = true tempfile.workspace = true thiserror.workspace = true diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index b862ecf10f..a09faf894c 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -61,9 +61,89 @@ pub struct SshConnectionOptions { pub username: Option, pub port: Option, pub password: Option, + pub args: Option>, } impl SshConnectionOptions { + pub fn parse_command_line(input: &str) -> Result { + let input = input.trim_start_matches("ssh "); + let mut hostname: Option = None; + let mut username: Option = None; + let mut port: Option = None; + let mut args = Vec::new(); + + // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W + const ALLOWED_OPTS: &[&str] = &[ + "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", + ]; + const ALLOWED_ARGS: &[&str] = &[ + "-B", "-b", "-c", "-D", "-I", "-i", "-J", "-L", "-l", "-m", "-o", "-P", "-p", "-R", + "-w", + ]; + + let mut tokens = shlex::split(input) + .ok_or_else(|| anyhow!("invalid input"))? + .into_iter(); + + 'outer: while let Some(arg) = tokens.next() { + if ALLOWED_OPTS.contains(&(&arg as &str)) { + args.push(arg.to_string()); + continue; + } + if arg == "-p" { + port = tokens.next().and_then(|arg| arg.parse().ok()); + continue; + } else if let Some(p) = arg.strip_prefix("-p") { + port = p.parse().ok(); + continue; + } + if arg == "-l" { + username = tokens.next(); + continue; + } else if let Some(l) = arg.strip_prefix("-l") { + username = Some(l.to_string()); + continue; + } + for a in ALLOWED_ARGS { + if arg == *a { + args.push(arg); + if let Some(next) = tokens.next() { + args.push(next); + } + continue 'outer; + } else if arg.starts_with(a) { + args.push(arg); + continue 'outer; + } + } + if arg.starts_with("-") || hostname.is_some() { + anyhow::bail!("unsupported argument: {:?}", arg); + } + let mut input = &arg as &str; + if let Some((u, rest)) = input.split_once('@') { + input = rest; + username = Some(u.to_string()); + } + if let Some((rest, p)) = input.split_once(':') { + input = rest; + port = p.parse().ok() + } + hostname = Some(input.to_string()) + } + + let Some(hostname) = hostname else { + anyhow::bail!("missing hostname"); + }; + + Ok(Self { + host: hostname.to_string(), + username: username.clone(), + port, + password: None, + args: Some(args), + }) + } + pub fn ssh_url(&self) -> String { let mut result = String::from("ssh://"); if let Some(username) = &self.username { @@ -78,6 +158,10 @@ impl SshConnectionOptions { result } + pub fn additional_args(&self) -> Option<&Vec> { + self.args.as_ref() + } + fn scp_url(&self) -> String { if let Some(username) = &self.username { format!("{}@{}", username, self.host) @@ -1179,6 +1263,7 @@ impl SshRemoteConnection { .stderr(Stdio::piped()) .env("SSH_ASKPASS_REQUIRE", "force") .env("SSH_ASKPASS", &askpass_script_path) + .args(connection_options.additional_args().unwrap_or(&Vec::new())) .args([ "-N", "-o", diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 5efc77205c..7528e4c393 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -11,7 +11,7 @@ use db::sqlez::{ }; use gpui::{AsyncWindowContext, Model, View, WeakView}; use project::Project; -use remote::{ssh_session::SshProjectId, SshConnectionOptions}; +use remote::ssh_session::SshProjectId; use serde::{Deserialize, Serialize}; use std::{ path::{Path, PathBuf}, @@ -50,15 +50,6 @@ impl SerializedSshProject { }) .collect() } - - pub fn connection_options(&self) -> SshConnectionOptions { - SshConnectionOptions { - host: self.host.clone(), - username: self.user.clone(), - port: self.port, - password: None, - } - } } impl StaticColumnCount for SerializedSshProject { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 25baf74c68..9ddb2982ec 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -33,7 +33,7 @@ use assets::Assets; use node_runtime::{NodeBinaryOptions, NodeRuntime}; use parking_lot::Mutex; use project::project_settings::ProjectSettings; -use recent_projects::open_ssh_project; +use recent_projects::{open_ssh_project, SshSettings}; use release_channel::{AppCommitSha, AppVersion}; use session::{AppSession, Session}; use settings::{ @@ -214,6 +214,7 @@ fn init_common(app_state: Arc, cx: &mut AppContext) -> Arc { + SerializedWorkspaceLocation::Ssh(ssh) => { + let args = cx + .update(|cx| { + SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user) + }) + .ok() + .flatten(); let connection_options = SshConnectionOptions { - host: ssh_project.host.clone(), - username: ssh_project.user.clone(), - port: ssh_project.port, + args, + host: ssh.host.clone(), + username: ssh.user.clone(), + port: ssh.port, password: None, }; let app_state = app_state.clone(); cx.spawn(move |mut cx| async move { recent_projects::open_ssh_project( connection_options, - ssh_project.paths.into_iter().map(PathBuf::from).collect(), + ssh.paths.into_iter().map(PathBuf::from).collect(), app_state, workspace::OpenOptions::default(), &mut cx, diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 66d2788821..64839f4625 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -16,8 +16,9 @@ use futures::future::join_all; use futures::{FutureExt, SinkExt, StreamExt}; use gpui::{AppContext, AsyncAppContext, Global, WindowHandle}; use language::{Bias, Point}; -use recent_projects::open_ssh_project; +use recent_projects::{open_ssh_project, SshSettings}; use remote::SshConnectionOptions; +use settings::Settings; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -48,7 +49,7 @@ impl OpenRequest { } else if let Some(file) = url.strip_prefix("zed://file") { this.parse_file_path(file) } else if url.starts_with("ssh://") { - this.parse_ssh_file_path(&url)? + this.parse_ssh_file_path(&url, cx)? } else if let Some(request_path) = parse_zed_link(&url, cx) { this.parse_request_path(request_path).log_err(); } else { @@ -65,7 +66,7 @@ impl OpenRequest { } } - fn parse_ssh_file_path(&mut self, file: &str) -> Result<()> { + fn parse_ssh_file_path(&mut self, file: &str, cx: &AppContext) -> Result<()> { let url = url::Url::parse(file)?; let host = url .host() @@ -77,11 +78,13 @@ impl OpenRequest { if !self.open_paths.is_empty() { return Err(anyhow!("cannot open both local and ssh paths")); } + let args = SshSettings::get_global(cx).args_for(&host, port, &username); let connection = SshConnectionOptions { username, password, host, port, + args, }; if let Some(ssh_connection) = &self.ssh_connection { if *ssh_connection != connection { @@ -419,12 +422,25 @@ async fn open_workspaces( errored = true } } - SerializedWorkspaceLocation::Ssh(ssh_project) => { + SerializedWorkspaceLocation::Ssh(ssh) => { let app_state = app_state.clone(); + let args = cx + .update(|cx| { + SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user) + }) + .ok() + .flatten(); + let connection_options = SshConnectionOptions { + args, + host: ssh.host.clone(), + username: ssh.user.clone(), + port: ssh.port, + password: None, + }; cx.spawn(|mut cx| async move { open_ssh_project( - ssh_project.connection_options(), - ssh_project.paths.into_iter().map(PathBuf::from).collect(), + connection_options, + ssh.paths.into_iter().map(PathBuf::from).collect(), app_state, OpenOptions::default(), &mut cx,