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