Skip to main content

mj_core/targets/
ssh.rs

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