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