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