1use super::*;
2
3#[cfg(unix)]
4use std::fs;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Condvar, Mutex, OnceLock};
7use std::time::Duration;
8
9pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
24 let mut args = vec![
25 "-o".to_owned(),
26 "BatchMode=yes".to_owned(),
27 "-o".to_owned(),
28 "StrictHostKeyChecking=yes".to_owned(),
29 ];
30 args.extend(ssh.ssh_args.iter().cloned());
31 args.push(ssh.destination.clone());
32 args.push(join_remote_command(&["true".to_owned()]));
33 CommandSpec::new("ssh", args)
36 .ssh_probe_session(ssh)
37 .purpose("verify SSH connectivity")
38}
39
40pub fn ssh_command(
41 ssh: &SshTarget,
42 args: impl IntoIterator<Item = impl AsRef<str>>,
43) -> CommandSpec {
44 ssh_command_owned(
45 ssh,
46 args.into_iter()
47 .map(|arg| arg.as_ref().to_owned())
48 .collect(),
49 )
50}
51
52pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
56 let mut args = ssh.ssh_args.clone();
57 args.push(ssh.destination.clone());
58 args.push(join_remote_command(&remote_args));
59 CommandSpec::new("ssh", args).ssh_session(ssh)
60}
61
62pub const REMOTE_UPLOAD_STAGING: &str = ".cache/mjolnir/uploads";
66
67pub fn scp_upload(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
69 let mut args = scp_args(ssh);
70 if recursive {
71 args.push("-r".into());
72 }
73 args.push(source.to_string_lossy().into_owned());
74 args.push(format!("{}:{remote}", ssh.destination));
75 scp_command(ssh, args)
76}
77
78pub fn scp_download(ssh: &SshTarget, remote: &str, local: &str) -> CommandSpec {
80 let mut args = scp_args(ssh);
81 args.push(format!("{}:{remote}", ssh.destination));
82 args.push(local.into());
83 scp_command(ssh, args)
84}
85
86fn scp_args(ssh: &SshTarget) -> Vec<String> {
89 ssh.ssh_args
90 .iter()
91 .map(|argument| {
92 if argument == "-p" {
93 "-P".to_owned()
94 } else {
95 argument.clone()
96 }
97 })
98 .collect()
99}
100
101fn scp_command(ssh: &SshTarget, args: Vec<String>) -> CommandSpec {
102 CommandSpec::new("scp", args).ssh_session(ssh)
105}
106
107#[cfg(unix)]
112const CONTROL_PERSIST: &str = "60";
113
114pub const CONTROL_MASTER_ENV: &str = "MJ_SSH_CONTROL_MASTER";
117
118#[cfg(unix)]
121const MAX_CONTROL_PATH: usize = 103;
122
123#[cfg(unix)]
126const CONNECTION_HASH_HEX: usize = 16;
127
128#[cfg(unix)]
133const CONTROL_SOCKET_NAME_RESERVE: usize = 1 + CONNECTION_HASH_HEX + 1 + 4 + 17;
134
135#[cfg(unix)]
136fn sharing_disabled(value: Option<&std::ffi::OsStr>) -> bool {
137 let Some(value) = value else {
138 return false;
139 };
140 matches!(
141 value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
142 "0" | "off" | "false" | "no"
143 )
144}
145
146#[doc(hidden)]
149#[derive(Debug, Clone)]
150pub enum SshSharingForTest {
151 Disabled,
153 Directory(PathBuf),
155}
156
157static SHARING_OVERRIDE: Mutex<Option<SshSharingForTest>> = Mutex::new(None);
158
159#[cfg(all(test, unix))]
162pub(super) static SHARING_TEST_LOCK: Mutex<()> = Mutex::new(());
163
164#[doc(hidden)]
169pub fn set_ssh_connection_sharing_for_test(setting: Option<SshSharingForTest>) {
170 *SHARING_OVERRIDE
171 .lock()
172 .unwrap_or_else(std::sync::PoisonError::into_inner) = setting;
173}
174
175#[cfg(unix)]
176fn sharing_override() -> Option<SshSharingForTest> {
177 SHARING_OVERRIDE
178 .lock()
179 .unwrap_or_else(std::sync::PoisonError::into_inner)
180 .clone()
181}
182
183#[cfg(unix)]
194fn control_socket_dir() -> Option<PathBuf> {
195 match sharing_override() {
196 Some(SshSharingForTest::Disabled) => return None,
197 Some(SshSharingForTest::Directory(dir)) => return prepare_control_dir(dir),
198 None => {}
199 }
200 static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
201 DIR.get_or_init(|| {
202 if sharing_disabled(std::env::var_os(CONTROL_MASTER_ENV).as_deref()) {
203 return None;
204 }
205 let data_dir_override = crate::config::env_override_os("DATA_DIR").map(PathBuf::from);
206 let identity = control_dir_identity(
207 crate::config::instance_name().as_deref(),
208 data_dir_override.as_deref(),
209 );
210 prepare_control_dir(default_control_dir(
211 std::env::var_os("XDG_RUNTIME_DIR"),
212 &identity,
213 ))
214 })
215 .clone()
216}
217
218#[cfg(unix)]
220fn default_control_dir(runtime: Option<std::ffi::OsString>, identity: &str) -> PathBuf {
221 match runtime {
222 Some(runtime) if !runtime.is_empty() => {
223 PathBuf::from(runtime).join("mjolnir").join(identity)
224 }
225 _ => crate::config::data_dir().join("ssh"),
227 }
228}
229
230#[cfg(unix)]
238fn control_dir_identity(instance: Option<&str>, data_dir_override: Option<&Path>) -> String {
239 if instance.is_none() && data_dir_override.is_none() {
240 return "default".to_owned();
241 }
242 let data_dir = data_dir_override.map_or_else(crate::config::data_dir, Path::to_path_buf);
243 crate::config::instance_identity_for(instance, &data_dir)
244}
245
246#[cfg(unix)]
249fn prepare_control_dir(dir: PathBuf) -> Option<PathBuf> {
250 if dir.as_os_str().len() + CONTROL_SOCKET_NAME_RESERVE > MAX_CONTROL_PATH {
251 tracing::debug!(
252 directory = %dir.display(),
253 "skipping SSH connection sharing: control socket path would be too long"
254 );
255 return None;
256 }
257 if let Err(error) = fs::create_dir_all(&dir) {
258 tracing::debug!(
259 directory = %dir.display(),
260 %error,
261 "skipping SSH connection sharing: control directory is unavailable"
262 );
263 return None;
264 }
265 use std::os::unix::fs::PermissionsExt;
266 if let Err(error) = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) {
267 tracing::debug!(
268 directory = %dir.display(),
269 %error,
270 "skipping SSH connection sharing: cannot restrict control directory"
271 );
272 return None;
273 }
274 Some(dir)
275}
276
277#[cfg(unix)]
281fn connection_key(ssh: &SshTarget) -> String {
282 let mut key = ssh.destination.clone();
283 for argument in &ssh.ssh_args {
284 key.push('\0');
285 key.push_str(argument);
286 }
287 key
288}
289
290#[cfg(unix)]
296fn control_socket_name(ssh: &SshTarget, shard: usize) -> String {
297 use sha2::{Digest, Sha256};
298 let digest = Sha256::digest(connection_key(ssh).as_bytes());
299 let mut name = String::with_capacity(CONNECTION_HASH_HEX + 5);
300 for byte in digest.iter().take(CONNECTION_HASH_HEX / 2) {
301 name.push_str(&format!("{byte:02x}"));
302 }
303 name.push_str(&format!("-{shard}"));
304 name
305}
306
307#[cfg(unix)]
312fn user_configures_sharing(ssh_args: &[String]) -> bool {
313 ssh_args.iter().any(|argument| {
314 if argument.starts_with("-S") {
315 return true;
316 }
317 let option = argument.strip_prefix("-o").unwrap_or(argument).trim_start();
318 let option = option.to_ascii_lowercase();
319 ["controlmaster", "controlpath"].iter().any(|name| {
320 option
321 .strip_prefix(name)
322 .is_some_and(|rest| rest.starts_with(['=', ' ', '\t']))
323 })
324 })
325}
326
327pub fn push_connection_reuse_args(args: &mut Vec<String>, ssh: &SshTarget) {
345 #[cfg(unix)]
346 if !user_configures_sharing(&ssh.ssh_args)
347 && let Some(dir) = control_socket_dir()
348 {
349 let socket = dir.join(control_socket_name(ssh, 0));
350 args.extend([
351 "-o".to_owned(),
352 "ControlMaster=no".to_owned(),
353 "-o".to_owned(),
354 format!("ControlPath={}", socket.display()),
355 ]);
356 }
357 #[cfg(not(unix))]
358 let _ = (args, ssh);
359}
360
361pub fn join_remote_command(args: &[String]) -> String {
362 args.iter()
363 .map(|arg| posix_quote(arg))
364 .collect::<Vec<_>>()
365 .join(" ")
366}
367
368pub fn ssh_directory_exists(
370 ssh: &SshTarget,
371 path: &Path,
372 executor: &impl CommandExecutor,
373) -> Result<bool> {
374 let command = ssh_validation_command(
375 ssh,
376 vec![
377 "test".into(),
378 "-d".into(),
379 path.to_string_lossy().into_owned(),
380 ],
381 "validate remote directory",
382 );
383 let output = executor.execute(&command)?;
384 match output.status {
385 0 => Ok(true),
386 1 => Ok(false),
387 status => {
388 let stderr = String::from_utf8_lossy(&output.stderr);
389 let error = anyhow::anyhow!(
390 "remote directory check failed with status {status}: {}",
391 stderr.trim()
392 );
393 Err(match host_key_refusal(&stderr, &ssh.ssh_args) {
394 Some(refusal) => error.context(refusal),
395 None => error,
396 })
397 }
398 }
399}
400
401fn host_key_refusal(stderr: &str, ssh_args: &[String]) -> Option<crate::refusal::Refusal> {
410 if !stderr.contains("Host key verification failed") {
411 return None;
412 }
413 let known_hosts = KnownHostsFile::from_ssh_args(ssh_args);
414 let file = known_hosts.phrase();
415 Some(crate::refusal::Refusal::precondition(
416 if stderr.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
417 let keygen = match known_hosts.first_named() {
418 Some(path) => format!("`ssh-keygen -f {path} -R`"),
419 None => "`ssh-keygen -R`".to_owned(),
420 };
421 format!(
422 "ssh reported \"Host key verification failed\": the machine's host key is not the one saved in {file}. If you expected the change, remove the old entry with {keygen} and the host name, add the new key, and try again."
423 )
424 } else {
425 format!(
426 "ssh reported \"Host key verification failed\": the machine's host key is not in {file}, and its ssh options require a known key. Add the host key (for example with `ssh-keyscan`, after checking the fingerprint), or put `-o StrictHostKeyChecking=accept-new` in the machine's extra_args, and try again."
427 )
428 },
429 ))
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
435enum KnownHostsFile {
436 Default,
438 Named(String),
441 Unknown,
444}
445
446impl KnownHostsFile {
447 fn from_ssh_args(args: &[String]) -> Self {
451 let mut config_file = false;
452 let mut args = args.iter();
453 while let Some(arg) = args.next() {
454 let option = match arg.as_str() {
455 "-o" => args.next().map(String::as_str),
456 other => other.strip_prefix("-o"),
457 };
458 if arg.starts_with("-F") {
459 config_file = true;
460 }
461 let Some(value) = option.and_then(|option| option_value(option, "UserKnownHostsFile"))
462 else {
463 continue;
464 };
465 let value = value.trim_matches('"').trim();
466 return if value.is_empty() || value.eq_ignore_ascii_case("none") {
467 Self::Unknown
468 } else {
469 Self::Named(value.to_owned())
470 };
471 }
472 if config_file {
473 Self::Unknown
474 } else {
475 Self::Default
476 }
477 }
478
479 fn phrase(&self) -> String {
481 match self {
482 Self::Default => "~/.ssh/known_hosts".to_owned(),
483 Self::Named(paths) => paths.split_whitespace().collect::<Vec<_>>().join(" or "),
484 Self::Unknown => "the known_hosts file ssh uses".to_owned(),
485 }
486 }
487
488 fn first_named(&self) -> Option<&str> {
490 match self {
491 Self::Named(paths) => paths.split_whitespace().next(),
492 Self::Default | Self::Unknown => None,
493 }
494 }
495}
496
497fn option_value<'a>(option: &'a str, keyword: &str) -> Option<&'a str> {
500 let option = option.trim_start();
501 let end = option
502 .find(|character: char| character == '=' || character.is_whitespace())
503 .unwrap_or(option.len());
504 let (name, rest) = option.split_at(end);
505 if !name.eq_ignore_ascii_case(keyword) {
506 return None;
507 }
508 let rest = rest.trim_start();
509 Some(rest.strip_prefix('=').unwrap_or(rest).trim())
510}
511
512pub fn validate_bare_project_directory(
514 ssh: &SshTarget,
515 path: &Path,
516 executor: &impl CommandExecutor,
517) -> Result<()> {
518 validate_bare_project_path(path)?;
519 if !ssh_directory_exists(ssh, path, executor)? {
520 return Err(anyhow::Error::new(crate::refusal::Refusal::unusable(
521 format!(
522 "remote project directory {} does not exist or is not a directory on {}",
523 path.display(),
524 ssh.destination
525 ),
526 )));
527 }
528 let output = executor.execute(&ssh_validation_command(
529 ssh,
530 vec![
531 "git".into(),
532 "-C".into(),
533 path.to_string_lossy().into_owned(),
534 "rev-parse".into(),
535 "--verify".into(),
536 "HEAD".into(),
537 ],
538 "validate bare SSH Git project",
539 ))?;
540 if output.status != 0 {
541 let detail = String::from_utf8_lossy(&output.stderr);
542 let detail = detail.trim();
543 if detail.is_empty() {
544 bail!(
545 "remote project directory {} has no valid Git HEAD",
546 path.display()
547 );
548 }
549 bail!(
550 "remote project directory {} has no valid Git HEAD: {detail}",
551 path.display()
552 );
553 }
554 Ok(())
555}
556
557pub fn validate_bare_project_path(path: &Path) -> Result<()> {
558 if !path.is_absolute()
559 || path
560 .components()
561 .any(|part| part == std::path::Component::ParentDir)
562 {
563 bail!("bare project directory must be an absolute safe path");
564 }
565 Ok(())
566}
567
568pub fn ssh_validation_command(
569 ssh: &SshTarget,
570 remote_args: Vec<String>,
571 purpose: &'static str,
572) -> CommandSpec {
573 let mut args = ssh.ssh_args.clone();
574 args.extend([
575 "-o".into(),
576 "BatchMode=yes".into(),
577 "-o".into(),
578 "ConnectTimeout=3".into(),
579 "-o".into(),
580 "ServerAliveInterval=2".into(),
581 "-o".into(),
582 "ServerAliveCountMax=1".into(),
583 ]);
584 args.extend([ssh.destination.clone(), join_remote_command(&remote_args)]);
585 CommandSpec::new("ssh", args)
586 .ssh_probe_session(ssh)
587 .purpose(purpose)
588}
589
590pub fn posix_quote(value: &str) -> String {
594 format!("'{}'", value.replace('\'', "'\\''"))
595}
596
597pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
598 validate_session_id(session_id)?;
599 match locator {
600 TargetLocator::LocalBare { worker_root } => {
601 let path = Path::new(worker_root);
602 if !path.is_absolute()
603 || path
604 .components()
605 .any(|part| part == std::path::Component::ParentDir)
606 || !path.ends_with(session_id)
607 {
608 bail!("refusing cleanup: invalid local bare worker root");
609 }
610 }
611 TargetLocator::LocalPodman {
612 container_id,
613 borrowed_from,
614 ..
615 }
616 | TargetLocator::LocalDocker {
617 container_id,
618 borrowed_from,
619 }
620 | TargetLocator::AppleContainer {
621 container_id,
622 borrowed_from,
623 }
624 | TargetLocator::SshPodman {
625 container_id,
626 borrowed_from,
627 ..
628 }
629 | TargetLocator::SshDocker {
630 container_id,
631 borrowed_from,
632 ..
633 } => match borrowed_from {
634 Some(owner) => {
635 validate_session_id(owner)?;
636 if owner == session_id {
637 bail!(
638 "refusing cleanup: a borrowed container cannot be owned by the borrowing session"
639 );
640 }
641 if !resource_name_belongs_to(container_id, owner)?
642 && !is_runtime_container_id(container_id)
643 {
644 bail!(
645 "refusing cleanup: borrowed container locator is neither the owning session's generated name nor an immutable runtime ID"
646 );
647 }
648 }
649 None => {
650 if !resource_name_belongs_to(container_id, session_id)?
651 && !is_runtime_container_id(container_id)
652 {
653 bail!(
654 "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
655 );
656 }
657 }
658 },
659 TargetLocator::AwsEc2 {
660 instance_id,
661 workspace,
662 ..
663 } => {
664 if !valid_ec2_instance_id(instance_id) {
665 bail!("refusing cleanup: invalid EC2 instance ID");
666 }
667 verify_session_workspace(workspace, session_id)?;
668 }
669 TargetLocator::SshBare {
670 workspace,
671 worker_id,
672 ..
673 } => match worker_id {
674 Some(worker_id) => {
675 validate_session_id(worker_id)?;
676 if worker_id != session_id {
677 bail!("refusing cleanup: SSH worker identity does not match session ID");
678 }
679 validate_workspace_prefix(workspace)?;
680 }
681 None => verify_session_workspace(workspace, session_id)?,
682 },
683 }
684 Ok(())
685}
686
687pub fn is_borrowed(locator: &TargetLocator) -> bool {
691 match locator {
692 TargetLocator::LocalPodman { borrowed_from, .. }
693 | TargetLocator::LocalDocker { borrowed_from, .. }
694 | TargetLocator::AppleContainer { borrowed_from, .. }
695 | TargetLocator::SshPodman { borrowed_from, .. }
696 | TargetLocator::SshDocker { borrowed_from, .. } => borrowed_from.is_some(),
697 TargetLocator::SshBare { worker_id, .. } => worker_id.is_some(),
698 TargetLocator::LocalBare { .. } | TargetLocator::AwsEc2 { .. } => false,
699 }
700}
701
702pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
703 validate_workspace_prefix(workspace)?;
704 let final_component = workspace.trim_end_matches('/').rsplit('/').next();
705 if final_component != Some(session_id) {
706 bail!("refusing cleanup: workspace does not end in the exact session ID");
707 }
708 Ok(())
709}
710
711pub fn validate_session_id(value: &str) -> Result<()> {
712 if value.len() < 8
713 || value.len() > 128
714 || !value
715 .chars()
716 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
717 {
718 bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
719 }
720 Ok(())
721}
722
723pub fn validate_relative_path(value: &str) -> Result<()> {
724 let path = std::path::Path::new(value);
725 if value.is_empty()
726 || path.is_absolute()
727 || path
728 .components()
729 .any(|part| !matches!(part, std::path::Component::Normal(_)))
730 {
731 bail!("unsafe relative bundle path {value:?}");
732 }
733 Ok(())
734}
735
736pub fn validate_workspace_prefix(value: &str) -> Result<()> {
737 if value.is_empty()
738 || value == "/"
739 || value == "~"
740 || value == "~/"
741 || value.contains('\0')
742 || value.split('/').any(|part| part == "..")
743 {
744 bail!("unsafe workspace path");
745 }
746 Ok(())
747}
748
749pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
750 if template.image.trim().is_empty() || template.image.starts_with('-') {
751 bail!("invalid container image");
752 }
753 if template
754 .extra_run_args
755 .iter()
756 .any(|arg| arg == "--name" || arg.starts_with("--name="))
757 {
758 bail!("container template may not override the generated name");
759 }
760 if template.extra_run_args.iter().any(|arg| {
761 arg == "--label"
762 || [SESSION_LABEL, MANAGED_LABEL, INSTANCE_LABEL]
763 .iter()
764 .any(|label| arg.starts_with(&format!("--label={label}=")))
765 }) {
766 bail!("container template may not override Mjolnir ownership labels");
767 }
768 Ok(())
769}
770
771pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
772 if ssh.destination.trim().is_empty()
773 || ssh.destination.starts_with('-')
774 || ssh.destination.chars().any(char::is_whitespace)
775 {
776 bail!("invalid SSH destination");
777 }
778 Ok(())
779}
780
781pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
782 validate_ssh(&aws.ssh)?;
783 for (name, value) in [
784 ("AWS profile", &aws.profile),
785 ("AWS region", &aws.region),
786 ("launch template", &aws.launch_template),
787 ] {
788 if value.is_empty()
789 || value.starts_with('-')
790 || !value
791 .chars()
792 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
793 {
794 bail!("invalid {name}");
795 }
796 }
797 Ok(())
798}
799
800pub fn validate_executable(value: &str) -> Result<()> {
801 if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
802 bail!("invalid executable name");
803 }
804 Ok(())
805}
806
807pub fn valid_ec2_instance_id(value: &str) -> bool {
808 value
809 .strip_prefix("i-")
810 .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
811}
812
813pub fn is_runtime_container_id(value: &str) -> bool {
814 value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
815}
816
817pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
820
821const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
825 "Connection closed by",
826 "Connection reset by",
827 "kex_exchange_identification",
828 "Connection timed out during banner exchange",
829];
830
831pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
837 ssh_refusal(status, stderr).is_some()
838}
839
840const SESSION_REFUSAL_MARKER: &str = "Session open refused by peer";
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848pub enum SshRefusal {
849 BeforeAuthentication,
853 SessionLimit,
856}
857
858impl SshRefusal {
859 pub fn retry_message(self) -> &'static str {
861 match self {
862 Self::BeforeAuthentication => {
863 "the SSH server closed the connection before authentication; retrying"
864 }
865 Self::SessionLimit => {
866 "the SSH server refused another session on a shared connection (MaxSessions); retrying"
867 }
868 }
869 }
870
871 pub fn log_retry(
880 self,
881 destination: &str,
882 purpose: &str,
883 attempt: usize,
884 delay: Duration,
885 stderr: &str,
886 ) {
887 let delay_ms = delay.as_millis() as u64;
888 match self {
889 Self::SessionLimit => tracing::debug!(
890 destination,
891 purpose,
892 attempt,
893 attempts = SSH_RETRY_ATTEMPTS,
894 delay_ms,
895 stderr,
896 "{}",
897 self.retry_message()
898 ),
899 Self::BeforeAuthentication => tracing::warn!(
900 destination,
901 purpose,
902 attempt,
903 attempts = SSH_RETRY_ATTEMPTS,
904 delay_ms,
905 stderr,
906 "{}",
907 self.retry_message()
908 ),
909 }
910 }
911
912 pub fn log_exhausted(self, destination: &str, purpose: &str, stderr: &str) {
914 tracing::warn!(
915 destination,
916 purpose,
917 attempts = SSH_RETRY_ATTEMPTS,
918 stderr,
919 "{}",
920 match self {
921 Self::BeforeAuthentication =>
922 "the SSH server closed the connection before authentication on every attempt",
923 Self::SessionLimit =>
924 "the SSH server refused another session on a shared connection (MaxSessions) on every attempt",
925 }
926 );
927 }
928}
929
930pub fn ssh_refusal(status: i32, stderr: &str) -> Option<SshRefusal> {
934 if status != SSH_TRANSPORT_EXIT_STATUS {
935 return None;
936 }
937 if stderr.contains(SESSION_REFUSAL_MARKER) {
938 return Some(SshRefusal::SessionLimit);
939 }
940 TRANSPORT_REJECTION_MARKERS
941 .iter()
942 .any(|marker| stderr.contains(marker))
943 .then_some(SshRefusal::BeforeAuthentication)
944}
945
946const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
955
956pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
958
959fn max_concurrent_ssh() -> usize {
960 static LIMIT: OnceLock<usize> = OnceLock::new();
961 *LIMIT.get_or_init(|| positive_env_limit(MAX_CONCURRENT_SSH_ENV, DEFAULT_MAX_CONCURRENT_SSH))
962}
963
964fn positive_env_limit(name: &str, default: usize) -> usize {
967 let Some(raw) = std::env::var_os(name) else {
968 return default;
969 };
970 match raw
971 .to_str()
972 .and_then(|value| value.trim().parse::<usize>().ok())
973 {
974 Some(limit) if limit > 0 => limit,
975 _ => {
976 tracing::warn!(
977 variable = name,
978 value = %raw.to_string_lossy(),
979 default,
980 "ignoring invalid SSH limit"
981 );
982 default
983 }
984 }
985}
986
987struct DestinationGate {
993 limit: usize,
994 in_flight: Mutex<usize>,
995 released: Condvar,
996}
997
998impl DestinationGate {
999 fn new(limit: usize) -> Arc<Self> {
1000 Arc::new(Self {
1001 limit,
1002 in_flight: Mutex::new(0),
1003 released: Condvar::new(),
1004 })
1005 }
1006
1007 fn acquire(self: &Arc<Self>) -> SshPermit {
1008 self.acquire_unless(&|| false)
1009 .expect("unconditional SSH admission cannot be cancelled")
1010 }
1011
1012 fn acquire_unless(self: &Arc<Self>, cancelled: &dyn Fn() -> bool) -> Result<SshPermit> {
1013 let mut in_flight = self
1014 .in_flight
1015 .lock()
1016 .unwrap_or_else(std::sync::PoisonError::into_inner);
1017 loop {
1018 ensure!(
1019 !cancelled(),
1020 "operation cancelled while waiting for SSH admission"
1021 );
1022 if *in_flight < self.limit {
1023 break;
1024 }
1025 (in_flight, _) = self
1026 .released
1027 .wait_timeout(in_flight, Duration::from_millis(25))
1028 .unwrap_or_else(std::sync::PoisonError::into_inner);
1029 }
1030 *in_flight += 1;
1031 drop(in_flight);
1032 Ok(SshPermit {
1033 gate: Arc::clone(self),
1034 })
1035 }
1036}
1037
1038pub struct SshPermit {
1040 gate: Arc<DestinationGate>,
1041}
1042
1043impl std::fmt::Debug for SshPermit {
1044 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1045 formatter.write_str("SshPermit")
1046 }
1047}
1048
1049impl Drop for SshPermit {
1050 fn drop(&mut self) {
1051 let mut in_flight = self
1052 .gate
1053 .in_flight
1054 .lock()
1055 .unwrap_or_else(std::sync::PoisonError::into_inner);
1056 *in_flight = in_flight.saturating_sub(1);
1057 drop(in_flight);
1058 self.gate.released.notify_one();
1059 }
1060}
1061
1062pub struct SshAdmission;
1064
1065impl SshAdmission {
1066 pub fn acquire(destination: &str) -> SshPermit {
1069 Self::gate(destination).acquire()
1070 }
1071
1072 pub fn acquire_unless(destination: &str, cancelled: &dyn Fn() -> bool) -> Result<SshPermit> {
1074 Self::gate(destination).acquire_unless(cancelled)
1075 }
1076
1077 fn gate(destination: &str) -> Arc<DestinationGate> {
1078 static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
1079 let mut gates = GATES
1080 .get_or_init(|| Mutex::new(BTreeMap::new()))
1081 .lock()
1082 .unwrap_or_else(std::sync::PoisonError::into_inner);
1083 Arc::clone(
1084 gates
1085 .entry(destination.to_owned())
1086 .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
1087 )
1088 }
1089}
1090
1091#[cfg(unix)]
1098const DEFAULT_SESSIONS_PER_CONNECTION: usize = 8;
1099
1100pub const SESSIONS_PER_CONNECTION_ENV: &str = "MJ_SSH_SESSIONS_PER_CONNECTION";
1102
1103#[cfg(unix)]
1106const MASTER_CHECK_INTERVAL: Duration = Duration::from_secs(5);
1107
1108pub const SSH_MASTER_OPEN_TIMEOUT: Duration = Duration::from_secs(60);
1111
1112#[cfg(unix)]
1113fn sessions_per_connection() -> usize {
1114 static LIMIT: OnceLock<usize> = OnceLock::new();
1115 *LIMIT.get_or_init(|| {
1116 positive_env_limit(SESSIONS_PER_CONNECTION_ENV, DEFAULT_SESSIONS_PER_CONNECTION)
1117 })
1118}
1119
1120#[cfg(unix)]
1122struct Shard {
1123 leased: usize,
1124 verified_at: Option<Instant>,
1126 opening: Arc<Mutex<()>>,
1129}
1130
1131#[cfg(unix)]
1133struct SessionLedger {
1134 per_connection: usize,
1135 connections: Mutex<BTreeMap<String, Vec<Shard>>>,
1136}
1137
1138#[cfg(unix)]
1139impl SessionLedger {
1140 fn new(per_connection: usize) -> Arc<Self> {
1141 Arc::new(Self {
1142 per_connection: per_connection.max(1),
1143 connections: Mutex::new(BTreeMap::new()),
1144 })
1145 }
1146
1147 fn global() -> Arc<Self> {
1148 static LEDGER: OnceLock<Arc<SessionLedger>> = OnceLock::new();
1149 Arc::clone(LEDGER.get_or_init(|| Self::new(sessions_per_connection())))
1150 }
1151
1152 fn connections(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Vec<Shard>>> {
1153 self.connections
1154 .lock()
1155 .unwrap_or_else(std::sync::PoisonError::into_inner)
1156 }
1157
1158 fn reserve(&self, key: &str) -> (usize, Arc<Mutex<()>>) {
1161 let mut connections = self.connections();
1162 let shards = connections.entry(key.to_owned()).or_default();
1163 let index = match shards
1164 .iter()
1165 .position(|shard| shard.leased < self.per_connection)
1166 {
1167 Some(index) => index,
1168 None => {
1169 shards.push(Shard {
1170 leased: 0,
1171 verified_at: None,
1172 opening: Arc::new(Mutex::new(())),
1173 });
1174 shards.len() - 1
1175 }
1176 };
1177 shards[index].leased += 1;
1178 (index, Arc::clone(&shards[index].opening))
1179 }
1180
1181 fn lease(
1184 self: &Arc<Self>,
1185 ssh: &SshTarget,
1186 dir: &Path,
1187 executor: &dyn CommandExecutor,
1188 ) -> Result<SshSessionLease> {
1189 let key = connection_key(ssh);
1190 let (shard, opening) = self.reserve(&key);
1191 let slot = LeasedSlot {
1193 ledger: Arc::clone(self),
1194 key,
1195 shard,
1196 socket: dir.join(control_socket_name(ssh, shard)),
1197 };
1198 if slot.needs_check() {
1199 let _opening = loop {
1200 ensure!(
1201 !executor.cancellation_requested(),
1202 "operation cancelled while waiting for SSH master"
1203 );
1204 match opening.try_lock() {
1205 Ok(guard) => break guard,
1206 Err(std::sync::TryLockError::Poisoned(error)) => break error.into_inner(),
1207 Err(std::sync::TryLockError::WouldBlock) => {
1208 std::thread::sleep(Duration::from_millis(25));
1209 }
1210 }
1211 };
1212 if slot.needs_check() {
1215 ensure_master(ssh, &slot.socket, executor)?;
1216 slot.set_verified(Some(Instant::now()));
1217 }
1218 }
1219 Ok(SshSessionLease {
1220 slot: Some(slot),
1221 probe: false,
1222 })
1223 }
1224
1225 fn lease_probe(self: &Arc<Self>, ssh: &SshTarget, dir: &Path) -> SshSessionLease {
1234 let key = connection_key(ssh);
1235 let (shard, _) = self.reserve(&key);
1236 SshSessionLease {
1237 slot: Some(LeasedSlot {
1238 ledger: Arc::clone(self),
1239 key,
1240 shard,
1241 socket: dir.join(control_socket_name(ssh, shard)),
1242 }),
1243 probe: true,
1244 }
1245 }
1246}
1247
1248#[cfg(unix)]
1254fn ensure_master(ssh: &SshTarget, socket: &Path, executor: &dyn CommandExecutor) -> Result<()> {
1255 let _opening = lock_master_opening_unless(socket, &|| executor.cancellation_requested())?;
1263 if master_running(ssh, socket, executor)? {
1264 return Ok(());
1265 }
1266 match fs::remove_file(socket) {
1270 Ok(()) => tracing::debug!(
1271 socket = %socket.display(),
1272 "removed a stale SSH control socket"
1273 ),
1274 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1275 Err(error) => {
1276 return Err(error)
1277 .with_context(|| format!("remove stale SSH control socket {}", socket.display()));
1278 }
1279 }
1280 let opened = executor.execute(&master_open_command(ssh, socket))?;
1281 if master_running(ssh, socket, executor)? {
1282 tracing::info!(
1283 destination = ssh.destination.as_str(),
1284 socket = %socket.display(),
1285 "opened a shared SSH connection"
1286 );
1287 return Ok(());
1288 }
1289 let stderr = String::from_utf8_lossy(&opened.stderr);
1290 let detail = match stderr.trim() {
1291 "" => format!("ssh exited with status {}", opened.status),
1292 stderr => stderr.to_owned(),
1293 };
1294 bail!(
1295 "could not open a shared SSH connection to {}: {detail}",
1296 ssh.destination
1297 )
1298}
1299
1300#[cfg(all(unix, test))]
1305fn lock_master_opening(socket: &Path) -> Result<fs::File> {
1306 lock_master_opening_unless(socket, &|| false)
1307}
1308
1309#[cfg(unix)]
1310fn lock_master_opening_unless(socket: &Path, cancelled: &dyn Fn() -> bool) -> Result<fs::File> {
1311 let mut path = socket.as_os_str().to_owned();
1312 path.push(".lock");
1313 let path = PathBuf::from(path);
1314 loop {
1315 ensure!(
1316 !cancelled(),
1317 "operation cancelled while waiting for SSH master lock"
1318 );
1319 let file = fs::OpenOptions::new()
1320 .create(true)
1321 .truncate(false)
1322 .write(true)
1323 .open(&path)
1324 .with_context(|| format!("open SSH master lock {}", path.display()))?;
1325 match file.try_lock() {
1326 Ok(()) => {}
1327 Err(std::fs::TryLockError::WouldBlock) => {
1328 std::thread::sleep(Duration::from_millis(25));
1329 continue;
1330 }
1331 Err(std::fs::TryLockError::Error(error)) => {
1332 return Err(error)
1333 .with_context(|| format!("lock SSH master lock {}", path.display()));
1334 }
1335 }
1336 if is_file_at(&file, &path) {
1340 return Ok(file);
1341 }
1342 }
1343}
1344
1345#[cfg(unix)]
1354fn remove_stale_master_locks_in(dir: &Path) {
1355 let entries = match fs::read_dir(dir) {
1356 Ok(entries) => entries,
1357 Err(error) => {
1358 tracing::debug!(directory = %dir.display(), %error, "cannot list SSH master locks");
1359 return;
1360 }
1361 };
1362 for entry in entries.flatten() {
1363 let lock = entry.path();
1364 let Some(socket) = lock
1365 .file_name()
1366 .and_then(std::ffi::OsStr::to_str)
1367 .and_then(|name| name.strip_suffix(".lock"))
1368 .map(|name| dir.join(name))
1369 else {
1370 continue;
1371 };
1372 if fs::symlink_metadata(&socket).is_ok() {
1373 continue;
1374 }
1375 let Ok(file) = fs::OpenOptions::new().write(true).open(&lock) else {
1376 continue;
1377 };
1378 if file.try_lock().is_err() {
1380 continue;
1381 }
1382 if fs::symlink_metadata(&socket).is_ok() || !is_file_at(&file, &lock) {
1383 continue;
1384 }
1385 match fs::remove_file(&lock) {
1386 Ok(()) => tracing::debug!(lock = %lock.display(), "removed a stale SSH master lock"),
1387 Err(error) => {
1388 tracing::debug!(lock = %lock.display(), %error, "cannot remove a stale SSH master lock")
1389 }
1390 }
1391 }
1392}
1393
1394#[cfg(unix)]
1396fn is_file_at(file: &fs::File, path: &Path) -> bool {
1397 use std::os::unix::fs::MetadataExt;
1398 match (file.metadata(), fs::metadata(path)) {
1399 (Ok(open), Ok(named)) => open.dev() == named.dev() && open.ino() == named.ino(),
1400 _ => false,
1401 }
1402}
1403
1404#[cfg(unix)]
1405fn master_running(ssh: &SshTarget, socket: &Path, executor: &dyn CommandExecutor) -> Result<bool> {
1406 Ok(executor.execute(&master_check_command(ssh, socket))?.status == 0)
1407}
1408
1409#[cfg(unix)]
1412fn master_check_command(ssh: &SshTarget, socket: &Path) -> CommandSpec {
1413 let mut args = ssh.ssh_args.clone();
1414 args.extend([
1415 "-o".to_owned(),
1416 format!("ControlPath={}", socket.display()),
1417 "-O".to_owned(),
1418 "check".to_owned(),
1419 ssh.destination.clone(),
1420 ]);
1421 CommandSpec::new("ssh", args).purpose("check a shared SSH connection")
1422}
1423
1424#[cfg(unix)]
1432fn master_open_command(ssh: &SshTarget, socket: &Path) -> CommandSpec {
1433 let mut args = ssh.ssh_args.clone();
1434 args.extend([
1435 "-o".to_owned(),
1436 "BatchMode=yes".to_owned(),
1437 "-o".to_owned(),
1438 "ConnectTimeout=10".to_owned(),
1439 "-f".to_owned(),
1440 "-N".to_owned(),
1441 "-o".to_owned(),
1442 "ControlMaster=yes".to_owned(),
1443 "-o".to_owned(),
1444 format!("ControlPath={}", socket.display()),
1445 "-o".to_owned(),
1446 format!("ControlPersist={CONTROL_PERSIST}"),
1447 ssh.destination.clone(),
1448 ]);
1449 CommandSpec::new("ssh", args)
1450 .ssh_destination(ssh.destination.clone())
1451 .purpose("open a shared SSH connection")
1452}
1453
1454#[cfg(unix)]
1456struct LeasedSlot {
1457 ledger: Arc<SessionLedger>,
1458 key: String,
1459 shard: usize,
1460 socket: PathBuf,
1461}
1462
1463#[cfg(unix)]
1464impl LeasedSlot {
1465 fn needs_check(&self) -> bool {
1466 let connections = self.ledger.connections();
1467 connections
1468 .get(&self.key)
1469 .and_then(|shards| shards.get(self.shard))
1470 .is_none_or(|shard| {
1471 shard
1472 .verified_at
1473 .is_none_or(|verified| verified.elapsed() >= MASTER_CHECK_INTERVAL)
1474 })
1475 }
1476
1477 fn set_verified(&self, verified_at: Option<Instant>) {
1478 let mut connections = self.ledger.connections();
1479 if let Some(shard) = connections
1480 .get_mut(&self.key)
1481 .and_then(|shards| shards.get_mut(self.shard))
1482 {
1483 shard.verified_at = verified_at;
1484 }
1485 }
1486}
1487
1488#[cfg(unix)]
1489impl Drop for LeasedSlot {
1490 fn drop(&mut self) {
1491 let mut connections = self.ledger.connections();
1492 if let Some(shard) = connections
1493 .get_mut(&self.key)
1494 .and_then(|shards| shards.get_mut(self.shard))
1495 {
1496 shard.leased = shard.leased.saturating_sub(1);
1497 }
1498 }
1499}
1500
1501pub struct SshSessionLease {
1509 #[cfg(unix)]
1510 slot: Option<LeasedSlot>,
1511 #[cfg(unix)]
1514 probe: bool,
1515}
1516
1517impl std::fmt::Debug for SshSessionLease {
1518 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1519 formatter
1520 .debug_struct("SshSessionLease")
1521 .field("control_path", &self.control_path())
1522 .finish()
1523 }
1524}
1525
1526impl SshSessionLease {
1527 fn unshared() -> Self {
1528 Self {
1529 #[cfg(unix)]
1530 slot: None,
1531 #[cfg(unix)]
1532 probe: false,
1533 }
1534 }
1535
1536 pub fn control_path(&self) -> Option<&Path> {
1539 #[cfg(unix)]
1540 return self.slot.as_ref().map(|slot| slot.socket.as_path());
1541 #[cfg(not(unix))]
1542 None
1543 }
1544
1545 pub fn invalidate(&self) {
1549 #[cfg(unix)]
1550 if let Some(slot) = &self.slot {
1551 slot.set_verified(None);
1552 }
1553 }
1554}
1555
1556pub struct SshSessions;
1563
1564impl SshSessions {
1565 pub fn lease(ssh: &SshTarget, executor: &dyn CommandExecutor) -> Result<SshSessionLease> {
1570 #[cfg(unix)]
1571 {
1572 if user_configures_sharing(&ssh.ssh_args) {
1573 return Ok(SshSessionLease::unshared());
1574 }
1575 let Some(dir) = control_socket_dir() else {
1576 return Ok(SshSessionLease::unshared());
1577 };
1578 SessionLedger::global().lease(ssh, &dir, executor)
1579 }
1580 #[cfg(not(unix))]
1581 {
1582 let _ = (ssh, executor);
1583 Ok(SshSessionLease::unshared())
1584 }
1585 }
1586
1587 pub fn remove_stale_master_locks() {
1590 #[cfg(unix)]
1591 if let Some(dir) = control_socket_dir() {
1592 remove_stale_master_locks_in(&dir);
1593 }
1594 }
1595
1596 pub fn lease_probe(ssh: &SshTarget) -> SshSessionLease {
1604 #[cfg(unix)]
1605 {
1606 if user_configures_sharing(&ssh.ssh_args) {
1607 return SshSessionLease::unshared();
1608 }
1609 let Some(dir) = control_socket_dir() else {
1610 return SshSessionLease::unshared();
1611 };
1612 SessionLedger::global().lease_probe(ssh, &dir)
1613 }
1614 #[cfg(not(unix))]
1615 {
1616 let _ = ssh;
1617 SshSessionLease::unshared()
1618 }
1619 }
1620}
1621
1622pub fn push_session_args(args: &mut Vec<String>, lease: &SshSessionLease) {
1631 if let Some(socket) = lease.control_path() {
1632 args.extend([
1633 "-o".to_owned(),
1634 "ControlMaster=no".to_owned(),
1635 "-o".to_owned(),
1636 format!("ControlPath={}", socket.display()),
1637 ]);
1638 #[cfg(unix)]
1639 let probe = lease.probe;
1640 #[cfg(not(unix))]
1641 let probe = false;
1642 if !probe {
1643 args.extend(["-o".to_owned(), "ProxyCommand=false".to_owned()]);
1644 }
1645 }
1646}
1647
1648pub fn session_command_args(
1661 program: &str,
1662 args: &[String],
1663 ssh: &SshTarget,
1664 lease: &SshSessionLease,
1665) -> Vec<String> {
1666 let mut session = Vec::with_capacity(args.len() + 6);
1667 push_session_args(&mut session, lease);
1668 if session.is_empty() {
1669 return args.to_vec();
1670 }
1671 if program == "ssh" && args.starts_with(&ssh.ssh_args) {
1672 let (user, rest) = args.split_at(ssh.ssh_args.len());
1673 let mut user = user.iter();
1674 while let Some(argument) = user.next() {
1675 match argument.strip_prefix("-J") {
1676 Some("") => match user.next() {
1677 Some(jump) => session.extend(["-o".to_owned(), format!("ProxyJump={jump}")]),
1678 None => session.push(argument.clone()),
1679 },
1680 Some(jump) => session.extend(["-o".to_owned(), format!("ProxyJump={jump}")]),
1681 None => session.push(argument.clone()),
1682 }
1683 }
1684 session.extend(rest.iter().cloned());
1685 } else {
1686 session.extend(args.iter().cloned());
1687 }
1688 session
1689}
1690
1691pub const SSH_RETRY_ATTEMPTS: usize = 3;
1693
1694const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
1698
1699static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
1702
1703#[doc(hidden)]
1706pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
1707 SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
1708 delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
1709 Ordering::Relaxed,
1710 );
1711}
1712
1713pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
1719 let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
1720 if override_ms != u64::MAX {
1721 return Duration::from_millis(override_ms);
1722 }
1723 let (low, high) = SSH_RETRY_BACKOFF_MS
1724 .get(attempts_made.saturating_sub(1))
1725 .copied()
1726 .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
1727 let mut bytes = [0_u8; 8];
1728 let spread = if getrandom::fill(&mut bytes).is_ok() {
1730 u64::from_le_bytes(bytes) % (high - low + 1)
1731 } else {
1732 0
1733 };
1734 Duration::from_millis(low + spread)
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739 use super::*;
1740
1741 #[test]
1742 fn remote_podman_exec_keeps_the_container_configured_user() {
1743 let command = ssh_command_owned(
1744 &plain_target("podman-root-test"),
1745 vec![
1746 "podman".into(),
1747 "exec".into(),
1748 "--detach".into(),
1749 "session".into(),
1750 "true".into(),
1751 ],
1752 );
1753 assert!(
1754 command
1755 .args
1756 .last()
1757 .unwrap()
1758 .contains("'podman' 'exec' '--detach' 'session' 'true'"),
1759 "{:?}",
1760 command.args
1761 );
1762 }
1763
1764 #[cfg(unix)]
1769 #[test]
1771 fn stale_master_locks_are_removed_but_live_or_held_ones_stay() {
1772 let dir = tempfile::tempdir().unwrap();
1773 let stale = dir.path().join("aaaaaaaaaaaaaaaa-0.lock");
1774 fs::write(&stale, b"").unwrap();
1775 fs::write(dir.path().join("bbbbbbbbbbbbbbbb-0"), b"").unwrap();
1776 let live = dir.path().join("bbbbbbbbbbbbbbbb-0.lock");
1777 fs::write(&live, b"").unwrap();
1778 let held = dir.path().join("cccccccccccccccc-0.lock");
1779 let holder = lock_master_opening(&dir.path().join("cccccccccccccccc-0")).unwrap();
1780
1781 remove_stale_master_locks_in(dir.path());
1782 assert!(!stale.exists(), "a lock whose master is gone is removed");
1783 assert!(live.exists(), "a lock beside a live socket stays");
1784 assert!(held.exists(), "a lock another opener holds stays");
1785
1786 drop(holder);
1787 for _ in 0..200 {
1792 remove_stale_master_locks_in(dir.path());
1793 if !held.exists() {
1794 break;
1795 }
1796 std::thread::sleep(Duration::from_millis(10));
1797 }
1798 assert!(!held.exists(), "a lock nobody holds any more is removed");
1799 }
1800
1801 const BORROW_PARENT: &str = "0123456789abcdef0123456789abcdef";
1802 const BORROW_CHILD: &str = "fedcba9876543210fedcba9876543210";
1803
1804 fn borrowed_podman(owner: &str) -> TargetLocator {
1805 TargetLocator::LocalPodman {
1806 container_id: crate::targets::resource_name(owner).unwrap(),
1807 workspace_storage: PodmanWorkspaceLocator::default(),
1808 borrowed_from: Some(owner.to_owned()),
1809 }
1810 }
1811
1812 #[test]
1814 fn verify_locator_accepts_a_container_borrowed_from_its_owner() {
1815 verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_CHILD)
1816 .expect("a child may borrow its parent's container");
1817 }
1818
1819 #[test]
1820 fn verify_locator_rejects_a_container_borrowed_from_the_checking_session() {
1821 let error = verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_PARENT)
1822 .expect_err("a session cannot borrow from itself");
1823 assert!(
1824 format!("{error:#}").contains("cannot be owned by the borrowing session"),
1825 "unexpected error: {error:#}"
1826 );
1827 }
1828
1829 #[test]
1830 fn verify_locator_rejects_a_borrowed_container_naming_another_session() {
1831 let locator = TargetLocator::LocalPodman {
1832 container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
1833 workspace_storage: PodmanWorkspaceLocator::default(),
1834 borrowed_from: Some(BORROW_PARENT.to_owned()),
1835 };
1836 let error = verify_locator(&locator, BORROW_CHILD)
1837 .expect_err("the container must belong to the recorded owner");
1838 assert!(
1839 format!("{error:#}").contains("borrowed container locator"),
1840 "unexpected error: {error:#}"
1841 );
1842 }
1843
1844 #[test]
1845 fn worker_root_of_a_borrowed_container_is_the_childs_own_directory() {
1846 assert_eq!(
1847 crate::targets::worker_root(&borrowed_podman(BORROW_PARENT), BORROW_CHILD).unwrap(),
1848 format!("/var/lib/hel/workers/{BORROW_CHILD}")
1849 );
1850 }
1851
1852 use std::sync::atomic::{AtomicUsize, Ordering};
1853
1854 #[cfg(unix)]
1856 #[derive(Default)]
1857 struct RecordingExecutor {
1858 seen: std::cell::RefCell<Vec<CommandSpec>>,
1859 }
1860
1861 #[cfg(unix)]
1862 impl CommandExecutor for RecordingExecutor {
1863 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1864 self.seen.borrow_mut().push(command.clone());
1865 Ok(CommandOutput {
1866 status: 0,
1867 stdout: Vec::new(),
1868 stderr: Vec::new(),
1869 })
1870 }
1871 }
1872
1873 #[cfg(unix)]
1874 fn sharing_socket_dir() -> tempfile::TempDir {
1875 tempfile::tempdir_in("/tmp").expect("short control socket directory")
1877 }
1878
1879 #[cfg(unix)]
1883 fn spawned_args(
1884 command: &CommandSpec,
1885 dir: Option<&Path>,
1886 masters: &FakeMasters,
1887 ) -> Vec<String> {
1888 set_ssh_connection_sharing_for_test(Some(match dir {
1889 Some(dir) => SshSharingForTest::Directory(dir.to_path_buf()),
1890 None => SshSharingForTest::Disabled,
1891 }));
1892 let session = command.open_ssh_session(masters);
1893 set_ssh_connection_sharing_for_test(None);
1894 session.expect("session").command().args.clone()
1895 }
1896
1897 #[test]
1901 #[cfg(unix)]
1902 fn session_options_lead_the_spawned_command() {
1903 let _guard = SHARING_TEST_LOCK
1904 .lock()
1905 .unwrap_or_else(std::sync::PoisonError::into_inner);
1906 let socket_dir = sharing_socket_dir();
1907 let ssh = SshTarget {
1908 destination: "session-options-host".to_owned(),
1909 ssh_args: vec![
1910 "-p".to_owned(),
1911 "2222".to_owned(),
1912 "-o".to_owned(),
1913 "ProxyCommand=nc %h %p".to_owned(),
1914 ],
1915 };
1916 let command = ssh_command(&ssh, ["true"]);
1917 assert_eq!(
1918 command.args,
1919 [
1920 "-p",
1921 "2222",
1922 "-o",
1923 "ProxyCommand=nc %h %p",
1924 "session-options-host",
1925 "'true'"
1926 ],
1927 "stored arguments never contain sharing options"
1928 );
1929 assert_eq!(command.ssh_session.as_ref(), Some(&ssh));
1930 assert_eq!(
1931 command.ssh_destination.as_deref(),
1932 Some("session-options-host")
1933 );
1934
1935 let masters = FakeMasters::default();
1936 let args = spawned_args(&command, Some(socket_dir.path()), &masters);
1937 let socket = socket_dir.path().join(control_socket_name(&ssh, 0));
1938 assert_eq!(
1939 args,
1940 [
1941 "-o".to_owned(),
1942 "ControlMaster=no".to_owned(),
1943 "-o".to_owned(),
1944 format!("ControlPath={}", socket.display()),
1945 "-o".to_owned(),
1946 "ProxyCommand=false".to_owned(),
1947 "-p".to_owned(),
1948 "2222".to_owned(),
1949 "-o".to_owned(),
1950 "ProxyCommand=nc %h %p".to_owned(),
1951 "session-options-host".to_owned(),
1952 "'true'".to_owned(),
1953 ]
1954 );
1955 assert_eq!(masters.openers(), 1);
1956 assert_eq!(
1957 std::os::unix::fs::MetadataExt::mode(
1958 &fs::metadata(socket_dir.path()).expect("socket directory")
1959 ) & 0o777,
1960 0o700
1961 );
1962 }
1963
1964 #[test]
1967 #[cfg(unix)]
1968 fn control_sockets_live_in_a_directory_per_instance() {
1969 let runtime = Some(std::ffi::OsString::from("/run/user/1000"));
1970 assert_eq!(
1971 default_control_dir(runtime.clone(), &control_dir_identity(Some("hel2"), None)),
1972 PathBuf::from("/run/user/1000/mjolnir/hel2")
1973 );
1974 assert_eq!(
1975 default_control_dir(runtime, &control_dir_identity(None, None)),
1976 PathBuf::from("/run/user/1000/mjolnir/default")
1977 );
1978 }
1979
1980 #[test]
1986 #[cfg(unix)]
1987 fn a_data_directory_override_gets_its_own_socket_directory() {
1988 let runtime = Some(std::ffi::OsString::from("/run/user/1000"));
1989 let lab = Path::new("/tmp/lab-a/data");
1990 let other_lab = Path::new("/tmp/lab-b/data");
1991 let lab_dir = default_control_dir(runtime.clone(), &control_dir_identity(None, Some(lab)));
1992 assert_ne!(lab_dir, PathBuf::from("/run/user/1000/mjolnir/default"));
1993 assert_eq!(
1994 lab_dir,
1995 PathBuf::from("/run/user/1000/mjolnir")
1996 .join(crate::config::instance_identity_for(None, lab))
1997 );
1998 assert_ne!(
1999 lab_dir,
2000 default_control_dir(
2001 runtime.clone(),
2002 &control_dir_identity(None, Some(other_lab))
2003 )
2004 );
2005 assert_eq!(
2008 default_control_dir(runtime, &control_dir_identity(Some("hel2"), Some(lab))),
2009 PathBuf::from("/run/user/1000/mjolnir/hel2")
2010 );
2011 }
2012
2013 #[test]
2016 #[cfg(unix)]
2017 fn socket_names_identify_the_connection_and_the_shard() {
2018 let plain = SshTarget {
2019 destination: "host".to_owned(),
2020 ssh_args: Vec::new(),
2021 };
2022 let other_port = SshTarget {
2023 destination: "host".to_owned(),
2024 ssh_args: vec!["-p".to_owned(), "2222".to_owned()],
2025 };
2026 let first = control_socket_name(&plain, 0);
2027 let second = control_socket_name(&plain, 1);
2028 assert_eq!(first.len(), CONNECTION_HASH_HEX + 2, "{first}");
2029 assert!(first.ends_with("-0") && second.ends_with("-1"));
2030 assert_eq!(first[..CONNECTION_HASH_HEX], second[..CONNECTION_HASH_HEX]);
2031 assert_ne!(
2032 first[..CONNECTION_HASH_HEX],
2033 control_socket_name(&other_port, 0)[..CONNECTION_HASH_HEX]
2034 );
2035 }
2036
2037 #[test]
2041 #[cfg(unix)]
2042 fn fail_fast_commands_reuse_a_master_without_becoming_one() {
2043 let _guard = SHARING_TEST_LOCK
2044 .lock()
2045 .unwrap_or_else(std::sync::PoisonError::into_inner);
2046 let socket_dir = sharing_socket_dir();
2047 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2048 socket_dir.path().to_path_buf(),
2049 )));
2050 let ssh = SshTarget {
2051 destination: "host".to_owned(),
2052 ssh_args: Vec::new(),
2053 };
2054 let masters = FakeMasters::default();
2055 let validation = spawned_args(
2056 &ssh_validation_command(&ssh, vec!["true".to_owned()], "test"),
2057 Some(socket_dir.path()),
2058 &masters,
2059 );
2060 assert_eq!(
2061 masters.commands(),
2062 0,
2063 "a probe never checks or opens a master"
2064 );
2065 assert!(!validation.contains(&"ProxyCommand=false".to_owned()));
2066 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2067 socket_dir.path().to_path_buf(),
2068 )));
2069 let executor = RecordingExecutor::default();
2070 crate::path_completion::ssh_completions(
2071 &ssh,
2072 "/srv/pr",
2073 crate::path_completion::CompletionKind::Directories,
2074 &executor,
2075 )
2076 .expect("completion runs");
2077 let completion = executor.seen.borrow()[0].args.clone();
2078 set_ssh_connection_sharing_for_test(None);
2079
2080 let control_path = format!(
2081 "ControlPath={}/{}",
2082 socket_dir.path().display(),
2083 control_socket_name(&ssh, 0)
2084 );
2085 for args in [&validation, &completion] {
2086 assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
2087 assert!(args.contains(&control_path), "{args:?}");
2088 assert!(
2089 !args.iter().any(|arg| arg.starts_with("ControlPersist")),
2090 "a fail-fast command must not set how long a master lingers: {args:?}"
2091 );
2092 let master = args
2093 .iter()
2094 .position(|arg| arg == "ControlMaster=no")
2095 .expect("sharing options");
2096 assert!(
2097 args.contains(&"ServerAliveCountMax=1".to_owned()),
2098 "its own keepalive: {args:?}"
2099 );
2100 assert!(
2101 master
2102 < args
2103 .iter()
2104 .position(|arg| arg == "host")
2105 .expect("destination"),
2106 "{args:?}"
2107 );
2108 }
2109 }
2110
2111 #[test]
2116 #[cfg(unix)]
2117 fn connectivity_probe_joins_a_master_without_becoming_one() {
2118 let _guard = SHARING_TEST_LOCK
2119 .lock()
2120 .unwrap_or_else(std::sync::PoisonError::into_inner);
2121 let socket_dir = sharing_socket_dir();
2122 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2123 socket_dir.path().to_path_buf(),
2124 )));
2125 let ssh = SshTarget {
2126 destination: "host".to_owned(),
2127 ssh_args: Vec::new(),
2128 };
2129 let masters = FakeMasters::default();
2130 let args = spawned_args(
2131 &ssh_connectivity_probe(&ssh),
2132 Some(socket_dir.path()),
2133 &masters,
2134 );
2135
2136 assert_eq!(masters.commands(), 0, "a probe never opens a master");
2137 assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
2138 assert!(
2139 args.contains(&format!(
2140 "ControlPath={}/{}",
2141 socket_dir.path().display(),
2142 control_socket_name(&ssh, 0)
2143 )),
2144 "the probe must still join an existing master: {args:?}"
2145 );
2146 assert!(
2147 !args.iter().any(|arg| arg.starts_with("ControlPersist")),
2148 "a doctor probe must not set how long a master lingers: {args:?}"
2149 );
2150 let master = args
2151 .iter()
2152 .position(|arg| arg == "ControlMaster=no")
2153 .expect("sharing options");
2154 let strict = args
2155 .iter()
2156 .position(|arg| arg == "StrictHostKeyChecking=yes")
2157 .expect("its own host key policy");
2158 assert!(master < strict, "{args:?}");
2159 }
2160
2161 #[test]
2162 #[cfg(unix)]
2163 fn a_control_path_that_cannot_fit_a_socket_address_is_skipped() {
2164 let _guard = SHARING_TEST_LOCK
2165 .lock()
2166 .unwrap_or_else(std::sync::PoisonError::into_inner);
2167 let root = tempfile::tempdir().expect("temp dir");
2168 let long = root.path().join("a".repeat(MAX_CONTROL_PATH));
2169 let ssh = SshTarget {
2170 destination: "long-path-host".to_owned(),
2171 ssh_args: Vec::new(),
2172 };
2173 let masters = FakeMasters::default();
2174 let args = spawned_args(&ssh_command(&ssh, ["true"]), Some(&long), &masters);
2175 assert_eq!(args, ["long-path-host", "'true'"]);
2176 assert_eq!(masters.commands(), 0);
2177 assert!(!long.exists(), "an unusable directory must not be created");
2178 }
2179
2180 #[test]
2184 #[cfg(unix)]
2185 fn a_leased_session_runs_through_an_opened_master_on_a_real_host() {
2186 let _guard = SHARING_TEST_LOCK
2187 .lock()
2188 .unwrap_or_else(std::sync::PoisonError::into_inner);
2189 let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
2190 return;
2191 };
2192 let host = host.to_string_lossy().into_owned();
2193 let socket_dir = sharing_socket_dir();
2194 let ssh = SshTarget {
2195 destination: host.clone(),
2196 ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
2197 };
2198 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2199 socket_dir.path().to_path_buf(),
2200 )));
2201 let output = ProcessExecutor.execute(&ssh_command(&ssh, ["true"]));
2202 set_ssh_connection_sharing_for_test(None);
2203 let socket = socket_dir.path().join(control_socket_name(&ssh, 0));
2204 let check = ProcessExecutor
2205 .execute(&master_check_command(&ssh, &socket))
2206 .expect("ssh -O check must run");
2207 let exit = std::process::Command::new("ssh")
2208 .args([
2209 "-O",
2210 "exit",
2211 "-o",
2212 &format!("ControlPath={}", socket.display()),
2213 &host,
2214 ])
2215 .output();
2216 let output = output.expect("ssh must run");
2217 assert_eq!(
2218 output.status,
2219 0,
2220 "ssh {host} true failed: {}",
2221 String::from_utf8_lossy(&output.stderr)
2222 );
2223 assert_eq!(
2224 check.status,
2225 0,
2226 "no master is running: {}",
2227 String::from_utf8_lossy(&check.stderr)
2228 );
2229 drop(exit);
2230 }
2231
2232 #[test]
2237 #[cfg(unix)]
2238 fn sessions_shard_across_masters_on_a_real_host() {
2239 let _guard = SHARING_TEST_LOCK
2240 .lock()
2241 .unwrap_or_else(std::sync::PoisonError::into_inner);
2242 let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
2243 return;
2244 };
2245 let host = host.to_string_lossy().into_owned();
2246 let socket_dir = sharing_socket_dir();
2247 let ssh = SshTarget {
2248 destination: host.clone(),
2249 ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
2250 };
2251 let ledger = SessionLedger::new(3);
2252 let leases: Vec<SshSessionLease> = (0..7)
2253 .map(|_| {
2254 ledger
2255 .lease(&ssh, socket_dir.path(), &ProcessExecutor)
2256 .expect("lease a session on a real host")
2257 })
2258 .collect();
2259 let sockets: Vec<PathBuf> = (0..4)
2260 .map(|shard| socket_dir.path().join(control_socket_name(&ssh, shard)))
2261 .collect();
2262 let exit_all = || {
2263 for socket in &sockets {
2264 let _ = std::process::Command::new("ssh")
2265 .args([
2266 "-O",
2267 "exit",
2268 "-o",
2269 &format!("ControlPath={}", socket.display()),
2270 &host,
2271 ])
2272 .output();
2273 }
2274 };
2275
2276 let base = ssh_command(&ssh, ["sleep", "2"]);
2278 let children: Vec<std::io::Result<std::process::Output>> = std::thread::scope(|scope| {
2279 let handles: Vec<_> = leases
2280 .iter()
2281 .map(|lease| {
2282 let args = session_command_args(&base.program, &base.args, &ssh, lease);
2283 scope.spawn(move || {
2284 std::process::Command::new("ssh")
2285 .args(args)
2286 .stdin(std::process::Stdio::null())
2287 .output()
2288 })
2289 })
2290 .collect();
2291 handles
2292 .into_iter()
2293 .map(|handle| handle.join().expect("session thread"))
2294 .collect()
2295 });
2296 let running: Vec<bool> = sockets
2297 .iter()
2298 .map(|socket| {
2299 ProcessExecutor
2300 .execute(&master_check_command(&ssh, socket))
2301 .map(|output| output.status == 0)
2302 .unwrap_or(false)
2303 })
2304 .collect();
2305 let orphan = std::process::Command::new("ssh")
2306 .args(session_command_args(
2307 &base.program,
2308 &base.args,
2309 &ssh,
2310 &SshSessionLease {
2311 slot: Some(LeasedSlot {
2312 ledger: Arc::clone(&ledger),
2313 key: connection_key(&ssh),
2314 shard: 9,
2315 socket: socket_dir.path().join(control_socket_name(&ssh, 9)),
2316 }),
2317 probe: false,
2318 },
2319 ))
2320 .stdin(std::process::Stdio::null())
2321 .output();
2322 drop(leases);
2323 exit_all();
2324
2325 let shards: Vec<usize> = leases_per_shard(&ledger, &ssh);
2326 assert_eq!(shards, [0, 0, 0], "every slot is freed on drop");
2327 for (index, output) in children.iter().enumerate() {
2328 let output = output.as_ref().expect("ssh must run");
2329 assert_eq!(
2330 output.status.code(),
2331 Some(0),
2332 "session {index} failed: {}",
2333 String::from_utf8_lossy(&output.stderr)
2334 );
2335 }
2336 assert_eq!(
2337 running,
2338 [true, true, true, false],
2339 "seven sessions at three per master"
2340 );
2341 let orphan = orphan.expect("ssh must run");
2342 assert_eq!(
2343 orphan.status.code(),
2344 Some(255),
2345 "a guarded session with no master must not connect: {}",
2346 String::from_utf8_lossy(&orphan.stderr)
2347 );
2348 }
2349
2350 #[cfg(unix)]
2351 fn leases_per_shard(ledger: &SessionLedger, ssh: &SshTarget) -> Vec<usize> {
2352 ledger
2353 .connections()
2354 .get(&connection_key(ssh))
2355 .map(|shards| shards.iter().map(|shard| shard.leased).collect())
2356 .unwrap_or_default()
2357 }
2358
2359 #[cfg(unix)]
2363 #[derive(Default)]
2364 struct FakeMasters {
2365 running: std::cell::RefCell<BTreeSet<String>>,
2366 refuse_open: std::cell::Cell<Option<&'static str>>,
2367 socket_existed_at_open: std::cell::RefCell<Vec<bool>>,
2368 seen: std::cell::RefCell<Vec<CommandSpec>>,
2369 }
2370
2371 #[cfg(unix)]
2372 impl FakeMasters {
2373 fn socket(command: &CommandSpec) -> String {
2374 command
2375 .args
2376 .iter()
2377 .find_map(|arg| arg.strip_prefix("ControlPath="))
2378 .expect("every master command names its socket")
2379 .to_owned()
2380 }
2381
2382 fn kill(&self, socket: &Path) {
2383 self.running
2384 .borrow_mut()
2385 .remove(&socket.display().to_string());
2386 }
2387
2388 fn commands(&self) -> usize {
2389 self.seen.borrow().len()
2390 }
2391
2392 fn openers(&self) -> usize {
2393 self.seen
2394 .borrow()
2395 .iter()
2396 .filter(|command| command.args.contains(&"ControlMaster=yes".to_owned()))
2397 .count()
2398 }
2399 }
2400
2401 #[cfg(unix)]
2402 impl CommandExecutor for FakeMasters {
2403 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2404 self.seen.borrow_mut().push(command.clone());
2405 assert_eq!(command.program, "ssh");
2406 let socket = Self::socket(command);
2407 let (status, stderr) = if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2408 if self.running.borrow().contains(&socket) {
2409 (0, "")
2410 } else {
2411 (255, "Control socket connect: No such file or directory")
2412 }
2413 } else if command.args.contains(&"ControlMaster=yes".to_owned()) {
2414 self.socket_existed_at_open
2415 .borrow_mut()
2416 .push(Path::new(&socket).exists());
2417 match self.refuse_open.get() {
2418 Some(stderr) => (255, stderr),
2419 None => {
2420 self.running.borrow_mut().insert(socket);
2421 (0, "")
2422 }
2423 }
2424 } else {
2425 panic!("the ledger ran an unexpected command: {command:?}");
2426 };
2427 Ok(CommandOutput {
2428 status,
2429 stdout: Vec::new(),
2430 stderr: stderr.as_bytes().to_vec(),
2431 })
2432 }
2433 }
2434
2435 #[cfg(unix)]
2436 fn shard_of(lease: &SshSessionLease) -> String {
2437 let path = lease.control_path().expect("a shared lease has a socket");
2438 let name = path.file_name().unwrap().to_string_lossy().into_owned();
2439 name.rsplit('-').next().unwrap().to_owned()
2440 }
2441
2442 fn plain_target(destination: &str) -> SshTarget {
2443 SshTarget {
2444 destination: destination.to_owned(),
2445 ssh_args: Vec::new(),
2446 }
2447 }
2448
2449 #[test]
2454 #[cfg(unix)]
2455 fn scp_translates_the_ssh_port_option_and_is_tagged_with_its_destination() {
2456 let _guard = SHARING_TEST_LOCK
2457 .lock()
2458 .unwrap_or_else(std::sync::PoisonError::into_inner);
2459 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
2460 let ssh = SshTarget {
2461 destination: "build@10.0.0.1".into(),
2462 ssh_args: vec!["-p".into(), "2222".into()],
2463 };
2464
2465 let upload = scp_upload(&ssh, Path::new("/tmp/local"), "remote/path", true);
2466 let download = scp_download(&ssh, "remote/archive.zip", "/tmp/local.zip");
2467 set_ssh_connection_sharing_for_test(None);
2468
2469 assert_eq!(
2470 upload.args,
2471 [
2472 "-P",
2473 "2222",
2474 "-r",
2475 "/tmp/local",
2476 "build@10.0.0.1:remote/path"
2477 ]
2478 );
2479 assert_eq!(
2480 download.args,
2481 [
2482 "-P",
2483 "2222",
2484 "build@10.0.0.1:remote/archive.zip",
2485 "/tmp/local.zip"
2486 ]
2487 );
2488 for command in [upload, download] {
2489 assert_eq!(command.program, "scp");
2490 assert_eq!(command.ssh_destination.as_deref(), Some("build@10.0.0.1"));
2491 }
2492 }
2493
2494 #[test]
2495 #[cfg(unix)]
2496 fn leases_fill_the_lowest_shard_and_open_another_at_the_cap() {
2497 let dir = sharing_socket_dir();
2498 let ledger = SessionLedger::new(2);
2499 let ssh = plain_target("host");
2500 let masters = FakeMasters::default();
2501
2502 let first = ledger.lease(&ssh, dir.path(), &masters).expect("first");
2503 assert_eq!(masters.commands(), 3);
2505 let second = ledger.lease(&ssh, dir.path(), &masters).expect("second");
2506 assert_eq!(
2507 masters.commands(),
2508 3,
2509 "a master verified moments ago is not checked again"
2510 );
2511 let third = ledger.lease(&ssh, dir.path(), &masters).expect("third");
2512 assert_eq!(
2513 [&first, &second, &third].map(shard_of),
2514 ["0", "0", "1"].map(str::to_owned)
2515 );
2516 assert_eq!(masters.openers(), 2, "one master per shard");
2517 assert_eq!(
2518 third.control_path().unwrap(),
2519 dir.path().join(control_socket_name(&ssh, 1))
2520 );
2521
2522 drop(first);
2523 let fourth = ledger.lease(&ssh, dir.path(), &masters).expect("fourth");
2524 assert_eq!(shard_of(&fourth), "0", "a freed slot is reused first");
2525 assert_eq!(masters.openers(), 2);
2526 }
2527
2528 #[test]
2534 #[cfg(unix)]
2535 fn probes_are_counted_on_the_shard_they_join_without_opening_it() {
2536 let dir = sharing_socket_dir();
2537 let ledger = SessionLedger::new(2);
2538 let ssh = plain_target("probe-host");
2539 let masters = FakeMasters::default();
2540
2541 let session = ledger.lease(&ssh, dir.path(), &masters).expect("session");
2542 let probe = ledger.lease_probe(&ssh, dir.path());
2543 assert_eq!(leases_per_shard(&ledger, &ssh), [2]);
2544 let second_probe = ledger.lease_probe(&ssh, dir.path());
2545 assert_eq!(
2546 [&session, &probe, &second_probe].map(shard_of),
2547 ["0", "0", "1"].map(str::to_owned)
2548 );
2549 assert_eq!(masters.openers(), 1, "a probe never opens a master");
2550
2551 let mut args = Vec::new();
2552 push_session_args(&mut args, &probe);
2553 assert!(
2554 !args.contains(&"ProxyCommand=false".to_owned()),
2555 "a probe may connect directly when its master is down: {args:?}"
2556 );
2557 drop(probe);
2558 drop(second_probe);
2559 assert_eq!(leases_per_shard(&ledger, &ssh), [1, 0]);
2560 let next = ledger.lease(&ssh, dir.path(), &masters).expect("next");
2561 assert_eq!(shard_of(&next), "0");
2562 }
2563
2564 #[test]
2565 #[cfg(unix)]
2566 fn separate_connections_are_counted_separately() {
2567 let dir = sharing_socket_dir();
2568 let ledger = SessionLedger::new(1);
2569 let masters = FakeMasters::default();
2570 let first = ledger
2571 .lease(&plain_target("one"), dir.path(), &masters)
2572 .expect("one");
2573 let second = ledger
2574 .lease(&plain_target("two"), dir.path(), &masters)
2575 .expect("two");
2576 assert_eq!(
2577 [&first, &second].map(shard_of),
2578 ["0", "0"].map(str::to_owned)
2579 );
2580 assert_ne!(first.control_path(), second.control_path());
2581 }
2582
2583 #[test]
2584 #[cfg(unix)]
2585 fn an_invalidated_lease_makes_the_next_lease_reopen_a_dead_master() {
2586 let dir = sharing_socket_dir();
2587 let ledger = SessionLedger::new(8);
2588 let ssh = plain_target("host");
2589 let masters = FakeMasters::default();
2590 let first = ledger.lease(&ssh, dir.path(), &masters).expect("first");
2591 masters.kill(first.control_path().unwrap());
2592
2593 drop(ledger.lease(&ssh, dir.path(), &masters).expect("trusted"));
2595 assert_eq!(masters.openers(), 1);
2596
2597 first.invalidate();
2598 let second = ledger.lease(&ssh, dir.path(), &masters).expect("reopened");
2599 assert_eq!(masters.openers(), 2);
2600 assert_eq!(first.control_path(), second.control_path());
2601 }
2602
2603 #[test]
2604 #[cfg(unix)]
2605 fn a_master_that_cannot_be_opened_is_an_error_naming_the_destination() {
2606 let dir = sharing_socket_dir();
2607 let ledger = SessionLedger::new(1);
2608 let ssh = plain_target("build@10.0.0.1");
2609 let masters = FakeMasters::default();
2610 masters
2611 .refuse_open
2612 .set(Some("Permission denied (publickey)."));
2613
2614 let error = ledger
2615 .lease(&ssh, dir.path(), &masters)
2616 .expect_err("no master means no session");
2617 let message = format!("{error:#}");
2618 assert!(message.contains("build@10.0.0.1"), "{message}");
2619 assert!(message.contains("Permission denied"), "{message}");
2620 assert_eq!(
2621 masters.openers(),
2622 1,
2623 "the opener is not retried by the ledger"
2624 );
2625
2626 masters.refuse_open.set(None);
2629 let lease = ledger.lease(&ssh, dir.path(), &masters).expect("opens");
2630 assert_eq!(shard_of(&lease), "0");
2631 }
2632
2633 #[cfg(unix)]
2639 #[derive(Default)]
2640 struct RacingMasters {
2641 bound: Mutex<BTreeSet<String>>,
2642 masters: AtomicUsize,
2643 orphans: AtomicUsize,
2644 }
2645
2646 #[cfg(unix)]
2647 impl CommandExecutor for RacingMasters {
2648 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2649 let socket = FakeMasters::socket(command);
2650 let status = if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2651 if self.bound.lock().unwrap().contains(&socket) {
2652 0
2653 } else {
2654 255
2655 }
2656 } else {
2657 std::thread::sleep(Duration::from_millis(100));
2658 if self.bound.lock().unwrap().insert(socket) {
2659 self.masters.fetch_add(1, Ordering::SeqCst);
2660 } else {
2661 self.orphans.fetch_add(1, Ordering::SeqCst);
2662 }
2663 0
2664 };
2665 Ok(CommandOutput {
2666 status,
2667 stdout: Vec::new(),
2668 stderr: Vec::new(),
2669 })
2670 }
2671 }
2672
2673 #[test]
2678 #[cfg(unix)]
2679 fn two_processes_opening_one_socket_open_one_master() {
2680 let dir = sharing_socket_dir();
2681 let ssh = plain_target("racing-host");
2682 let fake = RacingMasters::default();
2683 std::thread::scope(|scope| {
2684 for _ in 0..2 {
2685 scope.spawn(|| {
2686 let ledger = SessionLedger::new(8);
2688 ledger.lease(&ssh, dir.path(), &fake).expect("lease");
2689 });
2690 }
2691 });
2692 assert_eq!(fake.masters.load(Ordering::SeqCst), 1);
2693 assert_eq!(fake.orphans.load(Ordering::SeqCst), 0);
2694 }
2695
2696 #[test]
2697 #[cfg(unix)]
2698 fn a_stale_socket_is_removed_before_the_master_is_opened() {
2699 let dir = sharing_socket_dir();
2700 let ledger = SessionLedger::new(8);
2701 let ssh = plain_target("host");
2702 let socket = dir.path().join(control_socket_name(&ssh, 0));
2703 fs::write(&socket, b"").expect("stale socket stand-in");
2704 let masters = FakeMasters::default();
2705
2706 ledger.lease(&ssh, dir.path(), &masters).expect("opens");
2707
2708 assert_eq!(*masters.socket_existed_at_open.borrow(), [false]);
2709 }
2710
2711 #[test]
2712 #[cfg(unix)]
2713 fn a_master_open_times_out_during_handshake_and_honors_the_users_shorter_budget() {
2714 use std::net::TcpListener;
2715 use std::sync::mpsc;
2716
2717 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2718 let port = listener.local_addr().unwrap().port();
2719 listener.set_nonblocking(true).unwrap();
2720 let (finish, stopping) = mpsc::channel();
2721 let server = std::thread::spawn(move || {
2722 let deadline = Instant::now() + Duration::from_secs(5);
2723 loop {
2724 match listener.accept() {
2725 Ok((connection, _)) => {
2726 let _ = stopping.recv_timeout(Duration::from_secs(5));
2729 drop(connection);
2730 return;
2731 }
2732 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2733 if stopping.try_recv().is_ok() || Instant::now() >= deadline {
2734 return;
2735 }
2736 std::thread::sleep(Duration::from_millis(5));
2737 }
2738 Err(error) => panic!("accept stalled SSH handshake: {error}"),
2739 }
2740 }
2741 });
2742 let directory = tempfile::tempdir().unwrap();
2743 let ssh = SshTarget {
2744 destination: "127.0.0.1".into(),
2745 ssh_args: vec![
2746 "-F".into(),
2747 "/dev/null".into(),
2748 "-p".into(),
2749 port.to_string(),
2750 "-o".into(),
2751 "ConnectTimeout=1".into(),
2752 ],
2753 };
2754 let started = Instant::now();
2755 let result = CancellableProcessExecutor::with_timeout(Duration::from_secs(3))
2758 .run_once(&master_open_command(&ssh, &directory.path().join("master")));
2759 let _ = finish.send(());
2760 server.join().unwrap();
2761 let output = result.expect("SSH's handshake timeout must beat the executor deadline");
2762 assert_eq!(output.status, 255);
2763 let stderr = String::from_utf8_lossy(&output.stderr);
2764 assert!(stderr.contains("timed out"), "{stderr}");
2765 assert!(started.elapsed() < Duration::from_secs(3));
2766 }
2767
2768 #[test]
2769 #[cfg(unix)]
2770 fn session_args_forbid_a_direct_connection() {
2771 let dir = sharing_socket_dir();
2772 let ledger = SessionLedger::new(8);
2773 let masters = FakeMasters::default();
2774 let lease = ledger
2775 .lease(&plain_target("host"), dir.path(), &masters)
2776 .expect("lease");
2777 let mut args = Vec::new();
2778 push_session_args(&mut args, &lease);
2779 assert_eq!(
2780 args,
2781 [
2782 "-o".to_owned(),
2783 "ControlMaster=no".to_owned(),
2784 "-o".to_owned(),
2785 format!("ControlPath={}", lease.control_path().unwrap().display()),
2786 "-o".to_owned(),
2787 "ProxyCommand=false".to_owned(),
2788 ]
2789 );
2790 }
2791
2792 #[test]
2797 fn a_refused_session_is_named_apart_from_a_pre_authentication_hangup() {
2798 assert_eq!(
2799 ssh_refusal(
2800 255,
2801 "mux_client_request_session: session request failed: Session open refused by peer\n\
2802 kex_exchange_identification: Connection closed by remote host\n\
2803 Connection closed by UNKNOWN port 65535"
2804 ),
2805 Some(SshRefusal::SessionLimit)
2806 );
2807 assert_eq!(
2808 ssh_refusal(255, "Connection closed by 192.168.1.77 port 22"),
2809 Some(SshRefusal::BeforeAuthentication)
2810 );
2811 assert_eq!(ssh_refusal(1, "Session open refused by peer"), None);
2812 assert!(
2813 !SshRefusal::SessionLimit
2814 .retry_message()
2815 .contains("before authentication")
2816 );
2817 }
2818
2819 #[test]
2820 fn transport_rejection_matches_only_sshd_hangups() {
2821 let cases: [(i32, &str, bool); 7] = [
2822 (255, "Connection closed by 192.168.1.77 port 22", true),
2823 (
2824 255,
2825 "kex_exchange_identification: read: Connection reset by peer",
2826 true,
2827 ),
2828 (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
2829 (255, "Connection timed out during banner exchange", true),
2830 (255, "Permission denied (publickey).", false),
2831 (
2832 255,
2833 "ssh: connect to host h port 22: Connection refused",
2834 false,
2835 ),
2836 (1, "Connection closed by 192.168.1.77 port 22", false),
2837 ];
2838 for (status, stderr, expected) in cases {
2839 assert_eq!(
2840 is_transport_rejection(status, stderr),
2841 expected,
2842 "status {status} stderr {stderr:?}"
2843 );
2844 }
2845 }
2846
2847 #[test]
2848 fn admission_never_admits_more_than_the_limit() {
2849 let gate = DestinationGate::new(2);
2850 let in_flight = Arc::new(AtomicUsize::new(0));
2851 let peak = Arc::new(AtomicUsize::new(0));
2852 let threads: Vec<_> = (0..12)
2853 .map(|_| {
2854 let gate = Arc::clone(&gate);
2855 let in_flight = Arc::clone(&in_flight);
2856 let peak = Arc::clone(&peak);
2857 std::thread::spawn(move || {
2858 for _ in 0..25 {
2859 let permit = gate.acquire();
2860 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2861 peak.fetch_max(now, Ordering::SeqCst);
2862 std::thread::yield_now();
2863 in_flight.fetch_sub(1, Ordering::SeqCst);
2864 drop(permit);
2865 }
2866 })
2867 })
2868 .collect();
2869 for thread in threads {
2870 thread.join().expect("admission worker must not panic");
2871 }
2872 assert!(
2873 peak.load(Ordering::SeqCst) <= 2,
2874 "admission let {} connections run against a 2-permit gate",
2875 peak.load(Ordering::SeqCst)
2876 );
2877 assert_eq!(in_flight.load(Ordering::SeqCst), 0);
2878 }
2879
2880 #[test]
2881 fn admission_blocks_once_every_permit_is_held() {
2882 let gate = DestinationGate::new(2);
2883 let first = gate.acquire();
2884 let second = gate.acquire();
2885 let waiter = {
2886 let gate = Arc::clone(&gate);
2887 std::thread::spawn(move || {
2888 let permit = gate.acquire();
2889 drop(permit);
2890 })
2891 };
2892 std::thread::sleep(std::time::Duration::from_millis(50));
2894 assert!(!waiter.is_finished());
2895 drop(first);
2896 waiter
2897 .join()
2898 .expect("waiter must be admitted once a permit frees");
2899 drop(second);
2900 }
2901
2902 #[test]
2903 fn cancelled_admission_does_not_wait_for_the_holder_or_consume_a_slot() {
2904 let gate = DestinationGate::new(1);
2905 let held = gate.acquire();
2906 let executor = CancellableProcessExecutor::with_timeout(Duration::from_millis(50));
2907 let error = gate
2908 .acquire_unless(&|| executor.is_cancelled())
2909 .unwrap_err();
2910 assert!(error.to_string().contains("cancelled"));
2911 assert_eq!(*gate.in_flight.lock().unwrap(), 1);
2912 drop(held);
2913 assert!(gate.acquire_unless(&|| false).is_ok());
2914 }
2915
2916 #[cfg(unix)]
2917 #[test]
2918 fn master_file_and_thread_admission_honor_the_executor_deadline() {
2919 let dir = sharing_socket_dir();
2920 let socket = dir.path().join("held-master");
2921 let _held = lock_master_opening(&socket).unwrap();
2922 let executor = CancellableProcessExecutor::with_timeout(Duration::from_millis(50));
2923 assert!(lock_master_opening_unless(&socket, &|| executor.is_cancelled()).is_err());
2924
2925 let ledger = SessionLedger::new(2);
2926 let ssh = plain_target("cancelled-master-host");
2927 let (_, opening) = ledger.reserve(&connection_key(&ssh));
2928 let _opening = opening.lock().unwrap();
2929 let executor = CancellableProcessExecutor::with_timeout(Duration::from_millis(50));
2930 assert!(ledger.lease(&ssh, dir.path(), &executor).is_err());
2931 assert_eq!(ledger.connections()[&connection_key(&ssh)][0].leased, 1);
2932 }
2933}