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