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