1use crate::{
2 RemoteClientDelegate, RemotePlatform,
3 remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
4 transport::{parse_platform, parse_shell},
5};
6use anyhow::{Context as _, Result, anyhow};
7use async_trait::async_trait;
8use collections::HashMap;
9use futures::{
10 AsyncReadExt as _, FutureExt as _,
11 channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
12 select_biased,
13};
14use gpui::{App, AppContext as _, AsyncApp, Task};
15use parking_lot::Mutex;
16use paths::remote_server_dir_relative;
17use release_channel::{AppVersion, ReleaseChannel};
18use rpc::proto::Envelope;
19use semver::Version;
20pub use settings::SshPortForwardOption;
21use smol::{
22 fs,
23 process::{self, Child, Stdio},
24};
25use std::{
26 path::{Path, PathBuf},
27 sync::Arc,
28 time::Instant,
29};
30use tempfile::TempDir;
31use util::{
32 paths::{PathStyle, RemotePathBuf},
33 rel_path::RelPath,
34 shell::{Shell, ShellKind},
35 shell_builder::ShellBuilder,
36};
37
38pub(crate) struct SshRemoteConnection {
39 socket: SshSocket,
40 master_process: Mutex<Option<MasterProcess>>,
41 remote_binary_path: Option<Arc<RelPath>>,
42 ssh_platform: RemotePlatform,
43 ssh_path_style: PathStyle,
44 ssh_shell: String,
45 ssh_shell_kind: ShellKind,
46 ssh_default_system_shell: String,
47 _temp_dir: TempDir,
48}
49
50#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
51pub struct SshConnectionOptions {
52 pub host: String,
53 pub username: Option<String>,
54 pub port: Option<u16>,
55 pub password: Option<String>,
56 pub args: Option<Vec<String>>,
57 pub port_forwards: Option<Vec<SshPortForwardOption>>,
58 pub connection_timeout: Option<u16>,
59
60 pub nickname: Option<String>,
61 pub upload_binary_over_ssh: bool,
62}
63
64impl From<settings::SshConnection> for SshConnectionOptions {
65 fn from(val: settings::SshConnection) -> Self {
66 SshConnectionOptions {
67 host: val.host.into(),
68 username: val.username,
69 port: val.port,
70 password: None,
71 args: Some(val.args),
72 nickname: val.nickname,
73 upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
74 port_forwards: val.port_forwards,
75 connection_timeout: val.connection_timeout,
76 }
77 }
78}
79
80struct SshSocket {
81 connection_options: SshConnectionOptions,
82 #[cfg(not(target_os = "windows"))]
83 socket_path: std::path::PathBuf,
84 envs: HashMap<String, String>,
85 #[cfg(target_os = "windows")]
86 _proxy: askpass::PasswordProxy,
87}
88
89struct MasterProcess {
90 process: Child,
91}
92
93#[cfg(not(target_os = "windows"))]
94impl MasterProcess {
95 pub fn new(
96 askpass_script_path: &std::ffi::OsStr,
97 additional_args: Vec<String>,
98 socket_path: &std::path::Path,
99 url: &str,
100 ) -> Result<Self> {
101 let args = [
102 "-N",
103 "-o",
104 "ControlPersist=no",
105 "-o",
106 "ControlMaster=yes",
107 "-o",
108 ];
109
110 let mut master_process = util::command::new_smol_command("ssh");
111 master_process
112 .kill_on_drop(true)
113 .stdin(Stdio::null())
114 .stdout(Stdio::piped())
115 .stderr(Stdio::piped())
116 .env("SSH_ASKPASS_REQUIRE", "force")
117 .env("SSH_ASKPASS", askpass_script_path)
118 .args(additional_args)
119 .args(args);
120
121 master_process.arg(format!("ControlPath={}", socket_path.display()));
122
123 let process = master_process.arg(&url).spawn()?;
124
125 Ok(MasterProcess { process })
126 }
127
128 pub async fn wait_connected(&mut self) -> Result<()> {
129 let Some(mut stdout) = self.process.stdout.take() else {
130 anyhow::bail!("ssh process stdout capture failed");
131 };
132
133 let mut output = Vec::new();
134 stdout.read_to_end(&mut output).await?;
135 Ok(())
136 }
137}
138
139#[cfg(target_os = "windows")]
140impl MasterProcess {
141 const CONNECTION_ESTABLISHED_MAGIC: &str = "ZED_SSH_CONNECTION_ESTABLISHED";
142
143 pub fn new(
144 askpass_script_path: &std::ffi::OsStr,
145 additional_args: Vec<String>,
146 url: &str,
147 ) -> Result<Self> {
148 // On Windows, `ControlMaster` and `ControlPath` are not supported:
149 // https://github.com/PowerShell/Win32-OpenSSH/issues/405
150 // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
151 //
152 // Using an ugly workaround to detect connection establishment
153 // -N doesn't work with JumpHosts as windows openssh never closes stdin in that case
154 let args = [
155 "-t",
156 &format!("echo '{}'; exec $0", Self::CONNECTION_ESTABLISHED_MAGIC),
157 ];
158
159 let mut master_process = util::command::new_smol_command("ssh");
160 master_process
161 .kill_on_drop(true)
162 .stdin(Stdio::null())
163 .stdout(Stdio::piped())
164 .stderr(Stdio::piped())
165 .env("SSH_ASKPASS_REQUIRE", "force")
166 .env("SSH_ASKPASS", askpass_script_path)
167 .args(additional_args)
168 .arg(url)
169 .args(args);
170
171 let process = master_process.spawn()?;
172
173 Ok(MasterProcess { process })
174 }
175
176 pub async fn wait_connected(&mut self) -> Result<()> {
177 use smol::io::AsyncBufReadExt;
178
179 let Some(stdout) = self.process.stdout.take() else {
180 anyhow::bail!("ssh process stdout capture failed");
181 };
182
183 let mut reader = smol::io::BufReader::new(stdout);
184
185 let mut line = String::new();
186
187 loop {
188 let n = reader.read_line(&mut line).await?;
189 if n == 0 {
190 anyhow::bail!("ssh process exited before connection established");
191 }
192
193 if line.contains(Self::CONNECTION_ESTABLISHED_MAGIC) {
194 return Ok(());
195 }
196 }
197 }
198}
199
200impl AsRef<Child> for MasterProcess {
201 fn as_ref(&self) -> &Child {
202 &self.process
203 }
204}
205
206impl AsMut<Child> for MasterProcess {
207 fn as_mut(&mut self) -> &mut Child {
208 &mut self.process
209 }
210}
211
212#[async_trait(?Send)]
213impl RemoteConnection for SshRemoteConnection {
214 async fn kill(&self) -> Result<()> {
215 let Some(mut process) = self.master_process.lock().take() else {
216 return Ok(());
217 };
218 process.as_mut().kill().ok();
219 process.as_mut().status().await?;
220 Ok(())
221 }
222
223 fn has_been_killed(&self) -> bool {
224 self.master_process.lock().is_none()
225 }
226
227 fn connection_options(&self) -> RemoteConnectionOptions {
228 RemoteConnectionOptions::Ssh(self.socket.connection_options.clone())
229 }
230
231 fn shell(&self) -> String {
232 self.ssh_shell.clone()
233 }
234
235 fn default_system_shell(&self) -> String {
236 self.ssh_default_system_shell.clone()
237 }
238
239 fn build_command(
240 &self,
241 input_program: Option<String>,
242 input_args: &[String],
243 input_env: &HashMap<String, String>,
244 working_dir: Option<String>,
245 port_forward: Option<(u16, String, u16)>,
246 ) -> Result<CommandTemplate> {
247 let Self {
248 ssh_path_style,
249 socket,
250 ssh_shell_kind,
251 ssh_shell,
252 ..
253 } = self;
254 let env = socket.envs.clone();
255 build_command(
256 input_program,
257 input_args,
258 input_env,
259 working_dir,
260 port_forward,
261 env,
262 *ssh_path_style,
263 ssh_shell,
264 *ssh_shell_kind,
265 socket.ssh_args(),
266 )
267 }
268
269 fn build_forward_ports_command(
270 &self,
271 forwards: Vec<(u16, String, u16)>,
272 ) -> Result<CommandTemplate> {
273 let Self { socket, .. } = self;
274 let mut args = socket.ssh_args();
275 args.push("-N".into());
276 for (local_port, host, remote_port) in forwards {
277 args.push("-L".into());
278 args.push(format!("{local_port}:{host}:{remote_port}"));
279 }
280 Ok(CommandTemplate {
281 program: "ssh".into(),
282 args,
283 env: Default::default(),
284 })
285 }
286
287 fn upload_directory(
288 &self,
289 src_path: PathBuf,
290 dest_path: RemotePathBuf,
291 cx: &App,
292 ) -> Task<Result<()>> {
293 let dest_path_str = dest_path.to_string();
294 let src_path_display = src_path.display().to_string();
295
296 let mut sftp_command = self.build_sftp_command();
297 let mut scp_command =
298 self.build_scp_command(&src_path, &dest_path_str, Some(&["-C", "-r"]));
299
300 cx.background_spawn(async move {
301 // We will try SFTP first, and if that fails, we will fall back to SCP.
302 // If SCP fails also, we give up and return an error.
303 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
304 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
305 // This is for example the case on Windows as evidenced by this implementation snippet:
306 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
307 if Self::is_sftp_available().await {
308 log::debug!("using SFTP for directory upload");
309 let mut child = sftp_command.spawn()?;
310 if let Some(mut stdin) = child.stdin.take() {
311 use futures::AsyncWriteExt;
312 let sftp_batch = format!("put -r \"{src_path_display}\" \"{dest_path_str}\"\n");
313 stdin.write_all(sftp_batch.as_bytes()).await?;
314 stdin.flush().await?;
315 }
316
317 let output = child.output().await?;
318 if output.status.success() {
319 return Ok(());
320 }
321
322 let stderr = String::from_utf8_lossy(&output.stderr);
323 log::debug!("failed to upload directory via SFTP {src_path_display} -> {dest_path_str}: {stderr}");
324 }
325
326 log::debug!("using SCP for directory upload");
327 let output = scp_command.output().await?;
328
329 if output.status.success() {
330 return Ok(());
331 }
332
333 let stderr = String::from_utf8_lossy(&output.stderr);
334 log::debug!("failed to upload directory via SCP {src_path_display} -> {dest_path_str}: {stderr}");
335
336 anyhow::bail!(
337 "failed to upload directory via SFTP/SCP {} -> {}: {}",
338 src_path_display,
339 dest_path_str,
340 stderr,
341 );
342 })
343 }
344
345 fn start_proxy(
346 &self,
347 unique_identifier: String,
348 reconnect: bool,
349 incoming_tx: UnboundedSender<Envelope>,
350 outgoing_rx: UnboundedReceiver<Envelope>,
351 connection_activity_tx: Sender<()>,
352 delegate: Arc<dyn RemoteClientDelegate>,
353 cx: &mut AsyncApp,
354 ) -> Task<Result<i32>> {
355 delegate.set_status(Some("Starting proxy"), cx);
356
357 let Some(remote_binary_path) = self.remote_binary_path.clone() else {
358 return Task::ready(Err(anyhow!("Remote binary path not set")));
359 };
360
361 let mut proxy_args = vec![];
362 for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
363 if let Some(value) = std::env::var(env_var).ok() {
364 proxy_args.push(format!("{}='{}'", env_var, value));
365 }
366 }
367 proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
368 proxy_args.push("proxy".to_owned());
369 proxy_args.push("--identifier".to_owned());
370 proxy_args.push(unique_identifier);
371
372 if reconnect {
373 proxy_args.push("--reconnect".to_owned());
374 }
375
376 let ssh_proxy_process = match self
377 .socket
378 .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
379 // IMPORTANT: we kill this process when we drop the task that uses it.
380 .kill_on_drop(true)
381 .spawn()
382 {
383 Ok(process) => process,
384 Err(error) => {
385 return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
386 }
387 };
388
389 super::handle_rpc_messages_over_child_process_stdio(
390 ssh_proxy_process,
391 incoming_tx,
392 outgoing_rx,
393 connection_activity_tx,
394 cx,
395 )
396 }
397
398 fn path_style(&self) -> PathStyle {
399 self.ssh_path_style
400 }
401
402 fn has_wsl_interop(&self) -> bool {
403 false
404 }
405}
406
407impl SshRemoteConnection {
408 pub(crate) async fn new(
409 connection_options: SshConnectionOptions,
410 delegate: Arc<dyn RemoteClientDelegate>,
411 cx: &mut AsyncApp,
412 ) -> Result<Self> {
413 use askpass::AskPassResult;
414
415 let url = connection_options.ssh_url();
416
417 let temp_dir = tempfile::Builder::new()
418 .prefix("zed-ssh-session")
419 .tempdir()?;
420 let askpass_delegate = askpass::AskPassDelegate::new(cx, {
421 let delegate = delegate.clone();
422 move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
423 });
424
425 let mut askpass =
426 askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
427
428 delegate.set_status(Some("Connecting"), cx);
429
430 // Start the master SSH process, which does not do anything except for establish
431 // the connection and keep it open, allowing other ssh commands to reuse it
432 // via a control socket.
433 #[cfg(not(target_os = "windows"))]
434 let socket_path = temp_dir.path().join("ssh.sock");
435
436 #[cfg(target_os = "windows")]
437 let mut master_process = MasterProcess::new(
438 askpass.script_path().as_ref(),
439 connection_options.additional_args(),
440 &url,
441 )?;
442 #[cfg(not(target_os = "windows"))]
443 let mut master_process = MasterProcess::new(
444 askpass.script_path().as_ref(),
445 connection_options.additional_args(),
446 &socket_path,
447 &url,
448 )?;
449
450 let result = select_biased! {
451 result = askpass.run().fuse() => {
452 match result {
453 AskPassResult::CancelledByUser => {
454 master_process.as_mut().kill().ok();
455 anyhow::bail!("SSH connection canceled")
456 }
457 AskPassResult::Timedout => {
458 anyhow::bail!("connecting to host timed out")
459 }
460 }
461 }
462 _ = master_process.wait_connected().fuse() => {
463 anyhow::Ok(())
464 }
465 };
466
467 if let Err(e) = result {
468 return Err(e.context("Failed to connect to host"));
469 }
470
471 if master_process.as_mut().try_status()?.is_some() {
472 let mut output = Vec::new();
473 output.clear();
474 let mut stderr = master_process.as_mut().stderr.take().unwrap();
475 stderr.read_to_end(&mut output).await?;
476
477 let error_message = format!(
478 "failed to connect: {}",
479 String::from_utf8_lossy(&output).trim()
480 );
481 anyhow::bail!(error_message);
482 }
483
484 #[cfg(not(target_os = "windows"))]
485 let socket = SshSocket::new(connection_options, socket_path).await?;
486 #[cfg(target_os = "windows")]
487 let socket = SshSocket::new(
488 connection_options,
489 askpass
490 .get_password()
491 .or_else(|| askpass::EncryptedPassword::try_from("").ok())
492 .context("Failed to fetch askpass password")?,
493 cx.background_executor().clone(),
494 )
495 .await?;
496 drop(askpass);
497
498 let ssh_shell = socket.shell().await;
499 log::info!("Remote shell discovered: {}", ssh_shell);
500 let ssh_platform = socket.platform(ShellKind::new(&ssh_shell, false)).await?;
501 log::info!("Remote platform discovered: {:?}", ssh_platform);
502 let ssh_path_style = match ssh_platform.os {
503 "windows" => PathStyle::Windows,
504 _ => PathStyle::Posix,
505 };
506 let ssh_default_system_shell = String::from("/bin/sh");
507 let ssh_shell_kind = ShellKind::new(
508 &ssh_shell,
509 match ssh_platform.os {
510 "windows" => true,
511 _ => false,
512 },
513 );
514
515 let mut this = Self {
516 socket,
517 master_process: Mutex::new(Some(master_process)),
518 _temp_dir: temp_dir,
519 remote_binary_path: None,
520 ssh_path_style,
521 ssh_platform,
522 ssh_shell,
523 ssh_shell_kind,
524 ssh_default_system_shell,
525 };
526
527 let (release_channel, version) =
528 cx.update(|cx| (ReleaseChannel::global(cx), AppVersion::global(cx)))?;
529 this.remote_binary_path = Some(
530 this.ensure_server_binary(&delegate, release_channel, version, cx)
531 .await?,
532 );
533
534 Ok(this)
535 }
536
537 async fn ensure_server_binary(
538 &self,
539 delegate: &Arc<dyn RemoteClientDelegate>,
540 release_channel: ReleaseChannel,
541 version: Version,
542 cx: &mut AsyncApp,
543 ) -> Result<Arc<RelPath>> {
544 let version_str = match release_channel {
545 ReleaseChannel::Dev => "build".to_string(),
546 _ => version.to_string(),
547 };
548 let binary_name = format!(
549 "zed-remote-server-{}-{}",
550 release_channel.dev_name(),
551 version_str
552 );
553 let dst_path =
554 paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
555
556 #[cfg(debug_assertions)]
557 if let Some(remote_server_path) =
558 super::build_remote_server_from_source(&self.ssh_platform, delegate.as_ref(), cx)
559 .await?
560 {
561 let tmp_path = paths::remote_server_dir_relative().join(
562 RelPath::unix(&format!(
563 "download-{}-{}",
564 std::process::id(),
565 remote_server_path.file_name().unwrap().to_string_lossy()
566 ))
567 .unwrap(),
568 );
569 self.upload_local_server_binary(&remote_server_path, &tmp_path, delegate, cx)
570 .await?;
571 self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
572 .await?;
573 return Ok(dst_path);
574 }
575
576 if self
577 .socket
578 .run_command(
579 self.ssh_shell_kind,
580 &dst_path.display(self.path_style()),
581 &["version"],
582 true,
583 )
584 .await
585 .is_ok()
586 {
587 return Ok(dst_path);
588 }
589
590 let wanted_version = cx.update(|cx| match release_channel {
591 ReleaseChannel::Nightly => Ok(None),
592 ReleaseChannel::Dev => {
593 anyhow::bail!(
594 "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
595 dst_path
596 )
597 }
598 _ => Ok(Some(AppVersion::global(cx))),
599 })??;
600
601 let tmp_path_gz = remote_server_dir_relative().join(
602 RelPath::unix(&format!(
603 "{}-download-{}.gz",
604 binary_name,
605 std::process::id()
606 ))
607 .unwrap(),
608 );
609 if !self.socket.connection_options.upload_binary_over_ssh
610 && let Some(url) = delegate
611 .get_download_url(
612 self.ssh_platform,
613 release_channel,
614 wanted_version.clone(),
615 cx,
616 )
617 .await?
618 {
619 match self
620 .download_binary_on_server(&url, &tmp_path_gz, delegate, cx)
621 .await
622 {
623 Ok(_) => {
624 self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
625 .await
626 .context("extracting server binary")?;
627 return Ok(dst_path);
628 }
629 Err(e) => {
630 log::error!(
631 "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
632 )
633 }
634 }
635 }
636
637 let src_path = delegate
638 .download_server_binary_locally(
639 self.ssh_platform,
640 release_channel,
641 wanted_version.clone(),
642 cx,
643 )
644 .await
645 .context("downloading server binary locally")?;
646 self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
647 .await
648 .context("uploading server binary")?;
649 self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
650 .await
651 .context("extracting server binary")?;
652 Ok(dst_path)
653 }
654
655 async fn download_binary_on_server(
656 &self,
657 url: &str,
658 tmp_path_gz: &RelPath,
659 delegate: &Arc<dyn RemoteClientDelegate>,
660 cx: &mut AsyncApp,
661 ) -> Result<()> {
662 if let Some(parent) = tmp_path_gz.parent() {
663 self.socket
664 .run_command(
665 self.ssh_shell_kind,
666 "mkdir",
667 &["-p", parent.display(self.path_style()).as_ref()],
668 true,
669 )
670 .await?;
671 }
672
673 delegate.set_status(Some("Downloading remote development server on host"), cx);
674
675 let connection_timeout = self
676 .socket
677 .connection_options
678 .connection_timeout
679 .unwrap_or(10)
680 .to_string();
681
682 match self
683 .socket
684 .run_command(
685 self.ssh_shell_kind,
686 "curl",
687 &[
688 "-f",
689 "-L",
690 "--connect-timeout",
691 &connection_timeout,
692 url,
693 "-o",
694 &tmp_path_gz.display(self.path_style()),
695 ],
696 true,
697 )
698 .await
699 {
700 Ok(_) => {}
701 Err(e) => {
702 if self
703 .socket
704 .run_command(self.ssh_shell_kind, "which", &["curl"], true)
705 .await
706 .is_ok()
707 {
708 return Err(e);
709 }
710
711 log::info!("curl is not available, trying wget");
712 match self
713 .socket
714 .run_command(
715 self.ssh_shell_kind,
716 "wget",
717 &[
718 "--connect-timeout",
719 &connection_timeout,
720 "--tries",
721 "1",
722 url,
723 "-O",
724 &tmp_path_gz.display(self.path_style()),
725 ],
726 true,
727 )
728 .await
729 {
730 Ok(_) => {}
731 Err(e) => {
732 if self
733 .socket
734 .run_command(self.ssh_shell_kind, "which", &["wget"], true)
735 .await
736 .is_ok()
737 {
738 return Err(e);
739 } else {
740 anyhow::bail!("Neither curl nor wget is available");
741 }
742 }
743 }
744 }
745 }
746
747 Ok(())
748 }
749
750 async fn upload_local_server_binary(
751 &self,
752 src_path: &Path,
753 tmp_path_gz: &RelPath,
754 delegate: &Arc<dyn RemoteClientDelegate>,
755 cx: &mut AsyncApp,
756 ) -> Result<()> {
757 if let Some(parent) = tmp_path_gz.parent() {
758 self.socket
759 .run_command(
760 self.ssh_shell_kind,
761 "mkdir",
762 &["-p", parent.display(self.path_style()).as_ref()],
763 true,
764 )
765 .await?;
766 }
767
768 let src_stat = fs::metadata(&src_path).await?;
769 let size = src_stat.len();
770
771 let t0 = Instant::now();
772 delegate.set_status(Some("Uploading remote development server"), cx);
773 log::info!(
774 "uploading remote development server to {:?} ({}kb)",
775 tmp_path_gz,
776 size / 1024
777 );
778 self.upload_file(src_path, tmp_path_gz)
779 .await
780 .context("failed to upload server binary")?;
781 log::info!("uploaded remote development server in {:?}", t0.elapsed());
782 Ok(())
783 }
784
785 async fn extract_server_binary(
786 &self,
787 dst_path: &RelPath,
788 tmp_path: &RelPath,
789 delegate: &Arc<dyn RemoteClientDelegate>,
790 cx: &mut AsyncApp,
791 ) -> Result<()> {
792 delegate.set_status(Some("Extracting remote development server"), cx);
793 let server_mode = 0o755;
794
795 let shell_kind = ShellKind::Posix;
796 let orig_tmp_path = tmp_path.display(self.path_style());
797 let server_mode = format!("{:o}", server_mode);
798 let server_mode = shell_kind
799 .try_quote(&server_mode)
800 .context("shell quoting")?;
801 let dst_path = dst_path.display(self.path_style());
802 let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
803 let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
804 let orig_tmp_path = shell_kind
805 .try_quote(&orig_tmp_path)
806 .context("shell quoting")?;
807 let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
808 format!(
809 "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
810 )
811 } else {
812 let orig_tmp_path = shell_kind
813 .try_quote(&orig_tmp_path)
814 .context("shell quoting")?;
815 format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
816 };
817 let args = shell_kind.args_for_shell(false, script.to_string());
818 self.socket
819 .run_command(shell_kind, "sh", &args, true)
820 .await?;
821 Ok(())
822 }
823
824 fn build_scp_command(
825 &self,
826 src_path: &Path,
827 dest_path_str: &str,
828 args: Option<&[&str]>,
829 ) -> process::Command {
830 let mut command = util::command::new_smol_command("scp");
831 self.socket.ssh_options(&mut command, false).args(
832 self.socket
833 .connection_options
834 .port
835 .map(|port| vec!["-P".to_string(), port.to_string()])
836 .unwrap_or_default(),
837 );
838 if let Some(args) = args {
839 command.args(args);
840 }
841 command.arg(src_path).arg(format!(
842 "{}:{}",
843 self.socket.connection_options.scp_url(),
844 dest_path_str
845 ));
846 command
847 }
848
849 fn build_sftp_command(&self) -> process::Command {
850 let mut command = util::command::new_smol_command("sftp");
851 self.socket.ssh_options(&mut command, false).args(
852 self.socket
853 .connection_options
854 .port
855 .map(|port| vec!["-P".to_string(), port.to_string()])
856 .unwrap_or_default(),
857 );
858 command.arg("-b").arg("-");
859 command.arg(self.socket.connection_options.scp_url());
860 command.stdin(Stdio::piped());
861 command
862 }
863
864 async fn upload_file(&self, src_path: &Path, dest_path: &RelPath) -> Result<()> {
865 log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
866
867 let src_path_display = src_path.display().to_string();
868 let dest_path_str = dest_path.display(self.path_style());
869
870 // We will try SFTP first, and if that fails, we will fall back to SCP.
871 // If SCP fails also, we give up and return an error.
872 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
873 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
874 // This is for example the case on Windows as evidenced by this implementation snippet:
875 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
876 if Self::is_sftp_available().await {
877 log::debug!("using SFTP for file upload");
878 let mut command = self.build_sftp_command();
879 let sftp_batch = format!("put {src_path_display} {dest_path_str}\n");
880
881 let mut child = command.spawn()?;
882 if let Some(mut stdin) = child.stdin.take() {
883 use futures::AsyncWriteExt;
884 stdin.write_all(sftp_batch.as_bytes()).await?;
885 stdin.flush().await?;
886 }
887
888 let output = child.output().await?;
889 if output.status.success() {
890 return Ok(());
891 }
892
893 let stderr = String::from_utf8_lossy(&output.stderr);
894 log::debug!(
895 "failed to upload file via SFTP {src_path_display} -> {dest_path_str}: {stderr}"
896 );
897 }
898
899 log::debug!("using SCP for file upload");
900 let mut command = self.build_scp_command(src_path, &dest_path_str, None);
901 let output = command.output().await?;
902
903 if output.status.success() {
904 return Ok(());
905 }
906
907 let stderr = String::from_utf8_lossy(&output.stderr);
908 log::debug!(
909 "failed to upload file via SCP {src_path_display} -> {dest_path_str}: {stderr}",
910 );
911 anyhow::bail!(
912 "failed to upload file via STFP/SCP {} -> {}: {}",
913 src_path_display,
914 dest_path_str,
915 stderr,
916 );
917 }
918
919 async fn is_sftp_available() -> bool {
920 which::which("sftp").is_ok()
921 }
922}
923
924impl SshSocket {
925 #[cfg(not(target_os = "windows"))]
926 async fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
927 Ok(Self {
928 connection_options: options,
929 envs: HashMap::default(),
930 socket_path,
931 })
932 }
933
934 #[cfg(target_os = "windows")]
935 async fn new(
936 options: SshConnectionOptions,
937 password: askpass::EncryptedPassword,
938 executor: gpui::BackgroundExecutor,
939 ) -> Result<Self> {
940 let mut envs = HashMap::default();
941 let get_password =
942 move |_| Task::ready(std::ops::ControlFlow::Continue(Ok(password.clone())));
943
944 let _proxy = askpass::PasswordProxy::new(get_password, executor).await?;
945 envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
946 envs.insert(
947 "SSH_ASKPASS".into(),
948 _proxy.script_path().as_ref().display().to_string(),
949 );
950
951 Ok(Self {
952 connection_options: options,
953 envs,
954 _proxy,
955 })
956 }
957
958 // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
959 // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
960 // and passes -l as an argument to sh, not to ls.
961 // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
962 // into a machine. You must use `cd` to get back to $HOME.
963 // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
964 fn ssh_command(
965 &self,
966 shell_kind: ShellKind,
967 program: &str,
968 args: &[impl AsRef<str>],
969 allow_pseudo_tty: bool,
970 ) -> process::Command {
971 let mut command = util::command::new_smol_command("ssh");
972 let program = shell_kind.prepend_command_prefix(program);
973 let mut to_run = shell_kind
974 .try_quote_prefix_aware(&program)
975 .expect("shell quoting")
976 .into_owned();
977 for arg in args {
978 // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
979 debug_assert!(
980 !arg.as_ref().contains('\n'),
981 "multiline arguments do not work in all shells"
982 );
983 to_run.push(' ');
984 to_run.push_str(&shell_kind.try_quote(arg.as_ref()).expect("shell quoting"));
985 }
986 let separator = shell_kind.sequential_commands_separator();
987 let to_run = format!("cd{separator} {to_run}");
988 self.ssh_options(&mut command, true)
989 .arg(self.connection_options.ssh_url());
990 if !allow_pseudo_tty {
991 command.arg("-T");
992 }
993 command.arg(to_run);
994 log::debug!("ssh {:?}", command);
995 command
996 }
997
998 async fn run_command(
999 &self,
1000 shell_kind: ShellKind,
1001 program: &str,
1002 args: &[impl AsRef<str>],
1003 allow_pseudo_tty: bool,
1004 ) -> Result<String> {
1005 let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
1006 let output = command.output().await?;
1007 anyhow::ensure!(
1008 output.status.success(),
1009 "failed to run command {command:?}: {}",
1010 String::from_utf8_lossy(&output.stderr)
1011 );
1012 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1013 }
1014
1015 #[cfg(not(target_os = "windows"))]
1016 fn ssh_options<'a>(
1017 &self,
1018 command: &'a mut process::Command,
1019 include_port_forwards: bool,
1020 ) -> &'a mut process::Command {
1021 let args = if include_port_forwards {
1022 self.connection_options.additional_args()
1023 } else {
1024 self.connection_options.additional_args_for_scp()
1025 };
1026
1027 command
1028 .stdin(Stdio::piped())
1029 .stdout(Stdio::piped())
1030 .stderr(Stdio::piped())
1031 .args(args)
1032 .args(["-o", "ControlMaster=no", "-o"])
1033 .arg(format!("ControlPath={}", self.socket_path.display()))
1034 }
1035
1036 #[cfg(target_os = "windows")]
1037 fn ssh_options<'a>(
1038 &self,
1039 command: &'a mut process::Command,
1040 include_port_forwards: bool,
1041 ) -> &'a mut process::Command {
1042 let args = if include_port_forwards {
1043 self.connection_options.additional_args()
1044 } else {
1045 self.connection_options.additional_args_for_scp()
1046 };
1047
1048 command
1049 .stdin(Stdio::piped())
1050 .stdout(Stdio::piped())
1051 .stderr(Stdio::piped())
1052 .args(args)
1053 .envs(self.envs.clone())
1054 }
1055
1056 // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
1057 // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
1058 #[cfg(not(target_os = "windows"))]
1059 fn ssh_args(&self) -> Vec<String> {
1060 let mut arguments = self.connection_options.additional_args();
1061 arguments.extend(vec![
1062 "-o".to_string(),
1063 "ControlMaster=no".to_string(),
1064 "-o".to_string(),
1065 format!("ControlPath={}", self.socket_path.display()),
1066 self.connection_options.ssh_url(),
1067 ]);
1068 arguments
1069 }
1070
1071 #[cfg(target_os = "windows")]
1072 fn ssh_args(&self) -> Vec<String> {
1073 let mut arguments = self.connection_options.additional_args();
1074 arguments.push(self.connection_options.ssh_url());
1075 arguments
1076 }
1077
1078 async fn platform(&self, shell: ShellKind) -> Result<RemotePlatform> {
1079 let output = self.run_command(shell, "uname", &["-sm"], false).await?;
1080 parse_platform(&output)
1081 }
1082
1083 async fn shell(&self) -> String {
1084 const DEFAULT_SHELL: &str = "sh";
1085 match self
1086 .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
1087 .await
1088 {
1089 Ok(output) => parse_shell(&output, DEFAULT_SHELL),
1090 Err(e) => {
1091 log::error!("Failed to detect remote shell: {e}");
1092 DEFAULT_SHELL.to_owned()
1093 }
1094 }
1095 }
1096}
1097
1098fn parse_port_number(port_str: &str) -> Result<u16> {
1099 port_str
1100 .parse()
1101 .with_context(|| format!("parsing port number: {port_str}"))
1102}
1103
1104fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
1105 let parts: Vec<&str> = spec.split(':').collect();
1106
1107 match parts.len() {
1108 4 => {
1109 let local_port = parse_port_number(parts[1])?;
1110 let remote_port = parse_port_number(parts[3])?;
1111
1112 Ok(SshPortForwardOption {
1113 local_host: Some(parts[0].to_string()),
1114 local_port,
1115 remote_host: Some(parts[2].to_string()),
1116 remote_port,
1117 })
1118 }
1119 3 => {
1120 let local_port = parse_port_number(parts[0])?;
1121 let remote_port = parse_port_number(parts[2])?;
1122
1123 Ok(SshPortForwardOption {
1124 local_host: None,
1125 local_port,
1126 remote_host: Some(parts[1].to_string()),
1127 remote_port,
1128 })
1129 }
1130 _ => anyhow::bail!("Invalid port forward format"),
1131 }
1132}
1133
1134impl SshConnectionOptions {
1135 pub fn parse_command_line(input: &str) -> Result<Self> {
1136 let input = input.trim_start_matches("ssh ");
1137 let mut hostname: Option<String> = None;
1138 let mut username: Option<String> = None;
1139 let mut port: Option<u16> = None;
1140 let mut args = Vec::new();
1141 let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
1142
1143 // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
1144 const ALLOWED_OPTS: &[&str] = &[
1145 "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
1146 ];
1147 const ALLOWED_ARGS: &[&str] = &[
1148 "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
1149 "-w",
1150 ];
1151
1152 let mut tokens = ShellKind::Posix
1153 .split(input)
1154 .context("invalid input")?
1155 .into_iter();
1156
1157 'outer: while let Some(arg) = tokens.next() {
1158 if ALLOWED_OPTS.contains(&(&arg as &str)) {
1159 args.push(arg.to_string());
1160 continue;
1161 }
1162 if arg == "-p" {
1163 port = tokens.next().and_then(|arg| arg.parse().ok());
1164 continue;
1165 } else if let Some(p) = arg.strip_prefix("-p") {
1166 port = p.parse().ok();
1167 continue;
1168 }
1169 if arg == "-l" {
1170 username = tokens.next();
1171 continue;
1172 } else if let Some(l) = arg.strip_prefix("-l") {
1173 username = Some(l.to_string());
1174 continue;
1175 }
1176 if arg == "-L" || arg.starts_with("-L") {
1177 let forward_spec = if arg == "-L" {
1178 tokens.next()
1179 } else {
1180 Some(arg.strip_prefix("-L").unwrap().to_string())
1181 };
1182
1183 if let Some(spec) = forward_spec {
1184 port_forwards.push(parse_port_forward_spec(&spec)?);
1185 } else {
1186 anyhow::bail!("Missing port forward format");
1187 }
1188 }
1189
1190 for a in ALLOWED_ARGS {
1191 if arg == *a {
1192 args.push(arg);
1193 if let Some(next) = tokens.next() {
1194 args.push(next);
1195 }
1196 continue 'outer;
1197 } else if arg.starts_with(a) {
1198 args.push(arg);
1199 continue 'outer;
1200 }
1201 }
1202 if arg.starts_with("-") || hostname.is_some() {
1203 anyhow::bail!("unsupported argument: {:?}", arg);
1204 }
1205 let mut input = &arg as &str;
1206 // Destination might be: username1@username2@ip2@ip1
1207 if let Some((u, rest)) = input.rsplit_once('@') {
1208 input = rest;
1209 username = Some(u.to_string());
1210 }
1211 if let Some((rest, p)) = input.split_once(':') {
1212 input = rest;
1213 port = p.parse().ok()
1214 }
1215 hostname = Some(input.to_string())
1216 }
1217
1218 let Some(hostname) = hostname else {
1219 anyhow::bail!("missing hostname");
1220 };
1221
1222 let port_forwards = match port_forwards.len() {
1223 0 => None,
1224 _ => Some(port_forwards),
1225 };
1226
1227 Ok(Self {
1228 host: hostname,
1229 username,
1230 port,
1231 port_forwards,
1232 args: Some(args),
1233 password: None,
1234 nickname: None,
1235 upload_binary_over_ssh: false,
1236 connection_timeout: None,
1237 })
1238 }
1239
1240 pub fn ssh_url(&self) -> String {
1241 let mut result = String::from("ssh://");
1242 if let Some(username) = &self.username {
1243 // Username might be: username1@username2@ip2
1244 let username = urlencoding::encode(username);
1245 result.push_str(&username);
1246 result.push('@');
1247 }
1248 result.push_str(&self.host);
1249 if let Some(port) = self.port {
1250 result.push(':');
1251 result.push_str(&port.to_string());
1252 }
1253 result
1254 }
1255
1256 pub fn additional_args_for_scp(&self) -> Vec<String> {
1257 self.args.iter().flatten().cloned().collect::<Vec<String>>()
1258 }
1259
1260 pub fn additional_args(&self) -> Vec<String> {
1261 let mut args = self.additional_args_for_scp();
1262
1263 if let Some(timeout) = self.connection_timeout {
1264 args.extend(["-o".to_string(), format!("ConnectTimeout={}", timeout)]);
1265 }
1266
1267 if let Some(forwards) = &self.port_forwards {
1268 args.extend(forwards.iter().map(|pf| {
1269 let local_host = match &pf.local_host {
1270 Some(host) => host,
1271 None => "localhost",
1272 };
1273 let remote_host = match &pf.remote_host {
1274 Some(host) => host,
1275 None => "localhost",
1276 };
1277
1278 format!(
1279 "-L{}:{}:{}:{}",
1280 local_host, pf.local_port, remote_host, pf.remote_port
1281 )
1282 }));
1283 }
1284
1285 args
1286 }
1287
1288 fn scp_url(&self) -> String {
1289 if let Some(username) = &self.username {
1290 format!("{}@{}", username, self.host)
1291 } else {
1292 self.host.clone()
1293 }
1294 }
1295
1296 pub fn connection_string(&self) -> String {
1297 let host = if let Some(username) = &self.username {
1298 format!("{}@{}", username, self.host)
1299 } else {
1300 self.host.clone()
1301 };
1302 if let Some(port) = &self.port {
1303 format!("{}:{}", host, port)
1304 } else {
1305 host
1306 }
1307 }
1308}
1309
1310fn build_command(
1311 input_program: Option<String>,
1312 input_args: &[String],
1313 input_env: &HashMap<String, String>,
1314 working_dir: Option<String>,
1315 port_forward: Option<(u16, String, u16)>,
1316 ssh_env: HashMap<String, String>,
1317 ssh_path_style: PathStyle,
1318 ssh_shell: &str,
1319 ssh_shell_kind: ShellKind,
1320 ssh_args: Vec<String>,
1321) -> Result<CommandTemplate> {
1322 use std::fmt::Write as _;
1323
1324 let mut exec = String::new();
1325 if let Some(working_dir) = working_dir {
1326 let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1327
1328 // shlex will wrap the command in single quotes (''), disabling ~ expansion,
1329 // replace with something that works
1330 const TILDE_PREFIX: &'static str = "~/";
1331 if working_dir.starts_with(TILDE_PREFIX) {
1332 let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
1333 write!(
1334 exec,
1335 "cd \"$HOME/{working_dir}\" {} ",
1336 ssh_shell_kind.sequential_and_commands_separator()
1337 )?;
1338 } else {
1339 write!(
1340 exec,
1341 "cd \"{working_dir}\" {} ",
1342 ssh_shell_kind.sequential_and_commands_separator()
1343 )?;
1344 }
1345 } else {
1346 write!(
1347 exec,
1348 "cd {} ",
1349 ssh_shell_kind.sequential_and_commands_separator()
1350 )?;
1351 };
1352 write!(exec, "exec env ")?;
1353
1354 for (k, v) in input_env.iter() {
1355 write!(
1356 exec,
1357 "{}={} ",
1358 k,
1359 ssh_shell_kind.try_quote(v).context("shell quoting")?
1360 )?;
1361 }
1362
1363 if let Some(input_program) = input_program {
1364 write!(
1365 exec,
1366 "{}",
1367 ssh_shell_kind
1368 .try_quote_prefix_aware(&input_program)
1369 .context("shell quoting")?
1370 )?;
1371 for arg in input_args {
1372 let arg = ssh_shell_kind.try_quote(&arg).context("shell quoting")?;
1373 write!(exec, " {}", &arg)?;
1374 }
1375 } else {
1376 write!(exec, "{ssh_shell} -l")?;
1377 };
1378 let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false)
1379 .build(Some(exec.clone()), &[]);
1380
1381 let mut args = Vec::new();
1382 args.extend(ssh_args);
1383
1384 if let Some((local_port, host, remote_port)) = port_forward {
1385 args.push("-L".into());
1386 args.push(format!("{local_port}:{host}:{remote_port}"));
1387 }
1388
1389 args.push("-t".into());
1390 args.push(command);
1391 args.extend(command_args);
1392
1393 Ok(CommandTemplate {
1394 program: "ssh".into(),
1395 args,
1396 env: ssh_env,
1397 })
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402 use super::*;
1403
1404 #[test]
1405 fn test_build_command() -> Result<()> {
1406 let mut input_env = HashMap::default();
1407 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1408 let mut env = HashMap::default();
1409 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1410
1411 let command = build_command(
1412 Some("remote_program".to_string()),
1413 &["arg1".to_string(), "arg2".to_string()],
1414 &input_env,
1415 Some("~/work".to_string()),
1416 None,
1417 env.clone(),
1418 PathStyle::Posix,
1419 "/bin/fish",
1420 ShellKind::Fish,
1421 vec!["-p".to_string(), "2222".to_string()],
1422 )?;
1423
1424 assert_eq!(command.program, "ssh");
1425 assert_eq!(
1426 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1427 [
1428 "-p",
1429 "2222",
1430 "-t",
1431 "/bin/fish",
1432 "-i",
1433 "-c",
1434 "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
1435 ]
1436 );
1437 assert_eq!(command.env, env);
1438
1439 let mut input_env = HashMap::default();
1440 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1441 let mut env = HashMap::default();
1442 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1443
1444 let command = build_command(
1445 None,
1446 &["arg1".to_string(), "arg2".to_string()],
1447 &input_env,
1448 None,
1449 Some((1, "foo".to_owned(), 2)),
1450 env.clone(),
1451 PathStyle::Posix,
1452 "/bin/fish",
1453 ShellKind::Fish,
1454 vec!["-p".to_string(), "2222".to_string()],
1455 )?;
1456
1457 assert_eq!(command.program, "ssh");
1458 assert_eq!(
1459 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1460 [
1461 "-p",
1462 "2222",
1463 "-L",
1464 "1:foo:2",
1465 "-t",
1466 "/bin/fish",
1467 "-i",
1468 "-c",
1469 "cd && exec env INPUT_VA=val /bin/fish -l"
1470 ]
1471 );
1472 assert_eq!(command.env, env);
1473
1474 Ok(())
1475 }
1476
1477 #[test]
1478 fn scp_args_exclude_port_forward_flags() {
1479 let options = SshConnectionOptions {
1480 host: "example.com".into(),
1481 args: Some(vec![
1482 "-p".to_string(),
1483 "2222".to_string(),
1484 "-o".to_string(),
1485 "StrictHostKeyChecking=no".to_string(),
1486 ]),
1487 port_forwards: Some(vec![SshPortForwardOption {
1488 local_host: Some("127.0.0.1".to_string()),
1489 local_port: 8080,
1490 remote_host: Some("127.0.0.1".to_string()),
1491 remote_port: 80,
1492 }]),
1493 ..Default::default()
1494 };
1495
1496 let ssh_args = options.additional_args();
1497 assert!(
1498 ssh_args.iter().any(|arg| arg.starts_with("-L")),
1499 "expected ssh args to include port-forward: {ssh_args:?}"
1500 );
1501
1502 let scp_args = options.additional_args_for_scp();
1503 assert_eq!(
1504 scp_args,
1505 vec![
1506 "-p".to_string(),
1507 "2222".to_string(),
1508 "-o".to_string(),
1509 "StrictHostKeyChecking=no".to_string(),
1510 ]
1511 );
1512 }
1513}