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