1use crate::{
2 RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform,
3 remote_client::{CommandTemplate, Interactive, RemoteConnection, RemoteConnectionOptions},
4 transport::{parse_platform, parse_shell},
5};
6use anyhow::{Context as _, Result, anyhow};
7use async_trait::async_trait;
8use collections::HashMap;
9use futures::{
10 AsyncReadExt as _, FutureExt as _,
11 channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
12 select_biased,
13};
14use gpui::{App, AppContext as _, AsyncApp, Task};
15use parking_lot::Mutex;
16use paths::remote_server_dir_relative;
17use release_channel::{AppVersion, ReleaseChannel};
18use rpc::proto::Envelope;
19use semver::Version;
20pub use settings::SshPortForwardOption;
21use smol::fs;
22use std::{
23 net::IpAddr,
24 path::{Path, PathBuf},
25 sync::Arc,
26 time::Instant,
27};
28use tempfile::TempDir;
29use util::command::{Child, Stdio};
30use util::{
31 paths::{PathStyle, RemotePathBuf},
32 rel_path::RelPath,
33 shell::ShellKind,
34};
35
36pub(crate) struct SshRemoteConnection {
37 socket: SshSocket,
38 master_process: Mutex<Option<MasterProcess>>,
39 remote_binary_path: Option<Arc<RelPath>>,
40 ssh_platform: RemotePlatform,
41 ssh_path_style: PathStyle,
42 ssh_shell: String,
43 ssh_shell_kind: ShellKind,
44 ssh_default_system_shell: String,
45 _temp_dir: TempDir,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub enum SshConnectionHost {
50 IpAddr(IpAddr),
51 Hostname(String),
52}
53
54impl SshConnectionHost {
55 pub fn to_bracketed_string(&self) -> String {
56 match self {
57 Self::IpAddr(IpAddr::V4(ip)) => ip.to_string(),
58 Self::IpAddr(IpAddr::V6(ip)) => format!("[{}]", ip),
59 Self::Hostname(hostname) => hostname.clone(),
60 }
61 }
62
63 pub fn to_string(&self) -> String {
64 match self {
65 Self::IpAddr(ip) => ip.to_string(),
66 Self::Hostname(hostname) => hostname.clone(),
67 }
68 }
69}
70
71impl From<&str> for SshConnectionHost {
72 fn from(value: &str) -> Self {
73 if let Ok(address) = value.parse() {
74 Self::IpAddr(address)
75 } else {
76 Self::Hostname(value.to_string())
77 }
78 }
79}
80
81impl From<String> for SshConnectionHost {
82 fn from(value: String) -> Self {
83 if let Ok(address) = value.parse() {
84 Self::IpAddr(address)
85 } else {
86 Self::Hostname(value)
87 }
88 }
89}
90
91impl Default for SshConnectionHost {
92 fn default() -> Self {
93 Self::Hostname(Default::default())
94 }
95}
96
97#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
98pub struct SshConnectionOptions {
99 pub host: SshConnectionHost,
100 pub username: Option<String>,
101 pub port: Option<u16>,
102 pub password: Option<String>,
103 pub args: Option<Vec<String>>,
104 pub port_forwards: Option<Vec<SshPortForwardOption>>,
105 pub connection_timeout: Option<u16>,
106
107 pub nickname: Option<String>,
108 pub upload_binary_over_ssh: bool,
109}
110
111impl From<settings::SshConnection> for SshConnectionOptions {
112 fn from(val: settings::SshConnection) -> Self {
113 SshConnectionOptions {
114 host: val.host.to_string().into(),
115 username: val.username,
116 port: val.port,
117 password: None,
118 args: Some(val.args),
119 nickname: val.nickname,
120 upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
121 port_forwards: val.port_forwards,
122 connection_timeout: val.connection_timeout,
123 }
124 }
125}
126
127struct SshSocket {
128 connection_options: SshConnectionOptions,
129 #[cfg(not(windows))]
130 socket_path: std::path::PathBuf,
131 /// Extra environment variables needed for the ssh process
132 envs: HashMap<String, String>,
133 #[cfg(windows)]
134 _proxy: askpass::PasswordProxy,
135}
136
137struct MasterProcess {
138 process: Child,
139}
140
141#[cfg(not(windows))]
142impl MasterProcess {
143 pub fn new(
144 askpass_script_path: &std::ffi::OsStr,
145 additional_args: Vec<String>,
146 socket_path: &std::path::Path,
147 destination: &str,
148 ) -> Result<Self> {
149 let args = [
150 "-N",
151 "-o",
152 "ControlPersist=no",
153 "-o",
154 "ControlMaster=yes",
155 "-o",
156 ];
157
158 let mut master_process = util::command::new_command("ssh");
159 master_process
160 .kill_on_drop(true)
161 .stdin(Stdio::null())
162 .stdout(Stdio::piped())
163 .stderr(Stdio::piped())
164 .env("SSH_ASKPASS_REQUIRE", "force")
165 .env("SSH_ASKPASS", askpass_script_path)
166 .args(additional_args)
167 .args(args);
168
169 master_process.arg(format!("ControlPath={}", socket_path.display()));
170
171 let process = master_process.arg(&destination).spawn()?;
172
173 Ok(MasterProcess { process })
174 }
175
176 pub async fn wait_connected(&mut self) -> Result<()> {
177 let Some(mut stdout) = self.process.stdout.take() else {
178 anyhow::bail!("ssh process stdout capture failed");
179 };
180
181 let mut output = Vec::new();
182 stdout.read_to_end(&mut output).await?;
183 Ok(())
184 }
185}
186
187#[cfg(windows)]
188impl MasterProcess {
189 const CONNECTION_ESTABLISHED_MAGIC: &str = "ZED_SSH_CONNECTION_ESTABLISHED";
190
191 pub fn new(
192 askpass_script_path: &std::ffi::OsStr,
193 additional_args: Vec<String>,
194 destination: &str,
195 ) -> Result<Self> {
196 // On Windows, `ControlMaster` and `ControlPath` are not supported:
197 // https://github.com/PowerShell/Win32-OpenSSH/issues/405
198 // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
199 //
200 // Using an ugly workaround to detect connection establishment
201 // -N doesn't work with JumpHosts as windows openssh never closes stdin in that case
202 let args = [
203 "-t",
204 &format!("echo '{}'; exec $0", Self::CONNECTION_ESTABLISHED_MAGIC),
205 ];
206
207 let mut master_process = util::command::new_command("ssh");
208 master_process
209 .kill_on_drop(true)
210 .stdin(Stdio::null())
211 .stdout(Stdio::piped())
212 .stderr(Stdio::piped())
213 .env("SSH_ASKPASS_REQUIRE", "force")
214 .env("SSH_ASKPASS", askpass_script_path)
215 .args(additional_args)
216 .arg(destination)
217 .args(args);
218
219 let process = master_process.spawn()?;
220
221 Ok(MasterProcess { process })
222 }
223
224 pub async fn wait_connected(&mut self) -> Result<()> {
225 use smol::io::AsyncBufReadExt;
226
227 let Some(stdout) = self.process.stdout.take() else {
228 anyhow::bail!("ssh process stdout capture failed");
229 };
230
231 let mut reader = smol::io::BufReader::new(stdout);
232
233 let mut line = String::new();
234
235 loop {
236 let n = reader.read_line(&mut line).await?;
237 if n == 0 {
238 anyhow::bail!("ssh process exited before connection established");
239 }
240
241 if line.contains(Self::CONNECTION_ESTABLISHED_MAGIC) {
242 return Ok(());
243 }
244 }
245 }
246}
247
248impl AsRef<Child> for MasterProcess {
249 fn as_ref(&self) -> &Child {
250 &self.process
251 }
252}
253
254impl AsMut<Child> for MasterProcess {
255 fn as_mut(&mut self) -> &mut Child {
256 &mut self.process
257 }
258}
259
260#[async_trait(?Send)]
261impl RemoteConnection for SshRemoteConnection {
262 async fn kill(&self) -> Result<()> {
263 let Some(mut process) = self.master_process.lock().take() else {
264 return Ok(());
265 };
266 process.as_mut().kill().ok();
267 process.as_mut().status().await?;
268 Ok(())
269 }
270
271 fn has_been_killed(&self) -> bool {
272 self.master_process.lock().is_none()
273 }
274
275 fn connection_options(&self) -> RemoteConnectionOptions {
276 RemoteConnectionOptions::Ssh(self.socket.connection_options.clone())
277 }
278
279 fn shell(&self) -> String {
280 self.ssh_shell.clone()
281 }
282
283 fn default_system_shell(&self) -> String {
284 self.ssh_default_system_shell.clone()
285 }
286
287 fn build_command(
288 &self,
289 input_program: Option<String>,
290 input_args: &[String],
291 input_env: &HashMap<String, String>,
292 working_dir: Option<String>,
293 port_forward: Option<(u16, String, u16)>,
294 interactive: Interactive,
295 ) -> Result<CommandTemplate> {
296 let Self {
297 ssh_path_style,
298 socket,
299 ssh_shell_kind,
300 ssh_shell,
301 ..
302 } = self;
303 let env = socket.envs.clone();
304
305 if self.ssh_platform.os.is_windows() {
306 build_command_windows(
307 input_program,
308 input_args,
309 input_env,
310 working_dir,
311 port_forward,
312 env,
313 *ssh_path_style,
314 ssh_shell,
315 *ssh_shell_kind,
316 socket.ssh_command_options(),
317 &socket.connection_options.ssh_destination(),
318 interactive,
319 )
320 } else {
321 build_command_posix(
322 input_program,
323 input_args,
324 input_env,
325 working_dir,
326 port_forward,
327 env,
328 *ssh_path_style,
329 ssh_shell,
330 *ssh_shell_kind,
331 socket.ssh_command_options(),
332 &socket.connection_options.ssh_destination(),
333 interactive,
334 )
335 }
336 }
337
338 fn build_forward_ports_command(
339 &self,
340 forwards: Vec<(u16, String, u16)>,
341 ) -> Result<CommandTemplate> {
342 let Self { socket, .. } = self;
343 let mut args = socket.ssh_command_options();
344 args.push("-N".into());
345 for (local_port, host, remote_port) in forwards {
346 args.push("-L".into());
347 args.push(format!("{local_port}:{host}:{remote_port}"));
348 }
349 args.push(socket.connection_options.ssh_destination());
350 Ok(CommandTemplate {
351 program: "ssh".into(),
352 args,
353 env: Default::default(),
354 })
355 }
356
357 fn upload_directory(
358 &self,
359 src_path: PathBuf,
360 dest_path: RemotePathBuf,
361 cx: &App,
362 ) -> Task<Result<()>> {
363 let dest_path_str = dest_path.to_string();
364 let src_path_display = src_path.display().to_string();
365
366 let mut sftp_command = self.build_sftp_command();
367 let mut scp_command =
368 self.build_scp_command(&src_path, &dest_path_str, Some(&["-C", "-r"]));
369
370 cx.background_spawn(async move {
371 // We will try SFTP first, and if that fails, we will fall back to SCP.
372 // If SCP fails also, we give up and return an error.
373 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
374 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
375 // This is for example the case on Windows as evidenced by this implementation snippet:
376 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
377 if Self::is_sftp_available().await {
378 log::debug!("using SFTP for directory upload");
379 let mut child = sftp_command.spawn()?;
380 if let Some(mut stdin) = child.stdin.take() {
381 use futures::AsyncWriteExt;
382 let sftp_batch = format!("put -r \"{src_path_display}\" \"{dest_path_str}\"\n");
383 stdin.write_all(sftp_batch.as_bytes()).await?;
384 stdin.flush().await?;
385 }
386
387 let output = child.output().await?;
388 if output.status.success() {
389 return Ok(());
390 }
391
392 let stderr = String::from_utf8_lossy(&output.stderr);
393 log::debug!("failed to upload directory via SFTP {src_path_display} -> {dest_path_str}: {stderr}");
394 }
395
396 log::debug!("using SCP for directory upload");
397 let output = scp_command.output().await?;
398
399 if output.status.success() {
400 return Ok(());
401 }
402
403 let stderr = String::from_utf8_lossy(&output.stderr);
404 log::debug!("failed to upload directory via SCP {src_path_display} -> {dest_path_str}: {stderr}");
405
406 anyhow::bail!(
407 "failed to upload directory via SFTP/SCP {} -> {}: {}",
408 src_path_display,
409 dest_path_str,
410 stderr,
411 );
412 })
413 }
414
415 fn start_proxy(
416 &self,
417 unique_identifier: String,
418 reconnect: bool,
419 incoming_tx: UnboundedSender<Envelope>,
420 outgoing_rx: UnboundedReceiver<Envelope>,
421 connection_activity_tx: Sender<()>,
422 delegate: Arc<dyn RemoteClientDelegate>,
423 cx: &mut AsyncApp,
424 ) -> Task<Result<i32>> {
425 const VARS: [&str; 3] = ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"];
426 delegate.set_status(Some("Starting proxy"), cx);
427
428 let Some(remote_binary_path) = self.remote_binary_path.clone() else {
429 return Task::ready(Err(anyhow!("Remote binary path not set")));
430 };
431
432 let mut ssh_command = if self.ssh_platform.os.is_windows() {
433 // TODO: Set the `VARS` environment variables, we do not have `env` on windows
434 // so this needs a different approach
435 let mut proxy_args = vec![];
436 proxy_args.push("proxy".to_owned());
437 proxy_args.push("--identifier".to_owned());
438 proxy_args.push(unique_identifier);
439
440 if reconnect {
441 proxy_args.push("--reconnect".to_owned());
442 }
443 self.socket.ssh_command(
444 self.ssh_shell_kind,
445 &remote_binary_path.display(self.path_style()),
446 &proxy_args,
447 false,
448 )
449 } else {
450 let mut proxy_args = vec![];
451 for env_var in VARS {
452 if let Some(value) = std::env::var(env_var).ok() {
453 proxy_args.push(format!("{}='{}'", env_var, value));
454 }
455 }
456 proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
457 proxy_args.push("proxy".to_owned());
458 proxy_args.push("--identifier".to_owned());
459 proxy_args.push(unique_identifier);
460
461 if reconnect {
462 proxy_args.push("--reconnect".to_owned());
463 }
464 self.socket
465 .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
466 };
467
468 let ssh_proxy_process = match ssh_command
469 // IMPORTANT: we kill this process when we drop the task that uses it.
470 .kill_on_drop(true)
471 .spawn()
472 {
473 Ok(process) => process,
474 Err(error) => {
475 return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
476 }
477 };
478
479 super::handle_rpc_messages_over_child_process_stdio(
480 ssh_proxy_process,
481 incoming_tx,
482 outgoing_rx,
483 connection_activity_tx,
484 cx,
485 )
486 }
487
488 fn path_style(&self) -> PathStyle {
489 self.ssh_path_style
490 }
491
492 fn has_wsl_interop(&self) -> bool {
493 false
494 }
495}
496
497impl SshRemoteConnection {
498 pub(crate) async fn new(
499 connection_options: SshConnectionOptions,
500 delegate: Arc<dyn RemoteClientDelegate>,
501 cx: &mut AsyncApp,
502 ) -> Result<Self> {
503 use askpass::AskPassResult;
504
505 let destination = connection_options.ssh_destination();
506
507 let temp_dir = tempfile::Builder::new()
508 .prefix("zed-ssh-session")
509 .tempdir()?;
510 let askpass_delegate = askpass::AskPassDelegate::new(cx, {
511 let delegate = delegate.clone();
512 move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
513 });
514
515 let mut askpass =
516 askpass::AskPassSession::new(cx.background_executor().clone(), askpass_delegate)
517 .await?;
518
519 delegate.set_status(Some("Connecting"), cx);
520
521 // Start the master SSH process, which does not do anything except for establish
522 // the connection and keep it open, allowing other ssh commands to reuse it
523 // via a control socket.
524 #[cfg(not(windows))]
525 let socket_path = temp_dir.path().join("ssh.sock");
526
527 #[cfg(windows)]
528 let mut master_process = MasterProcess::new(
529 askpass.script_path().as_ref(),
530 connection_options.additional_args(),
531 &destination,
532 )?;
533 #[cfg(not(windows))]
534 let mut master_process = MasterProcess::new(
535 askpass.script_path().as_ref(),
536 connection_options.additional_args(),
537 &socket_path,
538 &destination,
539 )?;
540
541 let result = select_biased! {
542 result = askpass.run().fuse() => {
543 match result {
544 AskPassResult::CancelledByUser => {
545 master_process.as_mut().kill().ok();
546 anyhow::bail!("SSH connection canceled")
547 }
548 AskPassResult::Timedout => {
549 anyhow::bail!("connecting to host timed out")
550 }
551 }
552 }
553 _ = master_process.wait_connected().fuse() => {
554 anyhow::Ok(())
555 }
556 };
557
558 if let Err(e) = result {
559 return Err(e.context("Failed to connect to host"));
560 }
561
562 if master_process.as_mut().try_status()?.is_some() {
563 let mut output = Vec::new();
564 output.clear();
565 let mut stderr = master_process.as_mut().stderr.take().unwrap();
566 stderr.read_to_end(&mut output).await?;
567
568 let error_message = format!(
569 "failed to connect: {}",
570 String::from_utf8_lossy(&output).trim()
571 );
572 anyhow::bail!(error_message);
573 }
574
575 #[cfg(not(windows))]
576 let socket = SshSocket::new(connection_options, socket_path).await?;
577 #[cfg(windows)]
578 let socket = SshSocket::new(
579 connection_options,
580 askpass
581 .get_password()
582 .or_else(|| askpass::EncryptedPassword::try_from("").ok())
583 .context("Failed to fetch askpass password")?,
584 cx.background_executor().clone(),
585 )
586 .await?;
587 drop(askpass);
588
589 let is_windows = socket.probe_is_windows().await;
590 log::info!("Remote is windows: {}", is_windows);
591
592 let ssh_shell = socket.shell(is_windows).await;
593 log::info!("Remote shell discovered: {}", ssh_shell);
594
595 let ssh_shell_kind = ShellKind::new(&ssh_shell, is_windows);
596 let ssh_platform = socket.platform(ssh_shell_kind, is_windows).await?;
597 log::info!("Remote platform discovered: {:?}", ssh_platform);
598
599 let (ssh_path_style, ssh_default_system_shell) = match ssh_platform.os {
600 RemoteOs::Windows => (PathStyle::Windows, ssh_shell.clone()),
601 _ => (PathStyle::Posix, String::from("/bin/sh")),
602 };
603
604 let mut this = Self {
605 socket,
606 master_process: Mutex::new(Some(master_process)),
607 _temp_dir: temp_dir,
608 remote_binary_path: None,
609 ssh_path_style,
610 ssh_platform,
611 ssh_shell,
612 ssh_shell_kind,
613 ssh_default_system_shell,
614 };
615
616 let (release_channel, version) =
617 cx.update(|cx| (ReleaseChannel::global(cx), AppVersion::global(cx)));
618 this.remote_binary_path = Some(
619 this.ensure_server_binary(&delegate, release_channel, version, cx)
620 .await?,
621 );
622
623 Ok(this)
624 }
625
626 async fn ensure_server_binary(
627 &self,
628 delegate: &Arc<dyn RemoteClientDelegate>,
629 release_channel: ReleaseChannel,
630 version: Version,
631 cx: &mut AsyncApp,
632 ) -> Result<Arc<RelPath>> {
633 let version_str = match release_channel {
634 ReleaseChannel::Dev => "build".to_string(),
635 _ => version.to_string(),
636 };
637 let binary_name = format!(
638 "zed-remote-server-{}-{}{}",
639 release_channel.dev_name(),
640 version_str,
641 if self.ssh_platform.os.is_windows() {
642 ".exe"
643 } else {
644 ""
645 }
646 );
647 let dst_path =
648 paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
649
650 let binary_exists_on_server = self
651 .socket
652 .run_command(
653 self.ssh_shell_kind,
654 &dst_path.display(self.path_style()),
655 &["version"],
656 true,
657 )
658 .await
659 .is_ok();
660
661 #[cfg(any(debug_assertions, feature = "build-remote-server-binary"))]
662 if let Some(remote_server_path) = super::build_remote_server_from_source(
663 &self.ssh_platform,
664 delegate.as_ref(),
665 binary_exists_on_server,
666 cx,
667 )
668 .await?
669 {
670 let tmp_path = paths::remote_server_dir_relative().join(
671 RelPath::unix(&format!(
672 "download-{}-{}",
673 std::process::id(),
674 remote_server_path.file_name().unwrap().to_string_lossy()
675 ))
676 .unwrap(),
677 );
678 self.upload_local_server_binary(&remote_server_path, &tmp_path, delegate, cx)
679 .await?;
680 self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
681 .await?;
682 return Ok(dst_path);
683 }
684
685 if binary_exists_on_server {
686 return Ok(dst_path);
687 }
688
689 let wanted_version = cx.update(|cx| match release_channel {
690 ReleaseChannel::Nightly => Ok(None),
691 ReleaseChannel::Dev => {
692 anyhow::bail!(
693 "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
694 dst_path
695 )
696 }
697 _ => Ok(Some(AppVersion::global(cx))),
698 })?;
699
700 let tmp_path_compressed = remote_server_dir_relative().join(
701 RelPath::unix(&format!(
702 "{}-download-{}.{}",
703 binary_name,
704 std::process::id(),
705 if self.ssh_platform.os.is_windows() {
706 "zip"
707 } else {
708 "gz"
709 }
710 ))
711 .unwrap(),
712 );
713 if !self.socket.connection_options.upload_binary_over_ssh
714 && let Some(url) = delegate
715 .get_download_url(
716 self.ssh_platform,
717 release_channel,
718 wanted_version.clone(),
719 cx,
720 )
721 .await?
722 {
723 match self
724 .download_binary_on_server(&url, &tmp_path_compressed, delegate, cx)
725 .await
726 {
727 Ok(_) => {
728 self.extract_server_binary(&dst_path, &tmp_path_compressed, delegate, cx)
729 .await
730 .context("extracting server binary")?;
731 return Ok(dst_path);
732 }
733 Err(e) => {
734 log::error!(
735 "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
736 )
737 }
738 }
739 }
740
741 let src_path = delegate
742 .download_server_binary_locally(
743 self.ssh_platform,
744 release_channel,
745 wanted_version.clone(),
746 cx,
747 )
748 .await
749 .context("downloading server binary locally")?;
750 self.upload_local_server_binary(&src_path, &tmp_path_compressed, delegate, cx)
751 .await
752 .context("uploading server binary")?;
753 self.extract_server_binary(&dst_path, &tmp_path_compressed, delegate, cx)
754 .await
755 .context("extracting server binary")?;
756 Ok(dst_path)
757 }
758
759 async fn download_binary_on_server(
760 &self,
761 url: &str,
762 tmp_path: &RelPath,
763 delegate: &Arc<dyn RemoteClientDelegate>,
764 cx: &mut AsyncApp,
765 ) -> Result<()> {
766 if let Some(parent) = tmp_path.parent() {
767 let res = self
768 .socket
769 .run_command(
770 self.ssh_shell_kind,
771 "mkdir",
772 &["-p", parent.display(self.path_style()).as_ref()],
773 true,
774 )
775 .await;
776 if !self.ssh_platform.os.is_windows() {
777 // mkdir fails on windows if the path already exists ...
778 res?;
779 }
780 }
781
782 delegate.set_status(Some("Downloading remote development server on host"), cx);
783
784 let connection_timeout = self
785 .socket
786 .connection_options
787 .connection_timeout
788 .unwrap_or(10)
789 .to_string();
790
791 match self
792 .socket
793 .run_command(
794 self.ssh_shell_kind,
795 "curl",
796 &[
797 "-f",
798 "-L",
799 "--connect-timeout",
800 &connection_timeout,
801 url,
802 "-o",
803 &tmp_path.display(self.path_style()),
804 ],
805 true,
806 )
807 .await
808 {
809 Ok(_) => {}
810 Err(e) => {
811 if self
812 .socket
813 .run_command(self.ssh_shell_kind, "which", &["curl"], true)
814 .await
815 .is_ok()
816 {
817 return Err(e);
818 }
819
820 log::info!("curl is not available, trying wget");
821 match self
822 .socket
823 .run_command(
824 self.ssh_shell_kind,
825 "wget",
826 &[
827 "--connect-timeout",
828 &connection_timeout,
829 "--tries",
830 "1",
831 url,
832 "-O",
833 &tmp_path.display(self.path_style()),
834 ],
835 true,
836 )
837 .await
838 {
839 Ok(_) => {}
840 Err(e) => {
841 if self
842 .socket
843 .run_command(self.ssh_shell_kind, "which", &["wget"], true)
844 .await
845 .is_ok()
846 {
847 return Err(e);
848 } else {
849 anyhow::bail!("Neither curl nor wget is available");
850 }
851 }
852 }
853 }
854 }
855
856 Ok(())
857 }
858
859 async fn upload_local_server_binary(
860 &self,
861 src_path: &Path,
862 tmp_path: &RelPath,
863 delegate: &Arc<dyn RemoteClientDelegate>,
864 cx: &mut AsyncApp,
865 ) -> Result<()> {
866 if let Some(parent) = tmp_path.parent() {
867 let res = self
868 .socket
869 .run_command(
870 self.ssh_shell_kind,
871 "mkdir",
872 &["-p", parent.display(self.path_style()).as_ref()],
873 true,
874 )
875 .await;
876 if !self.ssh_platform.os.is_windows() {
877 // mkdir fails on windows if the path already exists ...
878 res?;
879 }
880 }
881
882 let src_stat = fs::metadata(&src_path)
883 .await
884 .with_context(|| format!("failed to get metadata for {:?}", src_path))?;
885 let size = src_stat.len();
886
887 let t0 = Instant::now();
888 delegate.set_status(Some("Uploading remote development server"), cx);
889 log::info!(
890 "uploading remote development server to {:?} ({}kb)",
891 tmp_path,
892 size / 1024
893 );
894 self.upload_file(src_path, tmp_path)
895 .await
896 .context("failed to upload server binary")?;
897 log::info!("uploaded remote development server in {:?}", t0.elapsed());
898 Ok(())
899 }
900
901 async fn extract_server_binary(
902 &self,
903 dst_path: &RelPath,
904 tmp_path: &RelPath,
905 delegate: &Arc<dyn RemoteClientDelegate>,
906 cx: &mut AsyncApp,
907 ) -> Result<()> {
908 delegate.set_status(Some("Extracting remote development server"), cx);
909
910 if self.ssh_platform.os.is_windows() {
911 self.extract_server_binary_windows(dst_path, tmp_path).await
912 } else {
913 self.extract_server_binary_posix(dst_path, tmp_path).await
914 }
915 }
916
917 async fn extract_server_binary_posix(
918 &self,
919 dst_path: &RelPath,
920 tmp_path: &RelPath,
921 ) -> Result<()> {
922 let shell_kind = ShellKind::Posix;
923 let server_mode = 0o755;
924 let orig_tmp_path = tmp_path.display(self.path_style());
925 let server_mode = format!("{:o}", server_mode);
926 let server_mode = shell_kind
927 .try_quote(&server_mode)
928 .context("shell quoting")?;
929 let dst_path = dst_path.display(self.path_style());
930 let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
931 let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
932 let orig_tmp_path = shell_kind
933 .try_quote(&orig_tmp_path)
934 .context("shell quoting")?;
935 let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
936 format!(
937 "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
938 )
939 } else {
940 let orig_tmp_path = shell_kind
941 .try_quote(&orig_tmp_path)
942 .context("shell quoting")?;
943 format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
944 };
945 let args = shell_kind.args_for_shell(false, script.to_string());
946 self.socket
947 .run_command(self.ssh_shell_kind, "sh", &args, true)
948 .await?;
949 Ok(())
950 }
951
952 async fn extract_server_binary_windows(
953 &self,
954 dst_path: &RelPath,
955 tmp_path: &RelPath,
956 ) -> Result<()> {
957 let shell_kind = ShellKind::Pwsh;
958 let orig_tmp_path = tmp_path.display(self.path_style());
959 let dst_path = dst_path.display(self.path_style());
960 let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
961
962 let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".zip") {
963 let orig_tmp_path = shell_kind
964 .try_quote(&orig_tmp_path)
965 .context("shell quoting")?;
966 let tmp_path = shell_kind.try_quote(tmp_path).context("shell quoting")?;
967 let tmp_exe_path = format!("{tmp_path}\\remote_server.exe");
968 let tmp_exe_path = shell_kind
969 .try_quote(&tmp_exe_path)
970 .context("shell quoting")?;
971 format!(
972 "Expand-Archive -Force -Path {orig_tmp_path} -DestinationPath {tmp_path} -ErrorAction Stop; Move-Item -Force {tmp_exe_path} {dst_path}; Remove-Item -Force {tmp_path} -Recurse; Remove-Item -Force {orig_tmp_path}",
973 )
974 } else {
975 let orig_tmp_path = shell_kind
976 .try_quote(&orig_tmp_path)
977 .context("shell quoting")?;
978 format!("Move-Item -Force {orig_tmp_path} {dst_path}")
979 };
980
981 let args = shell_kind.args_for_shell(false, script);
982 self.socket
983 .run_command(self.ssh_shell_kind, "powershell", &args, true)
984 .await?;
985 Ok(())
986 }
987
988 fn build_scp_command(
989 &self,
990 src_path: &Path,
991 dest_path_str: &str,
992 args: Option<&[&str]>,
993 ) -> util::command::Command {
994 let mut command = util::command::new_command("scp");
995 self.socket.ssh_options(&mut command, false).args(
996 self.socket
997 .connection_options
998 .port
999 .map(|port| vec!["-P".to_string(), port.to_string()])
1000 .unwrap_or_default(),
1001 );
1002 if let Some(args) = args {
1003 command.args(args);
1004 }
1005 command.arg(src_path).arg(format!(
1006 "{}:{}",
1007 self.socket.connection_options.scp_destination(),
1008 dest_path_str
1009 ));
1010 command
1011 }
1012
1013 fn build_sftp_command(&self) -> util::command::Command {
1014 let mut command = util::command::new_command("sftp");
1015 self.socket.ssh_options(&mut command, false).args(
1016 self.socket
1017 .connection_options
1018 .port
1019 .map(|port| vec!["-P".to_string(), port.to_string()])
1020 .unwrap_or_default(),
1021 );
1022 command.arg("-b").arg("-");
1023 command.arg(self.socket.connection_options.scp_destination());
1024 command.stdin(Stdio::piped());
1025 command
1026 }
1027
1028 async fn upload_file(&self, src_path: &Path, dest_path: &RelPath) -> Result<()> {
1029 log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
1030
1031 let src_path_display = src_path.display().to_string();
1032 let dest_path_str = dest_path.display(self.path_style());
1033
1034 // We will try SFTP first, and if that fails, we will fall back to SCP.
1035 // If SCP fails also, we give up and return an error.
1036 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
1037 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
1038 // This is for example the case on Windows as evidenced by this implementation snippet:
1039 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
1040 if Self::is_sftp_available().await {
1041 log::debug!("using SFTP for file upload");
1042 let mut command = self.build_sftp_command();
1043 let sftp_batch = format!("put {src_path_display} {dest_path_str}\n");
1044
1045 let mut child = command.spawn()?;
1046 if let Some(mut stdin) = child.stdin.take() {
1047 use futures::AsyncWriteExt;
1048 stdin.write_all(sftp_batch.as_bytes()).await?;
1049 stdin.flush().await?;
1050 }
1051
1052 let output = child.output().await?;
1053 if output.status.success() {
1054 return Ok(());
1055 }
1056
1057 let stderr = String::from_utf8_lossy(&output.stderr);
1058 log::debug!(
1059 "failed to upload file via SFTP {src_path_display} -> {dest_path_str}: {stderr}"
1060 );
1061 }
1062
1063 log::debug!("using SCP for file upload");
1064 let mut command = self.build_scp_command(src_path, &dest_path_str, None);
1065 let output = command.output().await?;
1066
1067 if output.status.success() {
1068 return Ok(());
1069 }
1070
1071 let stderr = String::from_utf8_lossy(&output.stderr);
1072 log::debug!(
1073 "failed to upload file via SCP {src_path_display} -> {dest_path_str}: {stderr}",
1074 );
1075 anyhow::bail!(
1076 "failed to upload file via STFP/SCP {} -> {}: {}",
1077 src_path_display,
1078 dest_path_str,
1079 stderr,
1080 );
1081 }
1082
1083 async fn is_sftp_available() -> bool {
1084 which::which("sftp").is_ok()
1085 }
1086}
1087
1088impl SshSocket {
1089 #[cfg(not(windows))]
1090 async fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
1091 Ok(Self {
1092 connection_options: options,
1093 envs: HashMap::default(),
1094 socket_path,
1095 })
1096 }
1097
1098 #[cfg(windows)]
1099 async fn new(
1100 options: SshConnectionOptions,
1101 password: askpass::EncryptedPassword,
1102 executor: gpui::BackgroundExecutor,
1103 ) -> Result<Self> {
1104 let mut envs = HashMap::default();
1105 let get_password =
1106 move |_| Task::ready(std::ops::ControlFlow::Continue(Ok(password.clone())));
1107
1108 let _proxy = askpass::PasswordProxy::new(Box::new(get_password), executor).await?;
1109 envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
1110 envs.insert(
1111 "SSH_ASKPASS".into(),
1112 _proxy.script_path().as_ref().display().to_string(),
1113 );
1114
1115 Ok(Self {
1116 connection_options: options,
1117 envs,
1118 _proxy,
1119 })
1120 }
1121
1122 // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
1123 // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
1124 // and passes -l as an argument to sh, not to ls.
1125 // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
1126 // into a machine. You must use `cd` to get back to $HOME.
1127 // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
1128 fn ssh_command(
1129 &self,
1130 shell_kind: ShellKind,
1131 program: &str,
1132 args: &[impl AsRef<str>],
1133 allow_pseudo_tty: bool,
1134 ) -> util::command::Command {
1135 let mut command = util::command::new_command("ssh");
1136 let program = shell_kind.prepend_command_prefix(program);
1137 let mut to_run = shell_kind
1138 .try_quote_prefix_aware(&program)
1139 .expect("shell quoting")
1140 .into_owned();
1141 for arg in args {
1142 // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
1143 debug_assert!(
1144 !arg.as_ref().contains('\n'),
1145 "multiline arguments do not work in all shells"
1146 );
1147 to_run.push(' ');
1148 to_run.push_str(&shell_kind.try_quote(arg.as_ref()).expect("shell quoting"));
1149 }
1150 let to_run = if shell_kind == ShellKind::Cmd {
1151 to_run // 'cd' prints the current directory in CMD
1152 } else {
1153 let separator = shell_kind.sequential_commands_separator();
1154 format!("cd{separator} {to_run}")
1155 };
1156 self.ssh_options(&mut command, true)
1157 .arg(self.connection_options.ssh_destination());
1158 if !allow_pseudo_tty {
1159 command.arg("-T");
1160 }
1161 command.arg(to_run);
1162 log::debug!("ssh {:?}", command);
1163 command
1164 }
1165
1166 async fn run_command(
1167 &self,
1168 shell_kind: ShellKind,
1169 program: &str,
1170 args: &[impl AsRef<str>],
1171 allow_pseudo_tty: bool,
1172 ) -> Result<String> {
1173 let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
1174 let output = command.output().await?;
1175 log::debug!("{:?}: {:?}", command, output);
1176 anyhow::ensure!(
1177 output.status.success(),
1178 "failed to run command {command:?}: {}",
1179 String::from_utf8_lossy(&output.stderr)
1180 );
1181 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1182 }
1183
1184 fn ssh_options<'a>(
1185 &self,
1186 command: &'a mut util::command::Command,
1187 include_port_forwards: bool,
1188 ) -> &'a mut util::command::Command {
1189 let args = if include_port_forwards {
1190 self.connection_options.additional_args()
1191 } else {
1192 self.connection_options.additional_args_for_scp()
1193 };
1194
1195 let cmd = command
1196 .stdin(Stdio::piped())
1197 .stdout(Stdio::piped())
1198 .stderr(Stdio::piped())
1199 .args(args);
1200
1201 if cfg!(windows) {
1202 cmd.envs(self.envs.clone());
1203 }
1204 #[cfg(not(windows))]
1205 {
1206 cmd.args(["-o", "ControlMaster=no", "-o"])
1207 .arg(format!("ControlPath={}", self.socket_path.display()));
1208 }
1209 cmd
1210 }
1211
1212 // Returns the SSH command-line options (without the destination) for building commands.
1213 // On Linux, this includes the ControlPath option to reuse the existing connection.
1214 // Note: The destination must be added separately after all options to ensure proper
1215 // SSH command structure: ssh [options] destination [command]
1216 fn ssh_command_options(&self) -> Vec<String> {
1217 let arguments = self.connection_options.additional_args();
1218 #[cfg(not(windows))]
1219 let arguments = {
1220 let mut args = arguments;
1221 args.extend(vec![
1222 "-o".to_string(),
1223 "ControlMaster=no".to_string(),
1224 "-o".to_string(),
1225 format!("ControlPath={}", self.socket_path.display()),
1226 ]);
1227 args
1228 };
1229 arguments
1230 }
1231
1232 async fn platform(&self, shell: ShellKind, is_windows: bool) -> Result<RemotePlatform> {
1233 if is_windows {
1234 self.platform_windows(shell).await
1235 } else {
1236 self.platform_posix(shell).await
1237 }
1238 }
1239
1240 async fn platform_posix(&self, shell: ShellKind) -> Result<RemotePlatform> {
1241 let output = self
1242 .run_command(shell, "uname", &["-sm"], false)
1243 .await
1244 .context("Failed to run 'uname -sm' to determine platform")?;
1245 parse_platform(&output)
1246 }
1247
1248 async fn platform_windows(&self, shell: ShellKind) -> Result<RemotePlatform> {
1249 let output = self
1250 .run_command(
1251 shell,
1252 "cmd.exe",
1253 &["/c", "echo", "%PROCESSOR_ARCHITECTURE%"],
1254 false,
1255 )
1256 .await
1257 .context(
1258 "Failed to run 'echo %PROCESSOR_ARCHITECTURE%' to determine Windows architecture",
1259 )?;
1260
1261 Ok(RemotePlatform {
1262 os: RemoteOs::Windows,
1263 arch: match output.trim() {
1264 "AMD64" => RemoteArch::X86_64,
1265 "ARM64" => RemoteArch::Aarch64,
1266 arch => anyhow::bail!(
1267 "Prebuilt remote servers are not yet available for windows-{arch}. See https://zed.dev/docs/remote-development"
1268 ),
1269 },
1270 })
1271 }
1272
1273 /// Probes whether the remote host is running Windows.
1274 ///
1275 /// This is done by attempting to run a simple Windows-specific command.
1276 /// If it succeeds and returns Windows-like output, we assume it's Windows.
1277 async fn probe_is_windows(&self) -> bool {
1278 match self
1279 .run_command(ShellKind::Cmd, "cmd.exe", &["/c", "ver"], false)
1280 .await
1281 {
1282 // Windows 'ver' command outputs something like "Microsoft Windows [Version 10.0.19045.5011]"
1283 Ok(output) => output.trim().contains("indows"),
1284 Err(_) => false,
1285 }
1286 }
1287
1288 async fn shell(&self, is_windows: bool) -> String {
1289 if is_windows {
1290 self.shell_windows().await
1291 } else {
1292 self.shell_posix().await
1293 }
1294 }
1295
1296 async fn shell_posix(&self) -> String {
1297 const DEFAULT_SHELL: &str = "sh";
1298 match self
1299 .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
1300 .await
1301 {
1302 Ok(output) => parse_shell(&output, DEFAULT_SHELL),
1303 Err(e) => {
1304 log::error!("Failed to detect remote shell: {e}");
1305 DEFAULT_SHELL.to_owned()
1306 }
1307 }
1308 }
1309
1310 async fn shell_windows(&self) -> String {
1311 const DEFAULT_SHELL: &str = "cmd.exe";
1312
1313 // We detect the shell used by the SSH session by running the following command in PowerShell:
1314 // (Get-CimInstance Win32_Process -Filter "ProcessId = $((Get-CimInstance Win32_Process -Filter ProcessId=$PID).ParentProcessId)").Name
1315 // This prints the name of PowerShell's parent process (which will be the shell that SSH launched).
1316 // We pass it as a Base64 encoded string since we don't yet know how to correctly quote that command.
1317 // (We'd need to know what the shell is to do that...)
1318 match self
1319 .run_command(
1320 ShellKind::Cmd,
1321 "powershell",
1322 &[
1323 "-E",
1324 "KABHAGUAdAAtAEMAaQBtAEkAbgBzAHQAYQBuAGMAZQAgAFcAaQBuADMAMgBfAFAAcgBvAGMAZQBzAHMAIAAtAEYAaQBsAHQAZQByACAAIgBQAHIAbwBjAGUAcwBzAEkAZAAgAD0AIAAkACgAKABHAGUAdAAtAEMAaQBtAEkAbgBzAHQAYQBuAGMAZQAgAFcAaQBuADMAMgBfAFAAcgBvAGMAZQBzAHMAIAAtAEYAaQBsAHQAZQByACAAUAByAG8AYwBlAHMAcwBJAGQAPQAkAFAASQBEACkALgBQAGEAcgBlAG4AdABQAHIAbwBjAGUAcwBzAEkAZAApACIAKQAuAE4AYQBtAGUA",
1325 ],
1326 false,
1327 )
1328 .await
1329 {
1330 Ok(output) => parse_shell(&output, DEFAULT_SHELL),
1331 Err(e) => {
1332 log::error!("Failed to detect remote shell: {e}");
1333 DEFAULT_SHELL.to_owned()
1334 }
1335 }
1336 }
1337}
1338
1339fn parse_port_number(port_str: &str) -> Result<u16> {
1340 port_str
1341 .parse()
1342 .with_context(|| format!("parsing port number: {port_str}"))
1343}
1344
1345fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
1346 let parts: Vec<&str> = spec.split(':').collect();
1347
1348 match *parts {
1349 [a, b, c, d] => {
1350 let local_port = parse_port_number(b)?;
1351 let remote_port = parse_port_number(d)?;
1352
1353 Ok(SshPortForwardOption {
1354 local_host: Some(a.to_string()),
1355 local_port,
1356 remote_host: Some(c.to_string()),
1357 remote_port,
1358 })
1359 }
1360 [a, b, c] => {
1361 let local_port = parse_port_number(a)?;
1362 let remote_port = parse_port_number(c)?;
1363
1364 Ok(SshPortForwardOption {
1365 local_host: None,
1366 local_port,
1367 remote_host: Some(b.to_string()),
1368 remote_port,
1369 })
1370 }
1371 _ => anyhow::bail!("Invalid port forward format"),
1372 }
1373}
1374
1375impl SshConnectionOptions {
1376 pub fn parse_command_line(input: &str) -> Result<Self> {
1377 let input = input.trim_start_matches("ssh ");
1378 let mut hostname: Option<String> = None;
1379 let mut username: Option<String> = None;
1380 let mut port: Option<u16> = None;
1381 let mut args = Vec::new();
1382 let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
1383
1384 // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
1385 const ALLOWED_OPTS: &[&str] = &[
1386 "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
1387 ];
1388 const ALLOWED_ARGS: &[&str] = &[
1389 "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
1390 "-w",
1391 ];
1392
1393 let mut tokens = ShellKind::Posix
1394 .split(input)
1395 .context("invalid input")?
1396 .into_iter();
1397
1398 'outer: while let Some(arg) = tokens.next() {
1399 if ALLOWED_OPTS.contains(&(&arg as &str)) {
1400 args.push(arg.to_string());
1401 continue;
1402 }
1403 if arg == "-p" {
1404 port = tokens.next().and_then(|arg| arg.parse().ok());
1405 continue;
1406 } else if let Some(p) = arg.strip_prefix("-p") {
1407 port = p.parse().ok();
1408 continue;
1409 }
1410 if arg == "-l" {
1411 username = tokens.next();
1412 continue;
1413 } else if let Some(l) = arg.strip_prefix("-l") {
1414 username = Some(l.to_string());
1415 continue;
1416 }
1417 if arg == "-L" || arg.starts_with("-L") {
1418 let forward_spec = if arg == "-L" {
1419 tokens.next()
1420 } else {
1421 Some(arg.strip_prefix("-L").unwrap().to_string())
1422 };
1423
1424 if let Some(spec) = forward_spec {
1425 port_forwards.push(parse_port_forward_spec(&spec)?);
1426 } else {
1427 anyhow::bail!("Missing port forward format");
1428 }
1429 }
1430
1431 for a in ALLOWED_ARGS {
1432 if arg == *a {
1433 args.push(arg);
1434 if let Some(next) = tokens.next() {
1435 args.push(next);
1436 }
1437 continue 'outer;
1438 } else if arg.starts_with(a) {
1439 args.push(arg);
1440 continue 'outer;
1441 }
1442 }
1443 if arg.starts_with("-") || hostname.is_some() {
1444 anyhow::bail!("unsupported argument: {:?}", arg);
1445 }
1446 let mut input = &arg as &str;
1447 // Destination might be: username1@username2@ip2@ip1
1448 if let Some((u, rest)) = input.rsplit_once('@') {
1449 input = rest;
1450 username = Some(u.to_string());
1451 }
1452
1453 // Handle port parsing, accounting for IPv6 addresses
1454 // IPv6 addresses can be: 2001:db8::1 or [2001:db8::1]:22
1455 if input.starts_with('[') {
1456 if let Some((rest, p)) = input.rsplit_once("]:") {
1457 input = rest.strip_prefix('[').unwrap_or(rest);
1458 port = p.parse().ok();
1459 } else if input.ends_with(']') {
1460 input = input.strip_prefix('[').unwrap_or(input);
1461 input = input.strip_suffix(']').unwrap_or(input);
1462 }
1463 } else if let Some((rest, p)) = input.rsplit_once(':')
1464 && !rest.contains(":")
1465 {
1466 input = rest;
1467 port = p.parse().ok();
1468 }
1469
1470 hostname = Some(input.to_string())
1471 }
1472
1473 let Some(hostname) = hostname else {
1474 anyhow::bail!("missing hostname");
1475 };
1476
1477 let port_forwards = match port_forwards.len() {
1478 0 => None,
1479 _ => Some(port_forwards),
1480 };
1481
1482 Ok(Self {
1483 host: hostname.into(),
1484 username,
1485 port,
1486 port_forwards,
1487 args: Some(args),
1488 password: None,
1489 nickname: None,
1490 upload_binary_over_ssh: false,
1491 connection_timeout: None,
1492 })
1493 }
1494
1495 pub fn ssh_destination(&self) -> String {
1496 let mut result = String::default();
1497 if let Some(username) = &self.username {
1498 // Username might be: username1@username2@ip2
1499 let username = urlencoding::encode(username);
1500 result.push_str(&username);
1501 result.push('@');
1502 }
1503
1504 result.push_str(&self.host.to_string());
1505 result
1506 }
1507
1508 pub fn additional_args_for_scp(&self) -> Vec<String> {
1509 self.args.iter().flatten().cloned().collect::<Vec<String>>()
1510 }
1511
1512 pub fn additional_args(&self) -> Vec<String> {
1513 let mut args = self.additional_args_for_scp();
1514
1515 if let Some(timeout) = self.connection_timeout {
1516 args.extend(["-o".to_string(), format!("ConnectTimeout={}", timeout)]);
1517 }
1518
1519 if let Some(port) = self.port {
1520 args.push("-p".to_string());
1521 args.push(port.to_string());
1522 }
1523
1524 if let Some(forwards) = &self.port_forwards {
1525 args.extend(forwards.iter().map(|pf| {
1526 let local_host = match &pf.local_host {
1527 Some(host) => host,
1528 None => "localhost",
1529 };
1530 let remote_host = match &pf.remote_host {
1531 Some(host) => host,
1532 None => "localhost",
1533 };
1534
1535 format!(
1536 "-L{}:{}:{}:{}",
1537 local_host, pf.local_port, remote_host, pf.remote_port
1538 )
1539 }));
1540 }
1541
1542 args
1543 }
1544
1545 fn scp_destination(&self) -> String {
1546 if let Some(username) = &self.username {
1547 format!("{}@{}", username, self.host.to_bracketed_string())
1548 } else {
1549 self.host.to_string()
1550 }
1551 }
1552
1553 pub fn connection_string(&self) -> String {
1554 let host = if let Some(port) = &self.port {
1555 format!("{}:{}", self.host.to_bracketed_string(), port)
1556 } else {
1557 self.host.to_string()
1558 };
1559
1560 if let Some(username) = &self.username {
1561 format!("{}@{}", username, host)
1562 } else {
1563 host
1564 }
1565 }
1566}
1567
1568fn build_command_posix(
1569 input_program: Option<String>,
1570 input_args: &[String],
1571 input_env: &HashMap<String, String>,
1572 working_dir: Option<String>,
1573 port_forward: Option<(u16, String, u16)>,
1574 ssh_env: HashMap<String, String>,
1575 ssh_path_style: PathStyle,
1576 ssh_shell: &str,
1577 ssh_shell_kind: ShellKind,
1578 ssh_options: Vec<String>,
1579 ssh_destination: &str,
1580 interactive: Interactive,
1581) -> Result<CommandTemplate> {
1582 use std::fmt::Write as _;
1583
1584 let mut exec = String::new();
1585 if let Some(working_dir) = working_dir {
1586 let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1587
1588 // shlex will wrap the command in single quotes (''), disabling ~ expansion,
1589 // replace with something that works
1590 const TILDE_PREFIX: &'static str = "~/";
1591 if working_dir.starts_with(TILDE_PREFIX) {
1592 let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
1593 write!(
1594 exec,
1595 "cd \"$HOME/{working_dir}\" {} ",
1596 ssh_shell_kind.sequential_and_commands_separator()
1597 )?;
1598 } else {
1599 write!(
1600 exec,
1601 "cd \"{working_dir}\" {} ",
1602 ssh_shell_kind.sequential_and_commands_separator()
1603 )?;
1604 }
1605 } else {
1606 write!(
1607 exec,
1608 "cd {} ",
1609 ssh_shell_kind.sequential_and_commands_separator()
1610 )?;
1611 };
1612 write!(exec, "exec env ")?;
1613
1614 for (k, v) in input_env.iter() {
1615 write!(
1616 exec,
1617 "{}={} ",
1618 k,
1619 ssh_shell_kind.try_quote(v).context("shell quoting")?
1620 )?;
1621 }
1622
1623 if let Some(input_program) = input_program {
1624 write!(
1625 exec,
1626 "{}",
1627 ssh_shell_kind
1628 .try_quote_prefix_aware(&input_program)
1629 .context("shell quoting")?
1630 )?;
1631 for arg in input_args {
1632 let arg = ssh_shell_kind.try_quote(&arg).context("shell quoting")?;
1633 write!(exec, " {}", &arg)?;
1634 }
1635 } else {
1636 write!(exec, "{ssh_shell} -l")?;
1637 };
1638
1639 let mut args = Vec::new();
1640 args.extend(ssh_options);
1641
1642 if let Some((local_port, host, remote_port)) = port_forward {
1643 args.push("-L".into());
1644 args.push(format!("{local_port}:{host}:{remote_port}"));
1645 }
1646
1647 // -q suppresses the "Connection to ... closed." message that SSH prints when
1648 // the connection terminates with -t (pseudo-terminal allocation)
1649 args.push("-q".into());
1650 match interactive {
1651 // -t forces pseudo-TTY allocation (for interactive use)
1652 Interactive::Yes => args.push("-t".into()),
1653 // -T disables pseudo-TTY allocation (for non-interactive piped stdio)
1654 Interactive::No => args.push("-T".into()),
1655 }
1656 // The destination must come after all options but before the command
1657 args.push(ssh_destination.into());
1658 args.push(exec);
1659
1660 Ok(CommandTemplate {
1661 program: "ssh".into(),
1662 args,
1663 env: ssh_env,
1664 })
1665}
1666
1667fn build_command_windows(
1668 input_program: Option<String>,
1669 input_args: &[String],
1670 _input_env: &HashMap<String, String>,
1671 working_dir: Option<String>,
1672 port_forward: Option<(u16, String, u16)>,
1673 ssh_env: HashMap<String, String>,
1674 ssh_path_style: PathStyle,
1675 ssh_shell: &str,
1676 _ssh_shell_kind: ShellKind,
1677 ssh_options: Vec<String>,
1678 ssh_destination: &str,
1679 interactive: Interactive,
1680) -> Result<CommandTemplate> {
1681 use base64::Engine as _;
1682 use std::fmt::Write as _;
1683
1684 let mut exec = String::new();
1685 let shell_kind = ShellKind::PowerShell;
1686
1687 if let Some(working_dir) = working_dir {
1688 let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1689
1690 write!(
1691 exec,
1692 "Set-Location -Path {} {} ",
1693 shell_kind
1694 .try_quote(&working_dir)
1695 .context("shell quoting")?,
1696 shell_kind.sequential_and_commands_separator()
1697 )?;
1698 }
1699
1700 // Windows OpenSSH has an 8K character limit for command lines. Sending a lot of environment variables easily puts us over the limit.
1701 // Until we have a better solution for this, we just won't set environment variables for now.
1702 // for (k, v) in input_env.iter() {
1703 // write!(
1704 // exec,
1705 // "$env:{}={} {} ",
1706 // k,
1707 // shell_kind.try_quote(v).context("shell quoting")?,
1708 // shell_kind.sequential_and_commands_separator()
1709 // )?;
1710 // }
1711
1712 if let Some(input_program) = input_program {
1713 write!(
1714 exec,
1715 "{}",
1716 shell_kind
1717 .try_quote_prefix_aware(&shell_kind.prepend_command_prefix(&input_program))
1718 .context("shell quoting")?
1719 )?;
1720 for arg in input_args {
1721 let arg = shell_kind.try_quote(arg).context("shell quoting")?;
1722 write!(exec, " {}", &arg)?;
1723 }
1724 } else {
1725 // Launch an interactive shell session
1726 write!(exec, "{ssh_shell}")?;
1727 };
1728
1729 let mut args = Vec::new();
1730 args.extend(ssh_options);
1731
1732 if let Some((local_port, host, remote_port)) = port_forward {
1733 args.push("-L".into());
1734 args.push(format!("{local_port}:{host}:{remote_port}"));
1735 }
1736
1737 // -q suppresses the "Connection to ... closed." message that SSH prints when
1738 // the connection terminates with -t (pseudo-terminal allocation)
1739 args.push("-q".into());
1740 match interactive {
1741 // -t forces pseudo-TTY allocation (for interactive use)
1742 Interactive::Yes => args.push("-t".into()),
1743 // -T disables pseudo-TTY allocation (for non-interactive piped stdio)
1744 Interactive::No => args.push("-T".into()),
1745 }
1746
1747 // The destination must come after all options but before the command
1748 args.push(ssh_destination.into());
1749
1750 // Windows OpenSSH server incorrectly escapes the command string when the PTY is used.
1751 // The simplest way to work around this is to use a base64 encoded command, which doesn't require escaping.
1752 let utf16_bytes: Vec<u16> = exec.encode_utf16().collect();
1753 let byte_slice: Vec<u8> = utf16_bytes.iter().flat_map(|&u| u.to_le_bytes()).collect();
1754 let base64_encoded = base64::engine::general_purpose::STANDARD.encode(&byte_slice);
1755
1756 args.push(format!("powershell.exe -E {}", base64_encoded));
1757
1758 Ok(CommandTemplate {
1759 program: "ssh".into(),
1760 args,
1761 env: ssh_env,
1762 })
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767 use super::*;
1768
1769 #[test]
1770 fn test_build_command() -> Result<()> {
1771 let mut input_env = HashMap::default();
1772 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1773 let mut env = HashMap::default();
1774 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1775
1776 // Test non-interactive command (interactive=false should use -T)
1777 let command = build_command_posix(
1778 Some("remote_program".to_string()),
1779 &["arg1".to_string(), "arg2".to_string()],
1780 &input_env,
1781 Some("~/work".to_string()),
1782 None,
1783 env.clone(),
1784 PathStyle::Posix,
1785 "/bin/bash",
1786 ShellKind::Posix,
1787 vec!["-o".to_string(), "ControlMaster=auto".to_string()],
1788 "user@host",
1789 Interactive::No,
1790 )?;
1791 assert_eq!(command.program, "ssh");
1792 // Should contain -T for non-interactive
1793 assert!(command.args.iter().any(|arg| arg == "-T"));
1794 assert!(!command.args.iter().any(|arg| arg == "-t"));
1795
1796 // Test interactive command (interactive=true should use -t)
1797 let command = build_command_posix(
1798 Some("remote_program".to_string()),
1799 &["arg1".to_string(), "arg2".to_string()],
1800 &input_env,
1801 Some("~/work".to_string()),
1802 None,
1803 env.clone(),
1804 PathStyle::Posix,
1805 "/bin/fish",
1806 ShellKind::Fish,
1807 vec!["-p".to_string(), "2222".to_string()],
1808 "user@host",
1809 Interactive::Yes,
1810 )?;
1811
1812 assert_eq!(command.program, "ssh");
1813 assert_eq!(
1814 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1815 [
1816 "-p",
1817 "2222",
1818 "-q",
1819 "-t",
1820 "user@host",
1821 "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
1822 ]
1823 );
1824 assert_eq!(command.env, env);
1825
1826 let mut input_env = HashMap::default();
1827 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1828 let mut env = HashMap::default();
1829 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1830
1831 let command = build_command_posix(
1832 None,
1833 &[],
1834 &input_env,
1835 None,
1836 Some((1, "foo".to_owned(), 2)),
1837 env.clone(),
1838 PathStyle::Posix,
1839 "/bin/fish",
1840 ShellKind::Fish,
1841 vec!["-p".to_string(), "2222".to_string()],
1842 "user@host",
1843 Interactive::Yes,
1844 )?;
1845
1846 assert_eq!(command.program, "ssh");
1847 assert_eq!(
1848 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1849 [
1850 "-p",
1851 "2222",
1852 "-L",
1853 "1:foo:2",
1854 "-q",
1855 "-t",
1856 "user@host",
1857 "cd && exec env INPUT_VA=val /bin/fish -l"
1858 ]
1859 );
1860 assert_eq!(command.env, env);
1861
1862 Ok(())
1863 }
1864
1865 #[test]
1866 fn scp_args_exclude_port_forward_flags() {
1867 let options = SshConnectionOptions {
1868 host: "example.com".into(),
1869 args: Some(vec![
1870 "-p".to_string(),
1871 "2222".to_string(),
1872 "-o".to_string(),
1873 "StrictHostKeyChecking=no".to_string(),
1874 ]),
1875 port_forwards: Some(vec![SshPortForwardOption {
1876 local_host: Some("127.0.0.1".to_string()),
1877 local_port: 8080,
1878 remote_host: Some("127.0.0.1".to_string()),
1879 remote_port: 80,
1880 }]),
1881 ..Default::default()
1882 };
1883
1884 let ssh_args = options.additional_args();
1885 assert!(
1886 ssh_args.iter().any(|arg| arg.starts_with("-L")),
1887 "expected ssh args to include port-forward: {ssh_args:?}"
1888 );
1889
1890 let scp_args = options.additional_args_for_scp();
1891 assert_eq!(
1892 scp_args,
1893 vec![
1894 "-p".to_string(),
1895 "2222".to_string(),
1896 "-o".to_string(),
1897 "StrictHostKeyChecking=no".to_string(),
1898 ]
1899 );
1900 }
1901
1902 #[test]
1903 fn test_host_parsing() -> Result<()> {
1904 let opts = SshConnectionOptions::parse_command_line("user@2001:db8::1")?;
1905 assert_eq!(opts.host, "2001:db8::1".into());
1906 assert_eq!(opts.username, Some("user".to_string()));
1907 assert_eq!(opts.port, None);
1908
1909 let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]:2222")?;
1910 assert_eq!(opts.host, "2001:db8::1".into());
1911 assert_eq!(opts.username, Some("user".to_string()));
1912 assert_eq!(opts.port, Some(2222));
1913
1914 let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]")?;
1915 assert_eq!(opts.host, "2001:db8::1".into());
1916 assert_eq!(opts.username, Some("user".to_string()));
1917 assert_eq!(opts.port, None);
1918
1919 let opts = SshConnectionOptions::parse_command_line("2001:db8::1")?;
1920 assert_eq!(opts.host, "2001:db8::1".into());
1921 assert_eq!(opts.username, None);
1922 assert_eq!(opts.port, None);
1923
1924 let opts = SshConnectionOptions::parse_command_line("[2001:db8::1]:2222")?;
1925 assert_eq!(opts.host, "2001:db8::1".into());
1926 assert_eq!(opts.username, None);
1927 assert_eq!(opts.port, Some(2222));
1928
1929 let opts = SshConnectionOptions::parse_command_line("user@example.com:2222")?;
1930 assert_eq!(opts.host, "example.com".into());
1931 assert_eq!(opts.username, Some("user".to_string()));
1932 assert_eq!(opts.port, Some(2222));
1933
1934 let opts = SshConnectionOptions::parse_command_line("user@192.168.1.1:2222")?;
1935 assert_eq!(opts.host, "192.168.1.1".into());
1936 assert_eq!(opts.username, Some("user".to_string()));
1937 assert_eq!(opts.port, Some(2222));
1938
1939 Ok(())
1940 }
1941}