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
399impl SshRemoteConnection {
400 pub(crate) async fn new(
401 connection_options: SshConnectionOptions,
402 delegate: Arc<dyn RemoteClientDelegate>,
403 cx: &mut AsyncApp,
404 ) -> Result<Self> {
405 use askpass::AskPassResult;
406
407 let url = connection_options.ssh_url();
408
409 let temp_dir = tempfile::Builder::new()
410 .prefix("zed-ssh-session")
411 .tempdir()?;
412 let askpass_delegate = askpass::AskPassDelegate::new(cx, {
413 let delegate = delegate.clone();
414 move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
415 });
416
417 let mut askpass =
418 askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
419
420 delegate.set_status(Some("Connecting"), cx);
421
422 // Start the master SSH process, which does not do anything except for establish
423 // the connection and keep it open, allowing other ssh commands to reuse it
424 // via a control socket.
425 #[cfg(not(target_os = "windows"))]
426 let socket_path = temp_dir.path().join("ssh.sock");
427
428 #[cfg(target_os = "windows")]
429 let mut master_process = MasterProcess::new(
430 askpass.script_path().as_ref(),
431 connection_options.additional_args(),
432 &url,
433 )?;
434 #[cfg(not(target_os = "windows"))]
435 let mut master_process = MasterProcess::new(
436 askpass.script_path().as_ref(),
437 connection_options.additional_args(),
438 &socket_path,
439 &url,
440 )?;
441
442 let result = select_biased! {
443 result = askpass.run().fuse() => {
444 match result {
445 AskPassResult::CancelledByUser => {
446 master_process.as_mut().kill().ok();
447 anyhow::bail!("SSH connection canceled")
448 }
449 AskPassResult::Timedout => {
450 anyhow::bail!("connecting to host timed out")
451 }
452 }
453 }
454 _ = master_process.wait_connected().fuse() => {
455 anyhow::Ok(())
456 }
457 };
458
459 if let Err(e) = result {
460 return Err(e.context("Failed to connect to host"));
461 }
462
463 if master_process.as_mut().try_status()?.is_some() {
464 let mut output = Vec::new();
465 output.clear();
466 let mut stderr = master_process.as_mut().stderr.take().unwrap();
467 stderr.read_to_end(&mut output).await?;
468
469 let error_message = format!(
470 "failed to connect: {}",
471 String::from_utf8_lossy(&output).trim()
472 );
473 anyhow::bail!(error_message);
474 }
475
476 #[cfg(not(target_os = "windows"))]
477 let socket = SshSocket::new(connection_options, socket_path).await?;
478 #[cfg(target_os = "windows")]
479 let socket = SshSocket::new(
480 connection_options,
481 askpass
482 .get_password()
483 .or_else(|| askpass::EncryptedPassword::try_from("").ok())
484 .context("Failed to fetch askpass password")?,
485 cx.background_executor().clone(),
486 )
487 .await?;
488 drop(askpass);
489
490 let ssh_shell = socket.shell().await;
491 log::info!("Remote shell discovered: {}", ssh_shell);
492 let ssh_platform = socket.platform(ShellKind::new(&ssh_shell, false)).await?;
493 log::info!("Remote platform discovered: {:?}", ssh_platform);
494 let ssh_path_style = match ssh_platform.os {
495 "windows" => PathStyle::Windows,
496 _ => PathStyle::Posix,
497 };
498 let ssh_default_system_shell = String::from("/bin/sh");
499 let ssh_shell_kind = ShellKind::new(
500 &ssh_shell,
501 match ssh_platform.os {
502 "windows" => true,
503 _ => false,
504 },
505 );
506
507 let mut this = Self {
508 socket,
509 master_process: Mutex::new(Some(master_process)),
510 _temp_dir: temp_dir,
511 remote_binary_path: None,
512 ssh_path_style,
513 ssh_platform,
514 ssh_shell,
515 ssh_shell_kind,
516 ssh_default_system_shell,
517 };
518
519 let (release_channel, version) =
520 cx.update(|cx| (ReleaseChannel::global(cx), AppVersion::global(cx)))?;
521 this.remote_binary_path = Some(
522 this.ensure_server_binary(&delegate, release_channel, version, cx)
523 .await?,
524 );
525
526 Ok(this)
527 }
528
529 async fn ensure_server_binary(
530 &self,
531 delegate: &Arc<dyn RemoteClientDelegate>,
532 release_channel: ReleaseChannel,
533 version: Version,
534 cx: &mut AsyncApp,
535 ) -> Result<Arc<RelPath>> {
536 let version_str = match release_channel {
537 ReleaseChannel::Dev => "build".to_string(),
538 _ => version.to_string(),
539 };
540 let binary_name = format!(
541 "zed-remote-server-{}-{}",
542 release_channel.dev_name(),
543 version_str
544 );
545 let dst_path =
546 paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
547
548 #[cfg(debug_assertions)]
549 if let Some(remote_server_path) =
550 super::build_remote_server_from_source(&self.ssh_platform, delegate.as_ref(), cx)
551 .await?
552 {
553 let tmp_path = paths::remote_server_dir_relative().join(
554 RelPath::unix(&format!(
555 "download-{}-{}",
556 std::process::id(),
557 remote_server_path.file_name().unwrap().to_string_lossy()
558 ))
559 .unwrap(),
560 );
561 self.upload_local_server_binary(&remote_server_path, &tmp_path, delegate, cx)
562 .await?;
563 self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
564 .await?;
565 return Ok(dst_path);
566 }
567
568 if self
569 .socket
570 .run_command(
571 self.ssh_shell_kind,
572 &dst_path.display(self.path_style()),
573 &["version"],
574 true,
575 )
576 .await
577 .is_ok()
578 {
579 return Ok(dst_path);
580 }
581
582 let wanted_version = cx.update(|cx| match release_channel {
583 ReleaseChannel::Nightly => Ok(None),
584 ReleaseChannel::Dev => {
585 anyhow::bail!(
586 "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
587 dst_path
588 )
589 }
590 _ => Ok(Some(AppVersion::global(cx))),
591 })??;
592
593 let tmp_path_gz = remote_server_dir_relative().join(
594 RelPath::unix(&format!(
595 "{}-download-{}.gz",
596 binary_name,
597 std::process::id()
598 ))
599 .unwrap(),
600 );
601 if !self.socket.connection_options.upload_binary_over_ssh
602 && let Some(url) = delegate
603 .get_download_url(
604 self.ssh_platform,
605 release_channel,
606 wanted_version.clone(),
607 cx,
608 )
609 .await?
610 {
611 match self
612 .download_binary_on_server(&url, &tmp_path_gz, delegate, cx)
613 .await
614 {
615 Ok(_) => {
616 self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
617 .await
618 .context("extracting server binary")?;
619 return Ok(dst_path);
620 }
621 Err(e) => {
622 log::error!(
623 "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
624 )
625 }
626 }
627 }
628
629 let src_path = delegate
630 .download_server_binary_locally(
631 self.ssh_platform,
632 release_channel,
633 wanted_version.clone(),
634 cx,
635 )
636 .await
637 .context("downloading server binary locally")?;
638 self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
639 .await
640 .context("uploading server binary")?;
641 self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
642 .await
643 .context("extracting server binary")?;
644 Ok(dst_path)
645 }
646
647 async fn download_binary_on_server(
648 &self,
649 url: &str,
650 tmp_path_gz: &RelPath,
651 delegate: &Arc<dyn RemoteClientDelegate>,
652 cx: &mut AsyncApp,
653 ) -> Result<()> {
654 if let Some(parent) = tmp_path_gz.parent() {
655 self.socket
656 .run_command(
657 self.ssh_shell_kind,
658 "mkdir",
659 &["-p", parent.display(self.path_style()).as_ref()],
660 true,
661 )
662 .await?;
663 }
664
665 delegate.set_status(Some("Downloading remote development server on host"), cx);
666
667 match self
668 .socket
669 .run_command(
670 self.ssh_shell_kind,
671 "curl",
672 &[
673 "-f",
674 "-L",
675 url,
676 "-o",
677 &tmp_path_gz.display(self.path_style()),
678 ],
679 true,
680 )
681 .await
682 {
683 Ok(_) => {}
684 Err(e) => {
685 if self
686 .socket
687 .run_command(self.ssh_shell_kind, "which", &["curl"], true)
688 .await
689 .is_ok()
690 {
691 return Err(e);
692 }
693
694 log::info!("curl is not available, trying wget");
695 match self
696 .socket
697 .run_command(
698 self.ssh_shell_kind,
699 "wget",
700 &[url, "-O", &tmp_path_gz.display(self.path_style())],
701 true,
702 )
703 .await
704 {
705 Ok(_) => {}
706 Err(e) => {
707 if self
708 .socket
709 .run_command(self.ssh_shell_kind, "which", &["wget"], true)
710 .await
711 .is_ok()
712 {
713 return Err(e);
714 } else {
715 anyhow::bail!("Neither curl nor wget is available");
716 }
717 }
718 }
719 }
720 }
721
722 Ok(())
723 }
724
725 async fn upload_local_server_binary(
726 &self,
727 src_path: &Path,
728 tmp_path_gz: &RelPath,
729 delegate: &Arc<dyn RemoteClientDelegate>,
730 cx: &mut AsyncApp,
731 ) -> Result<()> {
732 if let Some(parent) = tmp_path_gz.parent() {
733 self.socket
734 .run_command(
735 self.ssh_shell_kind,
736 "mkdir",
737 &["-p", parent.display(self.path_style()).as_ref()],
738 true,
739 )
740 .await?;
741 }
742
743 let src_stat = fs::metadata(&src_path).await?;
744 let size = src_stat.len();
745
746 let t0 = Instant::now();
747 delegate.set_status(Some("Uploading remote development server"), cx);
748 log::info!(
749 "uploading remote development server to {:?} ({}kb)",
750 tmp_path_gz,
751 size / 1024
752 );
753 self.upload_file(src_path, tmp_path_gz)
754 .await
755 .context("failed to upload server binary")?;
756 log::info!("uploaded remote development server in {:?}", t0.elapsed());
757 Ok(())
758 }
759
760 async fn extract_server_binary(
761 &self,
762 dst_path: &RelPath,
763 tmp_path: &RelPath,
764 delegate: &Arc<dyn RemoteClientDelegate>,
765 cx: &mut AsyncApp,
766 ) -> Result<()> {
767 delegate.set_status(Some("Extracting remote development server"), cx);
768 let server_mode = 0o755;
769
770 let shell_kind = ShellKind::Posix;
771 let orig_tmp_path = tmp_path.display(self.path_style());
772 let server_mode = format!("{:o}", server_mode);
773 let server_mode = shell_kind
774 .try_quote(&server_mode)
775 .context("shell quoting")?;
776 let dst_path = dst_path.display(self.path_style());
777 let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
778 let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
779 let orig_tmp_path = shell_kind
780 .try_quote(&orig_tmp_path)
781 .context("shell quoting")?;
782 let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
783 format!(
784 "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
785 )
786 } else {
787 let orig_tmp_path = shell_kind
788 .try_quote(&orig_tmp_path)
789 .context("shell quoting")?;
790 format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
791 };
792 let args = shell_kind.args_for_shell(false, script.to_string());
793 self.socket
794 .run_command(shell_kind, "sh", &args, true)
795 .await?;
796 Ok(())
797 }
798
799 fn build_scp_command(
800 &self,
801 src_path: &Path,
802 dest_path_str: &str,
803 args: Option<&[&str]>,
804 ) -> process::Command {
805 let mut command = util::command::new_smol_command("scp");
806 self.socket.ssh_options(&mut command, false).args(
807 self.socket
808 .connection_options
809 .port
810 .map(|port| vec!["-P".to_string(), port.to_string()])
811 .unwrap_or_default(),
812 );
813 if let Some(args) = args {
814 command.args(args);
815 }
816 command.arg(src_path).arg(format!(
817 "{}:{}",
818 self.socket.connection_options.scp_url(),
819 dest_path_str
820 ));
821 command
822 }
823
824 fn build_sftp_command(&self) -> process::Command {
825 let mut command = util::command::new_smol_command("sftp");
826 self.socket.ssh_options(&mut command, false).args(
827 self.socket
828 .connection_options
829 .port
830 .map(|port| vec!["-P".to_string(), port.to_string()])
831 .unwrap_or_default(),
832 );
833 command.arg("-b").arg("-");
834 command.arg(self.socket.connection_options.scp_url());
835 command.stdin(Stdio::piped());
836 command
837 }
838
839 async fn upload_file(&self, src_path: &Path, dest_path: &RelPath) -> Result<()> {
840 log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
841
842 let src_path_display = src_path.display().to_string();
843 let dest_path_str = dest_path.display(self.path_style());
844
845 // We will try SFTP first, and if that fails, we will fall back to SCP.
846 // If SCP fails also, we give up and return an error.
847 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
848 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
849 // This is for example the case on Windows as evidenced by this implementation snippet:
850 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
851 if Self::is_sftp_available().await {
852 log::debug!("using SFTP for file upload");
853 let mut command = self.build_sftp_command();
854 let sftp_batch = format!("put {src_path_display} {dest_path_str}\n");
855
856 let mut child = command.spawn()?;
857 if let Some(mut stdin) = child.stdin.take() {
858 use futures::AsyncWriteExt;
859 stdin.write_all(sftp_batch.as_bytes()).await?;
860 stdin.flush().await?;
861 }
862
863 let output = child.output().await?;
864 if output.status.success() {
865 return Ok(());
866 }
867
868 let stderr = String::from_utf8_lossy(&output.stderr);
869 log::debug!(
870 "failed to upload file via SFTP {src_path_display} -> {dest_path_str}: {stderr}"
871 );
872 }
873
874 log::debug!("using SCP for file upload");
875 let mut command = self.build_scp_command(src_path, &dest_path_str, None);
876 let output = command.output().await?;
877
878 if output.status.success() {
879 return Ok(());
880 }
881
882 let stderr = String::from_utf8_lossy(&output.stderr);
883 log::debug!(
884 "failed to upload file via SCP {src_path_display} -> {dest_path_str}: {stderr}",
885 );
886 anyhow::bail!(
887 "failed to upload file via STFP/SCP {} -> {}: {}",
888 src_path_display,
889 dest_path_str,
890 stderr,
891 );
892 }
893
894 async fn is_sftp_available() -> bool {
895 which::which("sftp").is_ok()
896 }
897}
898
899impl SshSocket {
900 #[cfg(not(target_os = "windows"))]
901 async fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
902 Ok(Self {
903 connection_options: options,
904 envs: HashMap::default(),
905 socket_path,
906 })
907 }
908
909 #[cfg(target_os = "windows")]
910 async fn new(
911 options: SshConnectionOptions,
912 password: askpass::EncryptedPassword,
913 executor: gpui::BackgroundExecutor,
914 ) -> Result<Self> {
915 let mut envs = HashMap::default();
916 let get_password =
917 move |_| Task::ready(std::ops::ControlFlow::Continue(Ok(password.clone())));
918
919 let _proxy = askpass::PasswordProxy::new(get_password, executor).await?;
920 envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
921 envs.insert(
922 "SSH_ASKPASS".into(),
923 _proxy.script_path().as_ref().display().to_string(),
924 );
925
926 Ok(Self {
927 connection_options: options,
928 envs,
929 _proxy,
930 })
931 }
932
933 // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
934 // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
935 // and passes -l as an argument to sh, not to ls.
936 // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
937 // into a machine. You must use `cd` to get back to $HOME.
938 // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
939 fn ssh_command(
940 &self,
941 shell_kind: ShellKind,
942 program: &str,
943 args: &[impl AsRef<str>],
944 allow_pseudo_tty: bool,
945 ) -> process::Command {
946 let mut command = util::command::new_smol_command("ssh");
947 let program = shell_kind.prepend_command_prefix(program);
948 let mut to_run = shell_kind
949 .try_quote_prefix_aware(&program)
950 .expect("shell quoting")
951 .into_owned();
952 for arg in args {
953 // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
954 debug_assert!(
955 !arg.as_ref().contains('\n'),
956 "multiline arguments do not work in all shells"
957 );
958 to_run.push(' ');
959 to_run.push_str(&shell_kind.try_quote(arg.as_ref()).expect("shell quoting"));
960 }
961 let separator = shell_kind.sequential_commands_separator();
962 let to_run = format!("cd{separator} {to_run}");
963 self.ssh_options(&mut command, true)
964 .arg(self.connection_options.ssh_url());
965 if !allow_pseudo_tty {
966 command.arg("-T");
967 }
968 command.arg(to_run);
969 log::debug!("ssh {:?}", command);
970 command
971 }
972
973 async fn run_command(
974 &self,
975 shell_kind: ShellKind,
976 program: &str,
977 args: &[impl AsRef<str>],
978 allow_pseudo_tty: bool,
979 ) -> Result<String> {
980 let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
981 let output = command.output().await?;
982 anyhow::ensure!(
983 output.status.success(),
984 "failed to run command {command:?}: {}",
985 String::from_utf8_lossy(&output.stderr)
986 );
987 Ok(String::from_utf8_lossy(&output.stdout).to_string())
988 }
989
990 #[cfg(not(target_os = "windows"))]
991 fn ssh_options<'a>(
992 &self,
993 command: &'a mut process::Command,
994 include_port_forwards: bool,
995 ) -> &'a mut process::Command {
996 let args = if include_port_forwards {
997 self.connection_options.additional_args()
998 } else {
999 self.connection_options.additional_args_for_scp()
1000 };
1001
1002 command
1003 .stdin(Stdio::piped())
1004 .stdout(Stdio::piped())
1005 .stderr(Stdio::piped())
1006 .args(args)
1007 .args(["-o", "ControlMaster=no", "-o"])
1008 .arg(format!("ControlPath={}", self.socket_path.display()))
1009 }
1010
1011 #[cfg(target_os = "windows")]
1012 fn ssh_options<'a>(
1013 &self,
1014 command: &'a mut process::Command,
1015 include_port_forwards: bool,
1016 ) -> &'a mut process::Command {
1017 let args = if include_port_forwards {
1018 self.connection_options.additional_args()
1019 } else {
1020 self.connection_options.additional_args_for_scp()
1021 };
1022
1023 command
1024 .stdin(Stdio::piped())
1025 .stdout(Stdio::piped())
1026 .stderr(Stdio::piped())
1027 .args(args)
1028 .envs(self.envs.clone())
1029 }
1030
1031 // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
1032 // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
1033 #[cfg(not(target_os = "windows"))]
1034 fn ssh_args(&self) -> Vec<String> {
1035 let mut arguments = self.connection_options.additional_args();
1036 arguments.extend(vec![
1037 "-o".to_string(),
1038 "ControlMaster=no".to_string(),
1039 "-o".to_string(),
1040 format!("ControlPath={}", self.socket_path.display()),
1041 self.connection_options.ssh_url(),
1042 ]);
1043 arguments
1044 }
1045
1046 #[cfg(target_os = "windows")]
1047 fn ssh_args(&self) -> Vec<String> {
1048 let mut arguments = self.connection_options.additional_args();
1049 arguments.push(self.connection_options.ssh_url());
1050 arguments
1051 }
1052
1053 async fn platform(&self, shell: ShellKind) -> Result<RemotePlatform> {
1054 let uname = self.run_command(shell, "uname", &["-sm"], false).await?;
1055 let Some((os, arch)) = uname.split_once(" ") else {
1056 anyhow::bail!("unknown uname: {uname:?}")
1057 };
1058
1059 let os = match os.trim() {
1060 "Darwin" => "macos",
1061 "Linux" => "linux",
1062 _ => anyhow::bail!(
1063 "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
1064 ),
1065 };
1066 // exclude armv5,6,7 as they are 32-bit.
1067 let arch = if arch.starts_with("armv8")
1068 || arch.starts_with("armv9")
1069 || arch.starts_with("arm64")
1070 || arch.starts_with("aarch64")
1071 {
1072 "aarch64"
1073 } else if arch.starts_with("x86") {
1074 "x86_64"
1075 } else {
1076 anyhow::bail!(
1077 "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
1078 )
1079 };
1080
1081 Ok(RemotePlatform { os, arch })
1082 }
1083
1084 async fn shell(&self) -> String {
1085 let default_shell = "sh";
1086 match self
1087 .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
1088 .await
1089 {
1090 Ok(shell) => match shell.trim() {
1091 "" => {
1092 log::error!("$SHELL is not set, falling back to {default_shell}");
1093 default_shell.to_owned()
1094 }
1095 shell => shell.to_owned(),
1096 },
1097 Err(e) => {
1098 log::error!("Failed to get shell: {e}");
1099 default_shell.to_owned()
1100 }
1101 }
1102 }
1103}
1104
1105fn parse_port_number(port_str: &str) -> Result<u16> {
1106 port_str
1107 .parse()
1108 .with_context(|| format!("parsing port number: {port_str}"))
1109}
1110
1111fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
1112 let parts: Vec<&str> = spec.split(':').collect();
1113
1114 match parts.len() {
1115 4 => {
1116 let local_port = parse_port_number(parts[1])?;
1117 let remote_port = parse_port_number(parts[3])?;
1118
1119 Ok(SshPortForwardOption {
1120 local_host: Some(parts[0].to_string()),
1121 local_port,
1122 remote_host: Some(parts[2].to_string()),
1123 remote_port,
1124 })
1125 }
1126 3 => {
1127 let local_port = parse_port_number(parts[0])?;
1128 let remote_port = parse_port_number(parts[2])?;
1129
1130 Ok(SshPortForwardOption {
1131 local_host: None,
1132 local_port,
1133 remote_host: Some(parts[1].to_string()),
1134 remote_port,
1135 })
1136 }
1137 _ => anyhow::bail!("Invalid port forward format"),
1138 }
1139}
1140
1141impl SshConnectionOptions {
1142 pub fn parse_command_line(input: &str) -> Result<Self> {
1143 let input = input.trim_start_matches("ssh ");
1144 let mut hostname: Option<String> = None;
1145 let mut username: Option<String> = None;
1146 let mut port: Option<u16> = None;
1147 let mut args = Vec::new();
1148 let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
1149
1150 // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
1151 const ALLOWED_OPTS: &[&str] = &[
1152 "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
1153 ];
1154 const ALLOWED_ARGS: &[&str] = &[
1155 "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
1156 "-w",
1157 ];
1158
1159 let mut tokens = ShellKind::Posix
1160 .split(input)
1161 .context("invalid input")?
1162 .into_iter();
1163
1164 'outer: while let Some(arg) = tokens.next() {
1165 if ALLOWED_OPTS.contains(&(&arg as &str)) {
1166 args.push(arg.to_string());
1167 continue;
1168 }
1169 if arg == "-p" {
1170 port = tokens.next().and_then(|arg| arg.parse().ok());
1171 continue;
1172 } else if let Some(p) = arg.strip_prefix("-p") {
1173 port = p.parse().ok();
1174 continue;
1175 }
1176 if arg == "-l" {
1177 username = tokens.next();
1178 continue;
1179 } else if let Some(l) = arg.strip_prefix("-l") {
1180 username = Some(l.to_string());
1181 continue;
1182 }
1183 if arg == "-L" || arg.starts_with("-L") {
1184 let forward_spec = if arg == "-L" {
1185 tokens.next()
1186 } else {
1187 Some(arg.strip_prefix("-L").unwrap().to_string())
1188 };
1189
1190 if let Some(spec) = forward_spec {
1191 port_forwards.push(parse_port_forward_spec(&spec)?);
1192 } else {
1193 anyhow::bail!("Missing port forward format");
1194 }
1195 }
1196
1197 for a in ALLOWED_ARGS {
1198 if arg == *a {
1199 args.push(arg);
1200 if let Some(next) = tokens.next() {
1201 args.push(next);
1202 }
1203 continue 'outer;
1204 } else if arg.starts_with(a) {
1205 args.push(arg);
1206 continue 'outer;
1207 }
1208 }
1209 if arg.starts_with("-") || hostname.is_some() {
1210 anyhow::bail!("unsupported argument: {:?}", arg);
1211 }
1212 let mut input = &arg as &str;
1213 // Destination might be: username1@username2@ip2@ip1
1214 if let Some((u, rest)) = input.rsplit_once('@') {
1215 input = rest;
1216 username = Some(u.to_string());
1217 }
1218 if let Some((rest, p)) = input.split_once(':') {
1219 input = rest;
1220 port = p.parse().ok()
1221 }
1222 hostname = Some(input.to_string())
1223 }
1224
1225 let Some(hostname) = hostname else {
1226 anyhow::bail!("missing hostname");
1227 };
1228
1229 let port_forwards = match port_forwards.len() {
1230 0 => None,
1231 _ => Some(port_forwards),
1232 };
1233
1234 Ok(Self {
1235 host: hostname,
1236 username,
1237 port,
1238 port_forwards,
1239 args: Some(args),
1240 password: None,
1241 nickname: None,
1242 upload_binary_over_ssh: false,
1243 })
1244 }
1245
1246 pub fn ssh_url(&self) -> String {
1247 let mut result = String::from("ssh://");
1248 if let Some(username) = &self.username {
1249 // Username might be: username1@username2@ip2
1250 let username = urlencoding::encode(username);
1251 result.push_str(&username);
1252 result.push('@');
1253 }
1254 result.push_str(&self.host);
1255 if let Some(port) = self.port {
1256 result.push(':');
1257 result.push_str(&port.to_string());
1258 }
1259 result
1260 }
1261
1262 pub fn additional_args_for_scp(&self) -> Vec<String> {
1263 self.args.iter().flatten().cloned().collect::<Vec<String>>()
1264 }
1265
1266 pub fn additional_args(&self) -> Vec<String> {
1267 let mut args = self.additional_args_for_scp();
1268
1269 if let Some(forwards) = &self.port_forwards {
1270 args.extend(forwards.iter().map(|pf| {
1271 let local_host = match &pf.local_host {
1272 Some(host) => host,
1273 None => "localhost",
1274 };
1275 let remote_host = match &pf.remote_host {
1276 Some(host) => host,
1277 None => "localhost",
1278 };
1279
1280 format!(
1281 "-L{}:{}:{}:{}",
1282 local_host, pf.local_port, remote_host, pf.remote_port
1283 )
1284 }));
1285 }
1286
1287 args
1288 }
1289
1290 fn scp_url(&self) -> String {
1291 if let Some(username) = &self.username {
1292 format!("{}@{}", username, self.host)
1293 } else {
1294 self.host.clone()
1295 }
1296 }
1297
1298 pub fn connection_string(&self) -> String {
1299 let host = if let Some(username) = &self.username {
1300 format!("{}@{}", username, self.host)
1301 } else {
1302 self.host.clone()
1303 };
1304 if let Some(port) = &self.port {
1305 format!("{}:{}", host, port)
1306 } else {
1307 host
1308 }
1309 }
1310}
1311
1312fn build_command(
1313 input_program: Option<String>,
1314 input_args: &[String],
1315 input_env: &HashMap<String, String>,
1316 working_dir: Option<String>,
1317 port_forward: Option<(u16, String, u16)>,
1318 ssh_env: HashMap<String, String>,
1319 ssh_path_style: PathStyle,
1320 ssh_shell: &str,
1321 ssh_shell_kind: ShellKind,
1322 ssh_args: Vec<String>,
1323) -> Result<CommandTemplate> {
1324 use std::fmt::Write as _;
1325
1326 let mut exec = String::new();
1327 if let Some(working_dir) = working_dir {
1328 let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1329
1330 // shlex will wrap the command in single quotes (''), disabling ~ expansion,
1331 // replace with something that works
1332 const TILDE_PREFIX: &'static str = "~/";
1333 if working_dir.starts_with(TILDE_PREFIX) {
1334 let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
1335 write!(
1336 exec,
1337 "cd \"$HOME/{working_dir}\" {} ",
1338 ssh_shell_kind.sequential_and_commands_separator()
1339 )?;
1340 } else {
1341 write!(
1342 exec,
1343 "cd \"{working_dir}\" {} ",
1344 ssh_shell_kind.sequential_and_commands_separator()
1345 )?;
1346 }
1347 } else {
1348 write!(
1349 exec,
1350 "cd {} ",
1351 ssh_shell_kind.sequential_and_commands_separator()
1352 )?;
1353 };
1354 write!(exec, "exec env ")?;
1355
1356 for (k, v) in input_env.iter() {
1357 write!(
1358 exec,
1359 "{}={} ",
1360 k,
1361 ssh_shell_kind.try_quote(v).context("shell quoting")?
1362 )?;
1363 }
1364
1365 if let Some(input_program) = input_program {
1366 write!(
1367 exec,
1368 "{}",
1369 ssh_shell_kind
1370 .try_quote_prefix_aware(&input_program)
1371 .context("shell quoting")?
1372 )?;
1373 for arg in input_args {
1374 let arg = ssh_shell_kind.try_quote(&arg).context("shell quoting")?;
1375 write!(exec, " {}", &arg)?;
1376 }
1377 } else {
1378 write!(exec, "{ssh_shell} -l")?;
1379 };
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(exec);
1391 Ok(CommandTemplate {
1392 program: "ssh".into(),
1393 args,
1394 env: ssh_env,
1395 })
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400 use super::*;
1401
1402 #[test]
1403 fn test_build_command() -> Result<()> {
1404 let mut input_env = HashMap::default();
1405 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1406 let mut env = HashMap::default();
1407 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1408
1409 let command = build_command(
1410 Some("remote_program".to_string()),
1411 &["arg1".to_string(), "arg2".to_string()],
1412 &input_env,
1413 Some("~/work".to_string()),
1414 None,
1415 env.clone(),
1416 PathStyle::Posix,
1417 "/bin/fish",
1418 ShellKind::Fish,
1419 vec!["-p".to_string(), "2222".to_string()],
1420 )?;
1421
1422 assert_eq!(command.program, "ssh");
1423 assert_eq!(
1424 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1425 [
1426 "-p",
1427 "2222",
1428 "-t",
1429 "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
1430 ]
1431 );
1432 assert_eq!(command.env, env);
1433
1434 let mut input_env = HashMap::default();
1435 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1436 let mut env = HashMap::default();
1437 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1438
1439 let command = build_command(
1440 None,
1441 &["arg1".to_string(), "arg2".to_string()],
1442 &input_env,
1443 None,
1444 Some((1, "foo".to_owned(), 2)),
1445 env.clone(),
1446 PathStyle::Posix,
1447 "/bin/fish",
1448 ShellKind::Fish,
1449 vec!["-p".to_string(), "2222".to_string()],
1450 )?;
1451
1452 assert_eq!(command.program, "ssh");
1453 assert_eq!(
1454 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1455 [
1456 "-p",
1457 "2222",
1458 "-L",
1459 "1:foo:2",
1460 "-t",
1461 "cd && exec env INPUT_VA=val /bin/fish -l"
1462 ]
1463 );
1464 assert_eq!(command.env, env);
1465
1466 Ok(())
1467 }
1468
1469 #[test]
1470 fn scp_args_exclude_port_forward_flags() {
1471 let options = SshConnectionOptions {
1472 host: "example.com".into(),
1473 args: Some(vec![
1474 "-p".to_string(),
1475 "2222".to_string(),
1476 "-o".to_string(),
1477 "StrictHostKeyChecking=no".to_string(),
1478 ]),
1479 port_forwards: Some(vec![SshPortForwardOption {
1480 local_host: Some("127.0.0.1".to_string()),
1481 local_port: 8080,
1482 remote_host: Some("127.0.0.1".to_string()),
1483 remote_port: 80,
1484 }]),
1485 ..Default::default()
1486 };
1487
1488 let ssh_args = options.additional_args();
1489 assert!(
1490 ssh_args.iter().any(|arg| arg.starts_with("-L")),
1491 "expected ssh args to include port-forward: {ssh_args:?}"
1492 );
1493
1494 let scp_args = options.additional_args_for_scp();
1495 assert_eq!(
1496 scp_args,
1497 vec![
1498 "-p".to_string(),
1499 "2222".to_string(),
1500 "-o".to_string(),
1501 "StrictHostKeyChecking=no".to_string()
1502 ]
1503 );
1504 assert!(
1505 scp_args.iter().all(|arg| !arg.starts_with("-L")),
1506 "scp args should not contain port forward flags: {scp_args:?}"
1507 );
1508 }
1509}