Skip to main content

mj_core/targets/
ssh.rs

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
9/// The connectivity probe `mj doctor` runs against an SSH target.
10///
11/// It reuses the provisioning argument order so the probe fails exactly where
12/// a real session would, with two deliberate overrides prepended. OpenSSH
13/// honours the first occurrence of an option, so these win over the
14/// provisioning defaults: `BatchMode=yes` never prompts for a password, and
15/// `StrictHostKeyChecking=yes` never accepts an unknown host key. Doctor
16/// diagnoses; the user decides whether to trust a key.
17pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
18    let mut probe = ssh.clone();
19    probe.ssh_args.splice(
20        0..0,
21        [
22            "-o".to_owned(),
23            "BatchMode=yes".to_owned(),
24            "-o".to_owned(),
25            "StrictHostKeyChecking=yes".to_owned(),
26        ],
27    );
28    ssh_command(&probe, ["true"]).purpose("verify SSH connectivity")
29}
30
31pub fn ssh_command(
32    ssh: &SshTarget,
33    args: impl IntoIterator<Item = impl AsRef<str>>,
34) -> CommandSpec {
35    ssh_command_owned(
36        ssh,
37        args.into_iter()
38            .map(|arg| arg.as_ref().to_owned())
39            .collect(),
40    )
41}
42
43pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
44    let mut args = ssh.ssh_args.clone();
45    push_connection_sharing_args(&mut args);
46    args.push(ssh.destination.clone());
47    args.push(join_remote_command(&remote_args));
48    CommandSpec::new("ssh", args).ssh_destination(ssh.destination.clone())
49}
50
51/// Home-relative directory on an SSH host where files bound for a remote
52/// container wait before the engine copies them in. Home-relative rather than
53/// `~/`, because `ssh_command` quotes every argument while `scp` expands `~`.
54pub const REMOTE_UPLOAD_STAGING: &str = ".cache/mjolnir/uploads";
55
56/// Upload a local file or directory to the SSH host.
57pub fn scp_upload(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
58    let mut args = scp_args(ssh);
59    if recursive {
60        args.push("-r".into());
61    }
62    args.push(source.to_string_lossy().into_owned());
63    args.push(format!("{}:{remote}", ssh.destination));
64    scp_command(ssh, args)
65}
66
67/// Download a remote file from the SSH host.
68pub fn scp_download(ssh: &SshTarget, remote: &str, local: &str) -> CommandSpec {
69    let mut args = scp_args(ssh);
70    args.push(format!("{}:{remote}", ssh.destination));
71    args.push(local.into());
72    scp_command(ssh, args)
73}
74
75/// The connection's `ssh` arguments rewritten for `scp`, which spells the port
76/// option `-P`; to `scp`, `-p` means "preserve file times". The connection
77/// sharing options follow, as they do for `ssh`.
78fn scp_args(ssh: &SshTarget) -> Vec<String> {
79    let mut args = ssh
80        .ssh_args
81        .iter()
82        .map(|argument| {
83            if argument == "-p" {
84                "-P".to_owned()
85            } else {
86                argument.clone()
87            }
88        })
89        .collect();
90    push_connection_sharing_args(&mut args);
91    args
92}
93
94fn scp_command(ssh: &SshTarget, args: Vec<String>) -> CommandSpec {
95    // `scp` opens its own connection to the same host, so it competes for the
96    // same pre-auth budget and is admitted and retried the same way.
97    CommandSpec::new("scp", args).ssh_destination(ssh.destination.clone())
98}
99
100/// How long a shared master connection stays alive after its last channel
101/// closes. The master is an `ssh` process that outlives the daemon by this
102/// long, so it is kept short enough to be unsurprising and long enough to
103/// cover a whole provision.
104#[cfg(unix)]
105const CONTROL_PERSIST: &str = "60";
106
107/// Environment override that turns connection sharing off. Any of `0`, `off`,
108/// `false`, or `no` disables it.
109pub const CONTROL_MASTER_ENV: &str = "MJ_SSH_CONTROL_MASTER";
110
111/// Longest `ControlPath` that still fits in a Unix socket address. `sun_path`
112/// holds 108 bytes on Linux and 104 on macOS, minus the terminating NUL.
113#[cfg(unix)]
114const MAX_CONTROL_PATH: usize = 103;
115
116/// `%C` expands to a hash of host, port, user, and local host: SHA-1 hex
117/// today, and 64 characters is reserved for it so a longer one still fits.
118#[cfg(unix)]
119const CONTROL_PATH_FILE: &str = "%C";
120
121#[cfg(unix)]
122fn sharing_disabled(value: Option<&std::ffi::OsStr>) -> bool {
123    let Some(value) = value else {
124        return false;
125    };
126    matches!(
127        value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
128        "0" | "off" | "false" | "no"
129    )
130}
131
132/// How a test pins connection sharing instead of letting it resolve from the
133/// environment.
134#[doc(hidden)]
135#[derive(Debug, Clone)]
136pub enum SshSharingForTest {
137    /// Behave as though the escape hatch were set.
138    Disabled,
139    /// Keep the control sockets in this directory.
140    Directory(PathBuf),
141}
142
143static SHARING_OVERRIDE: Mutex<Option<SshSharingForTest>> = Mutex::new(None);
144
145/// Pin connection sharing for a test, or restore the real resolution with
146/// `None`. Tests must not depend on the developer's `$XDG_RUNTIME_DIR` or home
147/// directory, so every test that inspects `ssh` arguments pins it. Not part of
148/// the daemon's behaviour.
149#[doc(hidden)]
150pub fn set_ssh_connection_sharing_for_test(setting: Option<SshSharingForTest>) {
151    *SHARING_OVERRIDE
152        .lock()
153        .unwrap_or_else(std::sync::PoisonError::into_inner) = setting;
154}
155
156#[cfg(unix)]
157fn sharing_override() -> Option<SshSharingForTest> {
158    SHARING_OVERRIDE
159        .lock()
160        .unwrap_or_else(std::sync::PoisonError::into_inner)
161        .clone()
162}
163
164/// Where the ControlMaster sockets live, or `None` when sharing is off.
165///
166/// `$XDG_RUNTIME_DIR` is preferred because it is short, per-user, and on
167/// tmpfs; the data directory is the fallback. Neither is world-writable, and
168/// the directory is created 0700 because `ssh` will not create it itself.
169#[cfg(unix)]
170fn control_socket_path() -> Option<PathBuf> {
171    match sharing_override() {
172        Some(SshSharingForTest::Disabled) => return None,
173        Some(SshSharingForTest::Directory(dir)) => return prepare_control_path(dir),
174        None => {}
175    }
176    static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
177    DIR.get_or_init(|| {
178        if sharing_disabled(std::env::var_os(CONTROL_MASTER_ENV).as_deref()) {
179            return None;
180        }
181        let base = match std::env::var_os("XDG_RUNTIME_DIR") {
182            Some(runtime) if !runtime.is_empty() => PathBuf::from(runtime).join("mjolnir"),
183            _ => crate::config::data_dir().join("ssh"),
184        };
185        prepare_control_path(base)
186    })
187    .clone()
188}
189
190/// Create the socket directory 0700 and reject one whose sockets would not fit
191/// in a Unix socket address. Failure means no sharing, never a failed command.
192#[cfg(unix)]
193fn prepare_control_path(dir: PathBuf) -> Option<PathBuf> {
194    let socket = dir.join(CONTROL_PATH_FILE);
195    // Measure the path ssh actually binds, with 64 characters reserved for
196    // the `%C` expansion.
197    let bound_len = socket.as_os_str().len() - CONTROL_PATH_FILE.len() + 64;
198    if bound_len > MAX_CONTROL_PATH {
199        tracing::debug!(
200            directory = %dir.display(),
201            "skipping SSH connection sharing: control socket path would be too long"
202        );
203        return None;
204    }
205    if let Err(error) = fs::create_dir_all(&dir) {
206        tracing::debug!(
207            directory = %dir.display(),
208            %error,
209            "skipping SSH connection sharing: control directory is unavailable"
210        );
211        return None;
212    }
213    use std::os::unix::fs::PermissionsExt;
214    if let Err(error) = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) {
215        tracing::debug!(
216            directory = %dir.display(),
217            %error,
218            "skipping SSH connection sharing: cannot restrict control directory"
219        );
220        return None;
221    }
222    Some(socket)
223}
224
225/// Append the options that let this command create and keep a shared master
226/// connection, to a partly built `ssh` or `scp` argument list.
227///
228/// Every Mjolnir invocation against one destination then rides one
229/// authenticated connection instead of paying for its own handshake. Call this
230/// after the caller's own `ssh_args` and before the destination: OpenSSH keeps
231/// the first value it sees for an option, so a user who sets `ControlMaster`
232/// or `ControlPath` in `extra_args` still wins.
233///
234/// Adds nothing on non-unix (Windows OpenSSH has no ControlMaster), when
235/// `MJ_SSH_CONTROL_MASTER` disables it, or when the socket directory cannot be
236/// prepared.
237pub fn push_connection_sharing_args(args: &mut Vec<String>) {
238    push_control_args(args, true);
239}
240
241/// Append the options that let this command *reuse* a shared master without
242/// ever becoming one.
243///
244/// Use this for commands that carry deliberately impatient options, such as
245/// the short `ConnectTimeout` and one-miss `ServerAlive` keepalive of a
246/// validation probe or a Tab completion. Those settings belong to the one
247/// command that asked for them. If such a command opened the master, the
248/// master would enforce them for its whole lifetime and drop every later
249/// multiplexed session -- an upload, a `podman run`, the worker bootstrap --
250/// on a stall of a couple of seconds. With `ControlMaster=no` the command
251/// joins an existing master when one is up and otherwise opens its own direct
252/// connection, keeping its fail-fast options to itself.
253pub fn push_connection_reuse_args(args: &mut Vec<String>) {
254    push_control_args(args, false);
255}
256
257fn push_control_args(args: &mut Vec<String>, may_become_master: bool) {
258    #[cfg(unix)]
259    if let Some(socket) = control_socket_path() {
260        args.extend([
261            "-o".to_owned(),
262            if may_become_master {
263                "ControlMaster=auto".to_owned()
264            } else {
265                "ControlMaster=no".to_owned()
266            },
267            "-o".to_owned(),
268            format!("ControlPath={}", socket.display()),
269        ]);
270        // Only a command that may open the master decides how long it lingers.
271        if may_become_master {
272            args.extend(["-o".to_owned(), format!("ControlPersist={CONTROL_PERSIST}")]);
273        }
274    }
275    #[cfg(not(unix))]
276    let _ = (args, may_become_master);
277}
278
279pub fn join_remote_command(args: &[String]) -> String {
280    args.iter()
281        .map(|arg| posix_quote(arg))
282        .collect::<Vec<_>>()
283        .join(" ")
284}
285
286/// Check whether a directory exists on the configured SSH host.
287pub fn ssh_directory_exists(
288    ssh: &SshTarget,
289    path: &Path,
290    executor: &impl CommandExecutor,
291) -> Result<bool> {
292    let command = ssh_validation_command(
293        ssh,
294        vec![
295            "test".into(),
296            "-d".into(),
297            path.to_string_lossy().into_owned(),
298        ],
299        "validate remote directory",
300    );
301    let output = executor.execute(&command)?;
302    match output.status {
303        0 => Ok(true),
304        1 => Ok(false),
305        status => bail!(
306            "remote directory check failed with status {status}: {}",
307            String::from_utf8_lossy(&output.stderr).trim()
308        ),
309    }
310}
311
312/// Verify that a bare-SSH project path exists and has a committed Git HEAD.
313pub fn validate_bare_project_directory(
314    ssh: &SshTarget,
315    path: &Path,
316    executor: &impl CommandExecutor,
317) -> Result<()> {
318    validate_bare_project_path(path)?;
319    if !ssh_directory_exists(ssh, path, executor)? {
320        bail!(
321            "remote project directory {} does not exist or is not a directory",
322            path.display()
323        );
324    }
325    let output = executor.execute(&ssh_validation_command(
326        ssh,
327        vec![
328            "git".into(),
329            "-C".into(),
330            path.to_string_lossy().into_owned(),
331            "rev-parse".into(),
332            "--verify".into(),
333            "HEAD".into(),
334        ],
335        "validate bare SSH Git project",
336    ))?;
337    if output.status != 0 {
338        let detail = String::from_utf8_lossy(&output.stderr);
339        let detail = detail.trim();
340        if detail.is_empty() {
341            bail!(
342                "remote project directory {} has no valid Git HEAD",
343                path.display()
344            );
345        }
346        bail!(
347            "remote project directory {} has no valid Git HEAD: {detail}",
348            path.display()
349        );
350    }
351    Ok(())
352}
353
354pub fn validate_bare_project_path(path: &Path) -> Result<()> {
355    if !path.is_absolute()
356        || path
357            .components()
358            .any(|part| part == std::path::Component::ParentDir)
359    {
360        bail!("bare project directory must be an absolute safe path");
361    }
362    Ok(())
363}
364
365pub fn ssh_validation_command(
366    ssh: &SshTarget,
367    remote_args: Vec<String>,
368    purpose: &'static str,
369) -> CommandSpec {
370    let mut args = ssh.ssh_args.clone();
371    args.extend([
372        "-o".into(),
373        "BatchMode=yes".into(),
374        "-o".into(),
375        "ConnectTimeout=3".into(),
376        "-o".into(),
377        "ServerAliveInterval=2".into(),
378        "-o".into(),
379        "ServerAliveCountMax=1".into(),
380    ]);
381    push_connection_reuse_args(&mut args);
382    args.extend([ssh.destination.clone(), join_remote_command(&remote_args)]);
383    CommandSpec::new("ssh", args)
384        .ssh_destination(ssh.destination.clone())
385        .purpose(purpose)
386}
387
388/// Wrap a value so a POSIX shell reads it as one literal argument. Used at the
389/// SSH boundary here and when Hel rebuilds an agent's terminal command line
390/// (`terminal::shell_line`).
391pub fn posix_quote(value: &str) -> String {
392    format!("'{}'", value.replace('\'', "'\\''"))
393}
394
395pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
396    let expected_name = resource_name(session_id)?;
397    match locator {
398        TargetLocator::LocalBare { worker_root } => {
399            let path = Path::new(worker_root);
400            if !path.is_absolute()
401                || path
402                    .components()
403                    .any(|part| part == std::path::Component::ParentDir)
404                || !path.ends_with(session_id)
405            {
406                bail!("refusing cleanup: invalid local bare worker root");
407            }
408        }
409        TargetLocator::LocalPodman {
410            container_id,
411            borrowed_from,
412            ..
413        }
414        | TargetLocator::LocalDocker {
415            container_id,
416            borrowed_from,
417        }
418        | TargetLocator::AppleContainer {
419            container_id,
420            borrowed_from,
421        }
422        | TargetLocator::SshPodman {
423            container_id,
424            borrowed_from,
425            ..
426        }
427        | TargetLocator::SshDocker {
428            container_id,
429            borrowed_from,
430            ..
431        } => match borrowed_from {
432            Some(owner) => {
433                validate_session_id(owner)?;
434                if owner == session_id {
435                    bail!(
436                        "refusing cleanup: a borrowed container cannot be owned by the borrowing session"
437                    );
438                }
439                let owner_name = resource_name(owner)?;
440                if container_id != &owner_name && !is_runtime_container_id(container_id) {
441                    bail!(
442                        "refusing cleanup: borrowed container locator is neither the owning session's generated name nor an immutable runtime ID"
443                    );
444                }
445            }
446            None => {
447                if container_id != &expected_name && !is_runtime_container_id(container_id) {
448                    bail!(
449                        "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
450                    );
451                }
452            }
453        },
454        TargetLocator::AwsEc2 {
455            instance_id,
456            workspace,
457            ..
458        } => {
459            if !valid_ec2_instance_id(instance_id) {
460                bail!("refusing cleanup: invalid EC2 instance ID");
461            }
462            verify_session_workspace(workspace, session_id)?;
463        }
464        TargetLocator::SshBare {
465            workspace,
466            worker_id,
467            ..
468        } => match worker_id {
469            Some(worker_id) => {
470                validate_session_id(worker_id)?;
471                if worker_id != session_id {
472                    bail!("refusing cleanup: SSH worker identity does not match session ID");
473                }
474                validate_workspace_prefix(workspace)?;
475            }
476            None => verify_session_workspace(workspace, session_id)?,
477        },
478    }
479    Ok(())
480}
481
482/// Whether this locator names a target another session owns: a sub-agent
483/// child either borrowing its parent's container or running as its own worker
484/// inside the parent's SSH workspace.
485pub fn is_borrowed(locator: &TargetLocator) -> bool {
486    match locator {
487        TargetLocator::LocalPodman { borrowed_from, .. }
488        | TargetLocator::LocalDocker { borrowed_from, .. }
489        | TargetLocator::AppleContainer { borrowed_from, .. }
490        | TargetLocator::SshPodman { borrowed_from, .. }
491        | TargetLocator::SshDocker { borrowed_from, .. } => borrowed_from.is_some(),
492        TargetLocator::SshBare { worker_id, .. } => worker_id.is_some(),
493        TargetLocator::LocalBare { .. } | TargetLocator::AwsEc2 { .. } => false,
494    }
495}
496
497pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
498    validate_workspace_prefix(workspace)?;
499    let final_component = workspace.trim_end_matches('/').rsplit('/').next();
500    if final_component != Some(session_id) {
501        bail!("refusing cleanup: workspace does not end in the exact session ID");
502    }
503    Ok(())
504}
505
506pub fn validate_session_id(value: &str) -> Result<()> {
507    if value.len() < 8
508        || value.len() > 128
509        || !value
510            .chars()
511            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
512    {
513        bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
514    }
515    Ok(())
516}
517
518pub fn validate_relative_path(value: &str) -> Result<()> {
519    let path = std::path::Path::new(value);
520    if value.is_empty()
521        || path.is_absolute()
522        || path
523            .components()
524            .any(|part| !matches!(part, std::path::Component::Normal(_)))
525    {
526        bail!("unsafe relative bundle path {value:?}");
527    }
528    Ok(())
529}
530
531pub fn validate_workspace_prefix(value: &str) -> Result<()> {
532    if value.is_empty()
533        || value == "/"
534        || value == "~"
535        || value == "~/"
536        || value.contains('\0')
537        || value.split('/').any(|part| part == "..")
538    {
539        bail!("unsafe workspace path");
540    }
541    Ok(())
542}
543
544pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
545    if template.image.trim().is_empty() || template.image.starts_with('-') {
546        bail!("invalid container image");
547    }
548    if template
549        .extra_run_args
550        .iter()
551        .any(|arg| arg == "--name" || arg.starts_with("--name="))
552    {
553        bail!("container template may not override the generated name");
554    }
555    if template.extra_run_args.iter().any(|arg| {
556        arg == "--label"
557            || [SESSION_LABEL, MANAGED_LABEL, INSTANCE_LABEL]
558                .iter()
559                .any(|label| arg.starts_with(&format!("--label={label}=")))
560    }) {
561        bail!("container template may not override Mjolnir ownership labels");
562    }
563    Ok(())
564}
565
566pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
567    if ssh.destination.trim().is_empty()
568        || ssh.destination.starts_with('-')
569        || ssh.destination.chars().any(char::is_whitespace)
570    {
571        bail!("invalid SSH destination");
572    }
573    Ok(())
574}
575
576pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
577    validate_ssh(&aws.ssh)?;
578    for (name, value) in [
579        ("AWS profile", &aws.profile),
580        ("AWS region", &aws.region),
581        ("launch template", &aws.launch_template),
582    ] {
583        if value.is_empty()
584            || value.starts_with('-')
585            || !value
586                .chars()
587                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
588        {
589            bail!("invalid {name}");
590        }
591    }
592    Ok(())
593}
594
595pub fn validate_executable(value: &str) -> Result<()> {
596    if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
597        bail!("invalid executable name");
598    }
599    Ok(())
600}
601
602pub fn valid_ec2_instance_id(value: &str) -> bool {
603    value
604        .strip_prefix("i-")
605        .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
606}
607
608pub fn is_runtime_container_id(value: &str) -> bool {
609    value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
610}
611
612/// `ssh` reserves exit status 255 for its own transport failures; a remote
613/// command never produces it, so the remote side provably never ran.
614pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
615
616/// Stderr fragments OpenSSH prints when the server hangs up before
617/// authentication. `sshd`'s `MaxStartups` produces exactly these when it drops
618/// an unauthenticated connection, and so does a server that is still starting.
619const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
620    "Connection closed by",
621    "Connection reset by",
622    "kex_exchange_identification",
623    "Connection timed out during banner exchange",
624];
625
626/// Whether a finished `ssh` process was turned away by the transport rather
627/// than by the remote command.
628///
629/// The remote command never started in this case, so the caller may retry the
630/// whole invocation without worrying about repeating a side effect.
631pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
632    status == SSH_TRANSPORT_EXIT_STATUS
633        && TRANSPORT_REJECTION_MARKERS
634            .iter()
635            .any(|marker| stderr.contains(marker))
636}
637
638/// Default number of `ssh` processes this daemon will have in flight against
639/// one destination at a time.
640///
641/// `sshd` counts *unauthenticated* connections against `MaxStartups`, whose
642/// stock value is `10:30:100`: from the eleventh concurrent pre-auth connection
643/// it starts dropping them, and past a hundred it drops all of them. A daemon
644/// that spawns one fresh `ssh` per operation reaches that during startup, so it
645/// admits its own connections instead of letting the server refuse them.
646const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
647
648/// Environment override for [`DEFAULT_MAX_CONCURRENT_SSH`].
649pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
650
651fn max_concurrent_ssh() -> usize {
652    static LIMIT: OnceLock<usize> = OnceLock::new();
653    *LIMIT.get_or_init(|| {
654        let Some(raw) = std::env::var_os(MAX_CONCURRENT_SSH_ENV) else {
655            return DEFAULT_MAX_CONCURRENT_SSH;
656        };
657        match raw
658            .to_str()
659            .and_then(|value| value.trim().parse::<usize>().ok())
660        {
661            Some(limit) if limit > 0 => limit,
662            _ => {
663                tracing::warn!(
664                    variable = MAX_CONCURRENT_SSH_ENV,
665                    value = %raw.to_string_lossy(),
666                    default = DEFAULT_MAX_CONCURRENT_SSH,
667                    "ignoring invalid SSH concurrency limit"
668                );
669                DEFAULT_MAX_CONCURRENT_SSH
670            }
671        }
672    })
673}
674
675/// A counting semaphore per SSH destination.
676///
677/// Deliberately built on `std::sync` rather than a runtime primitive: the
678/// blocking process executors are called from plain threads as well as from
679/// `spawn_blocking`, and both must share one gate.
680struct DestinationGate {
681    limit: usize,
682    in_flight: Mutex<usize>,
683    released: Condvar,
684}
685
686impl DestinationGate {
687    fn new(limit: usize) -> Arc<Self> {
688        Arc::new(Self {
689            limit,
690            in_flight: Mutex::new(0),
691            released: Condvar::new(),
692        })
693    }
694
695    fn acquire(self: &Arc<Self>) -> SshPermit {
696        let mut in_flight = self
697            .in_flight
698            .lock()
699            .unwrap_or_else(std::sync::PoisonError::into_inner);
700        while *in_flight >= self.limit {
701            in_flight = self
702                .released
703                .wait(in_flight)
704                .unwrap_or_else(std::sync::PoisonError::into_inner);
705        }
706        *in_flight += 1;
707        drop(in_flight);
708        SshPermit {
709            gate: Arc::clone(self),
710        }
711    }
712}
713
714/// One admitted `ssh` connection. The slot is returned on drop.
715pub struct SshPermit {
716    gate: Arc<DestinationGate>,
717}
718
719impl std::fmt::Debug for SshPermit {
720    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
721        formatter.write_str("SshPermit")
722    }
723}
724
725impl Drop for SshPermit {
726    fn drop(&mut self) {
727        let mut in_flight = self
728            .gate
729            .in_flight
730            .lock()
731            .unwrap_or_else(std::sync::PoisonError::into_inner);
732        *in_flight = in_flight.saturating_sub(1);
733        drop(in_flight);
734        self.gate.released.notify_one();
735    }
736}
737
738/// Process-wide admission control for outbound `ssh` connections.
739pub struct SshAdmission;
740
741impl SshAdmission {
742    /// Block until this process may open another `ssh` connection to
743    /// `destination`. The returned permit holds the slot until it is dropped.
744    pub fn acquire(destination: &str) -> SshPermit {
745        Self::gate(destination).acquire()
746    }
747
748    fn gate(destination: &str) -> Arc<DestinationGate> {
749        static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
750        let mut gates = GATES
751            .get_or_init(|| Mutex::new(BTreeMap::new()))
752            .lock()
753            .unwrap_or_else(std::sync::PoisonError::into_inner);
754        Arc::clone(
755            gates
756                .entry(destination.to_owned())
757                .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
758        )
759    }
760}
761
762/// How many times a transport-rejected `ssh` invocation is tried in total.
763pub const SSH_RETRY_ATTEMPTS: usize = 3;
764
765/// Inclusive millisecond bounds the jittered delay is drawn from, indexed by
766/// the number of attempts already made. `sshd` sheds load for as long as its
767/// pre-auth queue stays full, so the second wait is a multiple of the first.
768const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
769
770/// Test override collapsing every retry delay to this many milliseconds.
771/// `u64::MAX` means "no override".
772static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
773
774/// Shorten the retry backoff so tests can drive the retry path without
775/// sleeping for seconds. Not part of the daemon's behaviour.
776#[doc(hidden)]
777pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
778    SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
779        delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
780        Ordering::Relaxed,
781    );
782}
783
784/// The jittered wait before retry number `attempts_made + 1`.
785///
786/// Jitter matters more than the mean here: every session's reconnect fails at
787/// the same instant, so an unjittered schedule would simply re-send the whole
788/// burst into the same full queue.
789pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
790    let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
791    if override_ms != u64::MAX {
792        return Duration::from_millis(override_ms);
793    }
794    let (low, high) = SSH_RETRY_BACKOFF_MS
795        .get(attempts_made.saturating_sub(1))
796        .copied()
797        .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
798    let mut bytes = [0_u8; 8];
799    // A failed draw only costs jitter, so fall back to the lower bound.
800    let spread = if getrandom::fill(&mut bytes).is_ok() {
801        u64::from_le_bytes(bytes) % (high - low + 1)
802    } else {
803        0
804    };
805    Duration::from_millis(low + spread)
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    const BORROW_PARENT: &str = "0123456789abcdef0123456789abcdef";
813    const BORROW_CHILD: &str = "fedcba9876543210fedcba9876543210";
814
815    fn borrowed_podman(owner: &str) -> TargetLocator {
816        TargetLocator::LocalPodman {
817            container_id: crate::targets::resource_name(owner).unwrap(),
818            workspace_storage: PodmanWorkspaceLocator::default(),
819            borrowed_from: Some(owner.to_owned()),
820        }
821    }
822
823    #[test]
824    fn verify_locator_accepts_a_container_borrowed_from_its_owner() {
825        verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_CHILD)
826            .expect("a child may borrow its parent's container");
827    }
828
829    #[test]
830    fn verify_locator_rejects_a_container_borrowed_from_the_checking_session() {
831        let error = verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_PARENT)
832            .expect_err("a session cannot borrow from itself");
833        assert!(
834            format!("{error:#}").contains("cannot be owned by the borrowing session"),
835            "unexpected error: {error:#}"
836        );
837    }
838
839    #[test]
840    fn verify_locator_rejects_a_borrowed_container_naming_another_session() {
841        let locator = TargetLocator::LocalPodman {
842            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
843            workspace_storage: PodmanWorkspaceLocator::default(),
844            borrowed_from: Some(BORROW_PARENT.to_owned()),
845        };
846        let error = verify_locator(&locator, BORROW_CHILD)
847            .expect_err("the container must belong to the recorded owner");
848        assert!(
849            format!("{error:#}").contains("borrowed container locator"),
850            "unexpected error: {error:#}"
851        );
852    }
853
854    #[test]
855    fn worker_root_of_a_borrowed_container_is_the_childs_own_directory() {
856        assert_eq!(
857            crate::targets::worker_root(&borrowed_podman(BORROW_PARENT), BORROW_CHILD).unwrap(),
858            format!("/var/lib/hel/workers/{BORROW_CHILD}")
859        );
860    }
861
862    #[test]
863    fn is_borrowed_distinguishes_borrowed_targets_from_owned_ones() {
864        assert!(is_borrowed(&borrowed_podman(BORROW_PARENT)));
865        assert!(is_borrowed(&TargetLocator::SshBare {
866            ssh: SshTarget {
867                destination: "host".to_owned(),
868                ssh_args: Vec::new(),
869            },
870            workspace: format!(".local/share/hel/workspaces/{BORROW_PARENT}"),
871            worker_id: Some(BORROW_CHILD.to_owned()),
872        }));
873        assert!(!is_borrowed(&TargetLocator::LocalPodman {
874            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
875            workspace_storage: PodmanWorkspaceLocator::default(),
876            borrowed_from: None,
877        }));
878    }
879
880    #[test]
881    fn an_owned_container_locator_serializes_without_a_borrowed_from_key() {
882        let owned = TargetLocator::LocalDocker {
883            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
884            borrowed_from: None,
885        };
886        let serialized = serde_json::to_string(&owned).unwrap();
887        assert!(
888            !serialized.contains("borrowed_from"),
889            "owned locators must stay byte-identical for older readers: {serialized}"
890        );
891        assert_eq!(
892            serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
893            owned
894        );
895
896        let borrowed = borrowed_podman(BORROW_PARENT);
897        let serialized = serde_json::to_string(&borrowed).unwrap();
898        assert!(serialized.contains("borrowed_from"));
899        assert_eq!(
900            serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
901            borrowed
902        );
903    }
904    use std::sync::atomic::{AtomicUsize, Ordering};
905
906    /// The connection-sharing override is process-wide, so the tests that set
907    /// it take turns.
908    #[cfg(unix)]
909    static SHARING_TEST_LOCK: Mutex<()> = Mutex::new(());
910
911    /// Records the commands it is handed and reports an empty success.
912    #[cfg(unix)]
913    #[derive(Default)]
914    struct RecordingExecutor {
915        seen: std::cell::RefCell<Vec<CommandSpec>>,
916    }
917
918    #[cfg(unix)]
919    impl CommandExecutor for RecordingExecutor {
920        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
921            self.seen.borrow_mut().push(command.clone());
922            Ok(CommandOutput {
923                status: 0,
924                stdout: Vec::new(),
925                stderr: Vec::new(),
926            })
927        }
928    }
929
930    #[cfg(unix)]
931    fn sharing_args(ssh: &SshTarget) -> Vec<String> {
932        ssh_command(ssh, ["true"]).args
933    }
934
935    #[cfg(unix)]
936    fn sharing_socket_dir() -> tempfile::TempDir {
937        // macOS's default temporary path leaves too little room for SSH's hash.
938        tempfile::tempdir_in("/tmp").expect("short control socket directory")
939    }
940
941    #[test]
942    #[cfg(unix)]
943    fn connection_sharing_follows_user_supplied_ssh_args() {
944        let _guard = SHARING_TEST_LOCK
945            .lock()
946            .unwrap_or_else(std::sync::PoisonError::into_inner);
947        let socket_dir = sharing_socket_dir();
948        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
949            socket_dir.path().to_path_buf(),
950        )));
951        let ssh = SshTarget {
952            destination: "host".to_owned(),
953            ssh_args: vec!["-o".to_owned(), "ControlMaster=no".to_owned()],
954        };
955        let args = sharing_args(&ssh);
956        set_ssh_connection_sharing_for_test(None);
957
958        let expected_path = format!("ControlPath={}/%C", socket_dir.path().display());
959        assert_eq!(
960            args,
961            vec![
962                "-o".to_owned(),
963                "ControlMaster=no".to_owned(),
964                "-o".to_owned(),
965                "ControlMaster=auto".to_owned(),
966                "-o".to_owned(),
967                expected_path,
968                "-o".to_owned(),
969                format!("ControlPersist={CONTROL_PERSIST}"),
970                "host".to_owned(),
971                "'true'".to_owned(),
972            ],
973            "sharing options must come after the user's own args, which OpenSSH prefers"
974        );
975        assert_eq!(
976            std::os::unix::fs::MetadataExt::mode(
977                &fs::metadata(socket_dir.path()).expect("socket directory")
978            ) & 0o777,
979            0o700
980        );
981    }
982
983    /// A command with a two-second keepalive must join a master, never open
984    /// one: as the master it would impose that keepalive on every later
985    /// session sharing the connection.
986    #[test]
987    #[cfg(unix)]
988    fn fail_fast_commands_reuse_a_master_without_becoming_one() {
989        let _guard = SHARING_TEST_LOCK
990            .lock()
991            .unwrap_or_else(std::sync::PoisonError::into_inner);
992        let socket_dir = sharing_socket_dir();
993        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
994            socket_dir.path().to_path_buf(),
995        )));
996        let ssh = SshTarget {
997            destination: "host".to_owned(),
998            ssh_args: Vec::new(),
999        };
1000        let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test").args;
1001        let executor = RecordingExecutor::default();
1002        crate::path_completion::ssh_completions(
1003            &ssh,
1004            "/srv/pr",
1005            crate::path_completion::CompletionKind::Directories,
1006            &executor,
1007        )
1008        .expect("completion runs");
1009        let completion = executor.seen.borrow()[0].args.clone();
1010        set_ssh_connection_sharing_for_test(None);
1011
1012        let control_path = format!("ControlPath={}/%C", socket_dir.path().display());
1013        for args in [&validation, &completion] {
1014            assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1015            assert!(args.contains(&control_path), "{args:?}");
1016            assert!(
1017                !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1018                "a fail-fast command must not set how long a master lingers: {args:?}"
1019            );
1020            let master = args
1021                .iter()
1022                .position(|arg| arg == "ControlMaster=no")
1023                .expect("sharing options");
1024            let alive = args
1025                .iter()
1026                .position(|arg| arg == "ServerAliveCountMax=1")
1027                .expect("its own keepalive");
1028            assert!(alive < master, "{args:?}");
1029            assert!(
1030                master
1031                    < args
1032                        .iter()
1033                        .position(|arg| arg == "host")
1034                        .expect("destination"),
1035                "{args:?}"
1036            );
1037        }
1038    }
1039
1040    #[test]
1041    #[cfg(unix)]
1042    fn connection_sharing_is_absent_when_turned_off() {
1043        let _guard = SHARING_TEST_LOCK
1044            .lock()
1045            .unwrap_or_else(std::sync::PoisonError::into_inner);
1046        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1047        let ssh = SshTarget {
1048            destination: "host".to_owned(),
1049            ssh_args: Vec::new(),
1050        };
1051        let args = sharing_args(&ssh);
1052        set_ssh_connection_sharing_for_test(None);
1053        assert_eq!(args, vec!["host".to_owned(), "'true'".to_owned()]);
1054    }
1055
1056    #[test]
1057    #[cfg(unix)]
1058    fn a_control_path_that_cannot_fit_a_socket_address_is_skipped() {
1059        let _guard = SHARING_TEST_LOCK
1060            .lock()
1061            .unwrap_or_else(std::sync::PoisonError::into_inner);
1062        let root = tempfile::tempdir().expect("temp dir");
1063        let long = root.path().join("a".repeat(MAX_CONTROL_PATH));
1064        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(long.clone())));
1065        let ssh = SshTarget {
1066            destination: "host".to_owned(),
1067            ssh_args: Vec::new(),
1068        };
1069        let args = sharing_args(&ssh);
1070        set_ssh_connection_sharing_for_test(None);
1071        assert_eq!(args, vec!["host".to_owned(), "'true'".to_owned()]);
1072        assert!(!long.exists(), "an unusable directory must not be created");
1073    }
1074
1075    #[test]
1076    #[cfg(not(unix))]
1077    fn connection_sharing_is_unix_only() {
1078        let mut args = vec!["-o".to_owned(), "BatchMode=yes".to_owned()];
1079        push_connection_sharing_args(&mut args);
1080        assert_eq!(args, vec!["-o".to_owned(), "BatchMode=yes".to_owned()]);
1081    }
1082
1083    #[test]
1084    #[cfg(unix)]
1085    fn the_escape_hatch_accepts_the_usual_off_spellings() {
1086        for value in ["0", "off", "FALSE", " no "] {
1087            assert!(
1088                sharing_disabled(Some(std::ffi::OsStr::new(value))),
1089                "{value:?} must disable connection sharing"
1090            );
1091        }
1092        for value in ["1", "auto", "", "yes"] {
1093            assert!(
1094                !sharing_disabled(Some(std::ffi::OsStr::new(value))),
1095                "{value:?} must leave connection sharing on"
1096            );
1097        }
1098        assert!(!sharing_disabled(None));
1099    }
1100
1101    /// Against a real host: the first invocation must leave a master behind
1102    /// that `ssh -O check` finds. Set `MJ_E2E_SSH_HOST` to a reachable
1103    /// destination to run it.
1104    #[test]
1105    #[cfg(unix)]
1106    fn sharing_leaves_a_reusable_master_on_a_real_host() {
1107        let _guard = SHARING_TEST_LOCK
1108            .lock()
1109            .unwrap_or_else(std::sync::PoisonError::into_inner);
1110        let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
1111            return;
1112        };
1113        let host = host.to_string_lossy().into_owned();
1114        let socket_dir = sharing_socket_dir();
1115        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1116            socket_dir.path().to_path_buf(),
1117        )));
1118        let ssh = SshTarget {
1119            destination: host.clone(),
1120            ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
1121        };
1122        let spec = ssh_command(&ssh, ["true"]);
1123        set_ssh_connection_sharing_for_test(None);
1124
1125        let first = std::process::Command::new(&spec.program)
1126            .args(&spec.args)
1127            .status()
1128            .expect("ssh must run");
1129        assert!(first.success(), "ssh {host} true failed");
1130
1131        let control_path = format!("{}/%C", socket_dir.path().display());
1132        let check = std::process::Command::new("ssh")
1133            .args([
1134                "-O",
1135                "check",
1136                "-o",
1137                &format!("ControlPath={control_path}"),
1138                &host,
1139            ])
1140            .output()
1141            .expect("ssh -O check must run");
1142        let exit = std::process::Command::new("ssh")
1143            .args([
1144                "-O",
1145                "exit",
1146                "-o",
1147                &format!("ControlPath={control_path}"),
1148                &host,
1149            ])
1150            .output();
1151        assert!(
1152            check.status.success(),
1153            "no master survived the first connection: {}",
1154            String::from_utf8_lossy(&check.stderr)
1155        );
1156        drop(exit);
1157    }
1158
1159    /// `scp` spells the port `-P`; passing an `ssh` `-p` through would ask it
1160    /// to preserve file times and read the port as a file name. Every `scp`
1161    /// also opens a connection, so it is admitted like `ssh`.
1162    #[test]
1163    #[cfg(unix)]
1164    fn scp_translates_the_ssh_port_option_and_is_tagged_with_its_destination() {
1165        let _guard = SHARING_TEST_LOCK
1166            .lock()
1167            .unwrap_or_else(std::sync::PoisonError::into_inner);
1168        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1169        let ssh = SshTarget {
1170            destination: "build@10.0.0.1".into(),
1171            ssh_args: vec!["-p".into(), "2222".into()],
1172        };
1173
1174        let upload = scp_upload(&ssh, Path::new("/tmp/local"), "remote/path", true);
1175        let download = scp_download(&ssh, "remote/archive.zip", "/tmp/local.zip");
1176        set_ssh_connection_sharing_for_test(None);
1177
1178        assert_eq!(
1179            upload.args,
1180            [
1181                "-P",
1182                "2222",
1183                "-r",
1184                "/tmp/local",
1185                "build@10.0.0.1:remote/path"
1186            ]
1187        );
1188        assert_eq!(
1189            download.args,
1190            [
1191                "-P",
1192                "2222",
1193                "build@10.0.0.1:remote/archive.zip",
1194                "/tmp/local.zip"
1195            ]
1196        );
1197        for command in [upload, download] {
1198            assert_eq!(command.program, "scp");
1199            assert_eq!(command.ssh_destination.as_deref(), Some("build@10.0.0.1"));
1200        }
1201    }
1202
1203    #[test]
1204    fn transport_rejection_matches_only_sshd_hangups() {
1205        let cases: [(i32, &str, bool); 7] = [
1206            (255, "Connection closed by 192.168.1.77 port 22", true),
1207            (
1208                255,
1209                "kex_exchange_identification: read: Connection reset by peer",
1210                true,
1211            ),
1212            (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
1213            (255, "Connection timed out during banner exchange", true),
1214            (255, "Permission denied (publickey).", false),
1215            (
1216                255,
1217                "ssh: connect to host h port 22: Connection refused",
1218                false,
1219            ),
1220            (1, "Connection closed by 192.168.1.77 port 22", false),
1221        ];
1222        for (status, stderr, expected) in cases {
1223            assert_eq!(
1224                is_transport_rejection(status, stderr),
1225                expected,
1226                "status {status} stderr {stderr:?}"
1227            );
1228        }
1229    }
1230
1231    #[test]
1232    fn admission_never_admits_more_than_the_limit() {
1233        let gate = DestinationGate::new(2);
1234        let in_flight = Arc::new(AtomicUsize::new(0));
1235        let peak = Arc::new(AtomicUsize::new(0));
1236        let threads: Vec<_> = (0..12)
1237            .map(|_| {
1238                let gate = Arc::clone(&gate);
1239                let in_flight = Arc::clone(&in_flight);
1240                let peak = Arc::clone(&peak);
1241                std::thread::spawn(move || {
1242                    for _ in 0..25 {
1243                        let permit = gate.acquire();
1244                        let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1245                        peak.fetch_max(now, Ordering::SeqCst);
1246                        std::thread::yield_now();
1247                        in_flight.fetch_sub(1, Ordering::SeqCst);
1248                        drop(permit);
1249                    }
1250                })
1251            })
1252            .collect();
1253        for thread in threads {
1254            thread.join().expect("admission worker must not panic");
1255        }
1256        assert!(
1257            peak.load(Ordering::SeqCst) <= 2,
1258            "admission let {} connections run against a 2-permit gate",
1259            peak.load(Ordering::SeqCst)
1260        );
1261        assert_eq!(in_flight.load(Ordering::SeqCst), 0);
1262    }
1263
1264    #[test]
1265    fn admission_blocks_once_every_permit_is_held() {
1266        let gate = DestinationGate::new(2);
1267        let first = gate.acquire();
1268        let second = gate.acquire();
1269        let waiter = {
1270            let gate = Arc::clone(&gate);
1271            std::thread::spawn(move || {
1272                let permit = gate.acquire();
1273                drop(permit);
1274            })
1275        };
1276        // The third acquire has nothing to take until a permit comes back.
1277        std::thread::sleep(std::time::Duration::from_millis(50));
1278        assert!(!waiter.is_finished());
1279        drop(first);
1280        waiter
1281            .join()
1282            .expect("waiter must be admitted once a permit frees");
1283        drop(second);
1284    }
1285}