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