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    args.push(ssh.destination.clone());
44    args.push(join_remote_command(&remote_args));
45    CommandSpec::new("ssh", args).ssh_destination(ssh.destination.clone())
46}
47
48pub fn join_remote_command(args: &[String]) -> String {
49    args.iter()
50        .map(|arg| posix_quote(arg))
51        .collect::<Vec<_>>()
52        .join(" ")
53}
54
55/// Complete remote directory paths through the configured SSH target.
56///
57/// The SSH connection timeout and noninteractive mode keep a Tab press from
58/// blocking the wizard when a host is unavailable. The quoted prefix remains
59/// literal while the trailing glob is expanded only by the remote shell.
60pub fn ssh_directory_completions(
61    ssh: &SshTarget,
62    prefix: &str,
63    executor: &impl CommandExecutor,
64) -> Result<Vec<String>> {
65    if prefix.is_empty() {
66        return Ok(Vec::new());
67    }
68    let remote_command = format!("ls -d -- {}*/ 2>/dev/null", posix_quote(prefix));
69    let mut args = ssh.ssh_args.clone();
70    args.extend([
71        "-o".into(),
72        "BatchMode=yes".into(),
73        "-o".into(),
74        "ConnectTimeout=3".into(),
75        "-o".into(),
76        "ServerAliveInterval=2".into(),
77        "-o".into(),
78        "ServerAliveCountMax=1".into(),
79        ssh.destination.clone(),
80        remote_command,
81    ]);
82    let output = executor.execute(
83        &CommandSpec::new("ssh", args)
84            .ssh_destination(ssh.destination.clone())
85            .purpose("complete remote mount directory"),
86    )?;
87    if output.status != 0 {
88        return Ok(Vec::new());
89    }
90    let mut matches = String::from_utf8_lossy(&output.stdout)
91        .lines()
92        .filter(|path| path.starts_with(prefix) && path.ends_with('/'))
93        .map(str::to_owned)
94        .collect::<Vec<_>>();
95    matches.sort();
96    matches.dedup();
97    Ok(matches)
98}
99
100/// Check whether a directory exists on the configured SSH host.
101pub fn ssh_directory_exists(
102    ssh: &SshTarget,
103    path: &Path,
104    executor: &impl CommandExecutor,
105) -> Result<bool> {
106    let command = ssh_validation_command(
107        ssh,
108        vec![
109            "test".into(),
110            "-d".into(),
111            path.to_string_lossy().into_owned(),
112        ],
113        "validate remote directory",
114    );
115    let output = executor.execute(&command)?;
116    match output.status {
117        0 => Ok(true),
118        1 => Ok(false),
119        status => bail!(
120            "remote directory check failed with status {status}: {}",
121            String::from_utf8_lossy(&output.stderr).trim()
122        ),
123    }
124}
125
126/// Verify that a bare-SSH project path exists and has a committed Git HEAD.
127pub fn validate_bare_project_directory(
128    ssh: &SshTarget,
129    path: &Path,
130    executor: &impl CommandExecutor,
131) -> Result<()> {
132    validate_bare_project_path(path)?;
133    if !ssh_directory_exists(ssh, path, executor)? {
134        bail!(
135            "remote project directory {} does not exist or is not a directory",
136            path.display()
137        );
138    }
139    let output = executor.execute(&ssh_validation_command(
140        ssh,
141        vec![
142            "git".into(),
143            "-C".into(),
144            path.to_string_lossy().into_owned(),
145            "rev-parse".into(),
146            "--verify".into(),
147            "HEAD".into(),
148        ],
149        "validate bare SSH Git project",
150    ))?;
151    if output.status != 0 {
152        let detail = String::from_utf8_lossy(&output.stderr);
153        let detail = detail.trim();
154        if detail.is_empty() {
155            bail!(
156                "remote project directory {} has no valid Git HEAD",
157                path.display()
158            );
159        }
160        bail!(
161            "remote project directory {} has no valid Git HEAD: {detail}",
162            path.display()
163        );
164    }
165    Ok(())
166}
167
168pub fn validate_bare_project_path(path: &Path) -> Result<()> {
169    if !path.is_absolute()
170        || path
171            .components()
172            .any(|part| part == std::path::Component::ParentDir)
173    {
174        bail!("bare project directory must be an absolute safe path");
175    }
176    Ok(())
177}
178
179pub fn ssh_validation_command(
180    ssh: &SshTarget,
181    remote_args: Vec<String>,
182    purpose: &'static str,
183) -> CommandSpec {
184    let mut args = ssh.ssh_args.clone();
185    args.extend([
186        "-o".into(),
187        "BatchMode=yes".into(),
188        "-o".into(),
189        "ConnectTimeout=3".into(),
190        "-o".into(),
191        "ServerAliveInterval=2".into(),
192        "-o".into(),
193        "ServerAliveCountMax=1".into(),
194        ssh.destination.clone(),
195        join_remote_command(&remote_args),
196    ]);
197    CommandSpec::new("ssh", args)
198        .ssh_destination(ssh.destination.clone())
199        .purpose(purpose)
200}
201
202/// Wrap a value so a POSIX shell reads it as one literal argument. Used at the
203/// SSH boundary here and when Hel rebuilds an agent's terminal command line
204/// (`terminal::shell_line`).
205pub fn posix_quote(value: &str) -> String {
206    format!("'{}'", value.replace('\'', "'\\''"))
207}
208
209pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
210    let expected_name = resource_name(session_id)?;
211    match locator {
212        TargetLocator::LocalBare { worker_root } => {
213            let path = Path::new(worker_root);
214            if !path.is_absolute()
215                || path
216                    .components()
217                    .any(|part| part == std::path::Component::ParentDir)
218                || !path.ends_with(session_id)
219            {
220                bail!("refusing cleanup: invalid local bare worker root");
221            }
222        }
223        TargetLocator::LocalPodman { container_id, .. }
224        | TargetLocator::LocalDocker { container_id }
225        | TargetLocator::AppleContainer { container_id }
226        | TargetLocator::SshPodman { container_id, .. }
227        | TargetLocator::SshDocker { container_id, .. } => {
228            if container_id != &expected_name && !is_runtime_container_id(container_id) {
229                bail!(
230                    "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
231                );
232            }
233        }
234        TargetLocator::AwsEc2 {
235            instance_id,
236            workspace,
237            ..
238        } => {
239            if !valid_ec2_instance_id(instance_id) {
240                bail!("refusing cleanup: invalid EC2 instance ID");
241            }
242            verify_session_workspace(workspace, session_id)?;
243        }
244        TargetLocator::SshBare {
245            workspace,
246            worker_id,
247            ..
248        } => match worker_id {
249            Some(worker_id) => {
250                validate_session_id(worker_id)?;
251                if worker_id != session_id {
252                    bail!("refusing cleanup: SSH worker identity does not match session ID");
253                }
254                validate_workspace_prefix(workspace)?;
255            }
256            None => verify_session_workspace(workspace, session_id)?,
257        },
258    }
259    Ok(())
260}
261
262pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
263    validate_workspace_prefix(workspace)?;
264    let final_component = workspace.trim_end_matches('/').rsplit('/').next();
265    if final_component != Some(session_id) {
266        bail!("refusing cleanup: workspace does not end in the exact session ID");
267    }
268    Ok(())
269}
270
271pub fn validate_session_id(value: &str) -> Result<()> {
272    if value.len() < 8
273        || value.len() > 128
274        || !value
275            .chars()
276            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
277    {
278        bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
279    }
280    Ok(())
281}
282
283pub fn validate_relative_path(value: &str) -> Result<()> {
284    let path = std::path::Path::new(value);
285    if value.is_empty()
286        || path.is_absolute()
287        || path
288            .components()
289            .any(|part| !matches!(part, std::path::Component::Normal(_)))
290    {
291        bail!("unsafe relative bundle path {value:?}");
292    }
293    Ok(())
294}
295
296pub fn validate_workspace_prefix(value: &str) -> Result<()> {
297    if value.is_empty()
298        || value == "/"
299        || value == "~"
300        || value == "~/"
301        || value.contains('\0')
302        || value.split('/').any(|part| part == "..")
303    {
304        bail!("unsafe workspace path");
305    }
306    Ok(())
307}
308
309pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
310    if template.image.trim().is_empty() || template.image.starts_with('-') {
311        bail!("invalid container image");
312    }
313    if template
314        .extra_run_args
315        .iter()
316        .any(|arg| arg == "--name" || arg.starts_with("--name="))
317    {
318        bail!("container template may not override the generated name");
319    }
320    if template.extra_run_args.iter().any(|arg| {
321        arg == "--label"
322            || [SESSION_LABEL, MANAGED_LABEL]
323                .iter()
324                .any(|label| arg.starts_with(&format!("--label={label}=")))
325    }) {
326        bail!("container template may not override Mjolnir ownership labels");
327    }
328    Ok(())
329}
330
331pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
332    if ssh.destination.trim().is_empty()
333        || ssh.destination.starts_with('-')
334        || ssh.destination.chars().any(char::is_whitespace)
335    {
336        bail!("invalid SSH destination");
337    }
338    Ok(())
339}
340
341pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
342    validate_ssh(&aws.ssh)?;
343    for (name, value) in [
344        ("AWS profile", &aws.profile),
345        ("AWS region", &aws.region),
346        ("launch template", &aws.launch_template),
347    ] {
348        if value.is_empty()
349            || value.starts_with('-')
350            || !value
351                .chars()
352                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
353        {
354            bail!("invalid {name}");
355        }
356    }
357    Ok(())
358}
359
360pub fn validate_executable(value: &str) -> Result<()> {
361    if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
362        bail!("invalid executable name");
363    }
364    Ok(())
365}
366
367pub fn valid_ec2_instance_id(value: &str) -> bool {
368    value
369        .strip_prefix("i-")
370        .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
371}
372
373pub fn is_runtime_container_id(value: &str) -> bool {
374    value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
375}
376
377/// `ssh` reserves exit status 255 for its own transport failures; a remote
378/// command never produces it, so the remote side provably never ran.
379pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
380
381/// Stderr fragments OpenSSH prints when the server hangs up before
382/// authentication. `sshd`'s `MaxStartups` produces exactly these when it drops
383/// an unauthenticated connection, and so does a server that is still starting.
384const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
385    "Connection closed by",
386    "Connection reset by",
387    "kex_exchange_identification",
388    "Connection timed out during banner exchange",
389];
390
391/// Whether a finished `ssh` process was turned away by the transport rather
392/// than by the remote command.
393///
394/// The remote command never started in this case, so the caller may retry the
395/// whole invocation without worrying about repeating a side effect.
396pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
397    status == SSH_TRANSPORT_EXIT_STATUS
398        && TRANSPORT_REJECTION_MARKERS
399            .iter()
400            .any(|marker| stderr.contains(marker))
401}
402
403/// Default number of `ssh` processes this daemon will have in flight against
404/// one destination at a time.
405///
406/// `sshd` counts *unauthenticated* connections against `MaxStartups`, whose
407/// stock value is `10:30:100`: from the eleventh concurrent pre-auth connection
408/// it starts dropping them, and past a hundred it drops all of them. A daemon
409/// that spawns one fresh `ssh` per operation reaches that during startup, so it
410/// admits its own connections instead of letting the server refuse them.
411const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
412
413/// Environment override for [`DEFAULT_MAX_CONCURRENT_SSH`].
414pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
415
416fn max_concurrent_ssh() -> usize {
417    static LIMIT: OnceLock<usize> = OnceLock::new();
418    *LIMIT.get_or_init(|| {
419        let Some(raw) = std::env::var_os(MAX_CONCURRENT_SSH_ENV) else {
420            return DEFAULT_MAX_CONCURRENT_SSH;
421        };
422        match raw
423            .to_str()
424            .and_then(|value| value.trim().parse::<usize>().ok())
425        {
426            Some(limit) if limit > 0 => limit,
427            _ => {
428                tracing::warn!(
429                    variable = MAX_CONCURRENT_SSH_ENV,
430                    value = %raw.to_string_lossy(),
431                    default = DEFAULT_MAX_CONCURRENT_SSH,
432                    "ignoring invalid SSH concurrency limit"
433                );
434                DEFAULT_MAX_CONCURRENT_SSH
435            }
436        }
437    })
438}
439
440/// A counting semaphore per SSH destination.
441///
442/// Deliberately built on `std::sync` rather than a runtime primitive: the
443/// blocking process executors are called from plain threads as well as from
444/// `spawn_blocking`, and both must share one gate.
445struct DestinationGate {
446    limit: usize,
447    in_flight: Mutex<usize>,
448    released: Condvar,
449}
450
451impl DestinationGate {
452    fn new(limit: usize) -> Arc<Self> {
453        Arc::new(Self {
454            limit,
455            in_flight: Mutex::new(0),
456            released: Condvar::new(),
457        })
458    }
459
460    fn acquire(self: &Arc<Self>) -> SshPermit {
461        let mut in_flight = self
462            .in_flight
463            .lock()
464            .unwrap_or_else(std::sync::PoisonError::into_inner);
465        while *in_flight >= self.limit {
466            in_flight = self
467                .released
468                .wait(in_flight)
469                .unwrap_or_else(std::sync::PoisonError::into_inner);
470        }
471        *in_flight += 1;
472        drop(in_flight);
473        SshPermit {
474            gate: Arc::clone(self),
475        }
476    }
477}
478
479/// One admitted `ssh` connection. The slot is returned on drop.
480pub struct SshPermit {
481    gate: Arc<DestinationGate>,
482}
483
484impl std::fmt::Debug for SshPermit {
485    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        formatter.write_str("SshPermit")
487    }
488}
489
490impl Drop for SshPermit {
491    fn drop(&mut self) {
492        let mut in_flight = self
493            .gate
494            .in_flight
495            .lock()
496            .unwrap_or_else(std::sync::PoisonError::into_inner);
497        *in_flight = in_flight.saturating_sub(1);
498        drop(in_flight);
499        self.gate.released.notify_one();
500    }
501}
502
503/// Process-wide admission control for outbound `ssh` connections.
504pub struct SshAdmission;
505
506impl SshAdmission {
507    /// Block until this process may open another `ssh` connection to
508    /// `destination`. The returned permit holds the slot until it is dropped.
509    pub fn acquire(destination: &str) -> SshPermit {
510        Self::gate(destination).acquire()
511    }
512
513    fn gate(destination: &str) -> Arc<DestinationGate> {
514        static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
515        let mut gates = GATES
516            .get_or_init(|| Mutex::new(BTreeMap::new()))
517            .lock()
518            .unwrap_or_else(std::sync::PoisonError::into_inner);
519        Arc::clone(
520            gates
521                .entry(destination.to_owned())
522                .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
523        )
524    }
525}
526
527/// How many times a transport-rejected `ssh` invocation is tried in total.
528pub const SSH_RETRY_ATTEMPTS: usize = 3;
529
530/// Inclusive millisecond bounds the jittered delay is drawn from, indexed by
531/// the number of attempts already made. `sshd` sheds load for as long as its
532/// pre-auth queue stays full, so the second wait is a multiple of the first.
533const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
534
535/// Test override collapsing every retry delay to this many milliseconds.
536/// `u64::MAX` means "no override".
537static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
538
539/// Shorten the retry backoff so tests can drive the retry path without
540/// sleeping for seconds. Not part of the daemon's behaviour.
541#[doc(hidden)]
542pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
543    SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
544        delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
545        Ordering::Relaxed,
546    );
547}
548
549/// The jittered wait before retry number `attempts_made + 1`.
550///
551/// Jitter matters more than the mean here: every session's reconnect fails at
552/// the same instant, so an unjittered schedule would simply re-send the whole
553/// burst into the same full queue.
554pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
555    let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
556    if override_ms != u64::MAX {
557        return Duration::from_millis(override_ms);
558    }
559    let (low, high) = SSH_RETRY_BACKOFF_MS
560        .get(attempts_made.saturating_sub(1))
561        .copied()
562        .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
563    let mut bytes = [0_u8; 8];
564    // A failed draw only costs jitter, so fall back to the lower bound.
565    let spread = if getrandom::fill(&mut bytes).is_ok() {
566        u64::from_le_bytes(bytes) % (high - low + 1)
567    } else {
568        0
569    };
570    Duration::from_millis(low + spread)
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use std::sync::atomic::{AtomicUsize, Ordering};
577
578    #[test]
579    fn transport_rejection_matches_only_sshd_hangups() {
580        let cases: [(i32, &str, bool); 7] = [
581            (255, "Connection closed by 192.168.1.77 port 22", true),
582            (
583                255,
584                "kex_exchange_identification: read: Connection reset by peer",
585                true,
586            ),
587            (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
588            (255, "Connection timed out during banner exchange", true),
589            (255, "Permission denied (publickey).", false),
590            (
591                255,
592                "ssh: connect to host h port 22: Connection refused",
593                false,
594            ),
595            (1, "Connection closed by 192.168.1.77 port 22", false),
596        ];
597        for (status, stderr, expected) in cases {
598            assert_eq!(
599                is_transport_rejection(status, stderr),
600                expected,
601                "status {status} stderr {stderr:?}"
602            );
603        }
604    }
605
606    #[test]
607    fn admission_never_admits_more_than_the_limit() {
608        let gate = DestinationGate::new(2);
609        let in_flight = Arc::new(AtomicUsize::new(0));
610        let peak = Arc::new(AtomicUsize::new(0));
611        let threads: Vec<_> = (0..12)
612            .map(|_| {
613                let gate = Arc::clone(&gate);
614                let in_flight = Arc::clone(&in_flight);
615                let peak = Arc::clone(&peak);
616                std::thread::spawn(move || {
617                    for _ in 0..25 {
618                        let permit = gate.acquire();
619                        let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
620                        peak.fetch_max(now, Ordering::SeqCst);
621                        std::thread::yield_now();
622                        in_flight.fetch_sub(1, Ordering::SeqCst);
623                        drop(permit);
624                    }
625                })
626            })
627            .collect();
628        for thread in threads {
629            thread.join().expect("admission worker must not panic");
630        }
631        assert!(
632            peak.load(Ordering::SeqCst) <= 2,
633            "admission let {} connections run against a 2-permit gate",
634            peak.load(Ordering::SeqCst)
635        );
636        assert_eq!(in_flight.load(Ordering::SeqCst), 0);
637    }
638
639    #[test]
640    fn admission_blocks_once_every_permit_is_held() {
641        let gate = DestinationGate::new(2);
642        let first = gate.acquire();
643        let second = gate.acquire();
644        let waiter = {
645            let gate = Arc::clone(&gate);
646            std::thread::spawn(move || {
647                let permit = gate.acquire();
648                drop(permit);
649            })
650        };
651        // The third acquire has nothing to take until a permit comes back.
652        std::thread::sleep(std::time::Duration::from_millis(50));
653        assert!(!waiter.is_finished());
654        drop(first);
655        waiter
656            .join()
657            .expect("waiter must be admitted once a permit frees");
658        drop(second);
659    }
660}