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