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