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