use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Condvar, Mutex, OnceLock};
use std::time::Duration;
pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
let mut probe = ssh.clone();
probe.ssh_args.splice(
0..0,
[
"-o".to_owned(),
"BatchMode=yes".to_owned(),
"-o".to_owned(),
"StrictHostKeyChecking=yes".to_owned(),
],
);
ssh_command(&probe, ["true"]).purpose("verify SSH connectivity")
}
pub fn ssh_command(
ssh: &SshTarget,
args: impl IntoIterator<Item = impl AsRef<str>>,
) -> CommandSpec {
ssh_command_owned(
ssh,
args.into_iter()
.map(|arg| arg.as_ref().to_owned())
.collect(),
)
}
pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
let mut args = ssh.ssh_args.clone();
args.push(ssh.destination.clone());
args.push(join_remote_command(&remote_args));
CommandSpec::new("ssh", args).ssh_destination(ssh.destination.clone())
}
pub fn join_remote_command(args: &[String]) -> String {
args.iter()
.map(|arg| posix_quote(arg))
.collect::<Vec<_>>()
.join(" ")
}
pub fn ssh_directory_completions(
ssh: &SshTarget,
prefix: &str,
executor: &impl CommandExecutor,
) -> Result<Vec<String>> {
if prefix.is_empty() {
return Ok(Vec::new());
}
let remote_command = format!("ls -d -- {}*/ 2>/dev/null", posix_quote(prefix));
let mut args = ssh.ssh_args.clone();
args.extend([
"-o".into(),
"BatchMode=yes".into(),
"-o".into(),
"ConnectTimeout=3".into(),
"-o".into(),
"ServerAliveInterval=2".into(),
"-o".into(),
"ServerAliveCountMax=1".into(),
ssh.destination.clone(),
remote_command,
]);
let output = executor.execute(
&CommandSpec::new("ssh", args)
.ssh_destination(ssh.destination.clone())
.purpose("complete remote mount directory"),
)?;
if output.status != 0 {
return Ok(Vec::new());
}
let mut matches = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|path| path.starts_with(prefix) && path.ends_with('/'))
.map(str::to_owned)
.collect::<Vec<_>>();
matches.sort();
matches.dedup();
Ok(matches)
}
pub fn ssh_directory_exists(
ssh: &SshTarget,
path: &Path,
executor: &impl CommandExecutor,
) -> Result<bool> {
let command = ssh_validation_command(
ssh,
vec![
"test".into(),
"-d".into(),
path.to_string_lossy().into_owned(),
],
"validate remote directory",
);
let output = executor.execute(&command)?;
match output.status {
0 => Ok(true),
1 => Ok(false),
status => bail!(
"remote directory check failed with status {status}: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
}
}
pub fn validate_bare_project_directory(
ssh: &SshTarget,
path: &Path,
executor: &impl CommandExecutor,
) -> Result<()> {
validate_bare_project_path(path)?;
if !ssh_directory_exists(ssh, path, executor)? {
bail!(
"remote project directory {} does not exist or is not a directory",
path.display()
);
}
let output = executor.execute(&ssh_validation_command(
ssh,
vec![
"git".into(),
"-C".into(),
path.to_string_lossy().into_owned(),
"rev-parse".into(),
"--verify".into(),
"HEAD".into(),
],
"validate bare SSH Git project",
))?;
if output.status != 0 {
let detail = String::from_utf8_lossy(&output.stderr);
let detail = detail.trim();
if detail.is_empty() {
bail!(
"remote project directory {} has no valid Git HEAD",
path.display()
);
}
bail!(
"remote project directory {} has no valid Git HEAD: {detail}",
path.display()
);
}
Ok(())
}
pub fn validate_bare_project_path(path: &Path) -> Result<()> {
if !path.is_absolute()
|| path
.components()
.any(|part| part == std::path::Component::ParentDir)
{
bail!("bare project directory must be an absolute safe path");
}
Ok(())
}
pub fn ssh_validation_command(
ssh: &SshTarget,
remote_args: Vec<String>,
purpose: &'static str,
) -> CommandSpec {
let mut args = ssh.ssh_args.clone();
args.extend([
"-o".into(),
"BatchMode=yes".into(),
"-o".into(),
"ConnectTimeout=3".into(),
"-o".into(),
"ServerAliveInterval=2".into(),
"-o".into(),
"ServerAliveCountMax=1".into(),
ssh.destination.clone(),
join_remote_command(&remote_args),
]);
CommandSpec::new("ssh", args)
.ssh_destination(ssh.destination.clone())
.purpose(purpose)
}
pub fn posix_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
let expected_name = resource_name(session_id)?;
match locator {
TargetLocator::LocalBare { worker_root } => {
let path = Path::new(worker_root);
if !path.is_absolute()
|| path
.components()
.any(|part| part == std::path::Component::ParentDir)
|| !path.ends_with(session_id)
{
bail!("refusing cleanup: invalid local bare worker root");
}
}
TargetLocator::LocalPodman { container_id, .. }
| TargetLocator::LocalDocker { container_id }
| TargetLocator::AppleContainer { container_id }
| TargetLocator::SshPodman { container_id, .. }
| TargetLocator::SshDocker { container_id, .. } => {
if container_id != &expected_name && !is_runtime_container_id(container_id) {
bail!(
"refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
);
}
}
TargetLocator::AwsEc2 {
instance_id,
workspace,
..
} => {
if !valid_ec2_instance_id(instance_id) {
bail!("refusing cleanup: invalid EC2 instance ID");
}
verify_session_workspace(workspace, session_id)?;
}
TargetLocator::SshBare {
workspace,
worker_id,
..
} => match worker_id {
Some(worker_id) => {
validate_session_id(worker_id)?;
if worker_id != session_id {
bail!("refusing cleanup: SSH worker identity does not match session ID");
}
validate_workspace_prefix(workspace)?;
}
None => verify_session_workspace(workspace, session_id)?,
},
}
Ok(())
}
pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
validate_workspace_prefix(workspace)?;
let final_component = workspace.trim_end_matches('/').rsplit('/').next();
if final_component != Some(session_id) {
bail!("refusing cleanup: workspace does not end in the exact session ID");
}
Ok(())
}
pub fn validate_session_id(value: &str) -> Result<()> {
if value.len() < 8
|| value.len() > 128
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
{
bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
}
Ok(())
}
pub fn validate_relative_path(value: &str) -> Result<()> {
let path = std::path::Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|part| !matches!(part, std::path::Component::Normal(_)))
{
bail!("unsafe relative bundle path {value:?}");
}
Ok(())
}
pub fn validate_workspace_prefix(value: &str) -> Result<()> {
if value.is_empty()
|| value == "/"
|| value == "~"
|| value == "~/"
|| value.contains('\0')
|| value.split('/').any(|part| part == "..")
{
bail!("unsafe workspace path");
}
Ok(())
}
pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
if template.image.trim().is_empty() || template.image.starts_with('-') {
bail!("invalid container image");
}
if template
.extra_run_args
.iter()
.any(|arg| arg == "--name" || arg.starts_with("--name="))
{
bail!("container template may not override the generated name");
}
if template.extra_run_args.iter().any(|arg| {
arg == "--label"
|| [SESSION_LABEL, MANAGED_LABEL]
.iter()
.any(|label| arg.starts_with(&format!("--label={label}=")))
}) {
bail!("container template may not override Mjolnir ownership labels");
}
Ok(())
}
pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
if ssh.destination.trim().is_empty()
|| ssh.destination.starts_with('-')
|| ssh.destination.chars().any(char::is_whitespace)
{
bail!("invalid SSH destination");
}
Ok(())
}
pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
validate_ssh(&aws.ssh)?;
for (name, value) in [
("AWS profile", &aws.profile),
("AWS region", &aws.region),
("launch template", &aws.launch_template),
] {
if value.is_empty()
|| value.starts_with('-')
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
{
bail!("invalid {name}");
}
}
Ok(())
}
pub fn validate_executable(value: &str) -> Result<()> {
if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
bail!("invalid executable name");
}
Ok(())
}
pub fn valid_ec2_instance_id(value: &str) -> bool {
value
.strip_prefix("i-")
.is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
}
pub fn is_runtime_container_id(value: &str) -> bool {
value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
}
pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
"Connection closed by",
"Connection reset by",
"kex_exchange_identification",
"Connection timed out during banner exchange",
];
pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
status == SSH_TRANSPORT_EXIT_STATUS
&& TRANSPORT_REJECTION_MARKERS
.iter()
.any(|marker| stderr.contains(marker))
}
const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
fn max_concurrent_ssh() -> usize {
static LIMIT: OnceLock<usize> = OnceLock::new();
*LIMIT.get_or_init(|| {
let Some(raw) = std::env::var_os(MAX_CONCURRENT_SSH_ENV) else {
return DEFAULT_MAX_CONCURRENT_SSH;
};
match raw
.to_str()
.and_then(|value| value.trim().parse::<usize>().ok())
{
Some(limit) if limit > 0 => limit,
_ => {
tracing::warn!(
variable = MAX_CONCURRENT_SSH_ENV,
value = %raw.to_string_lossy(),
default = DEFAULT_MAX_CONCURRENT_SSH,
"ignoring invalid SSH concurrency limit"
);
DEFAULT_MAX_CONCURRENT_SSH
}
}
})
}
struct DestinationGate {
limit: usize,
in_flight: Mutex<usize>,
released: Condvar,
}
impl DestinationGate {
fn new(limit: usize) -> Arc<Self> {
Arc::new(Self {
limit,
in_flight: Mutex::new(0),
released: Condvar::new(),
})
}
fn acquire(self: &Arc<Self>) -> SshPermit {
let mut in_flight = self
.in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while *in_flight >= self.limit {
in_flight = self
.released
.wait(in_flight)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
*in_flight += 1;
drop(in_flight);
SshPermit {
gate: Arc::clone(self),
}
}
}
pub struct SshPermit {
gate: Arc<DestinationGate>,
}
impl std::fmt::Debug for SshPermit {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("SshPermit")
}
}
impl Drop for SshPermit {
fn drop(&mut self) {
let mut in_flight = self
.gate
.in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*in_flight = in_flight.saturating_sub(1);
drop(in_flight);
self.gate.released.notify_one();
}
}
pub struct SshAdmission;
impl SshAdmission {
pub fn acquire(destination: &str) -> SshPermit {
Self::gate(destination).acquire()
}
fn gate(destination: &str) -> Arc<DestinationGate> {
static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
let mut gates = GATES
.get_or_init(|| Mutex::new(BTreeMap::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(
gates
.entry(destination.to_owned())
.or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
)
}
}
pub const SSH_RETRY_ATTEMPTS: usize = 3;
const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
#[doc(hidden)]
pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
Ordering::Relaxed,
);
}
pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
if override_ms != u64::MAX {
return Duration::from_millis(override_ms);
}
let (low, high) = SSH_RETRY_BACKOFF_MS
.get(attempts_made.saturating_sub(1))
.copied()
.unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
let mut bytes = [0_u8; 8];
let spread = if getrandom::fill(&mut bytes).is_ok() {
u64::from_le_bytes(bytes) % (high - low + 1)
} else {
0
};
Duration::from_millis(low + spread)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn transport_rejection_matches_only_sshd_hangups() {
let cases: [(i32, &str, bool); 7] = [
(255, "Connection closed by 192.168.1.77 port 22", true),
(
255,
"kex_exchange_identification: read: Connection reset by peer",
true,
),
(255, "ssh: Connection reset by 10.0.0.1 port 22", true),
(255, "Connection timed out during banner exchange", true),
(255, "Permission denied (publickey).", false),
(
255,
"ssh: connect to host h port 22: Connection refused",
false,
),
(1, "Connection closed by 192.168.1.77 port 22", false),
];
for (status, stderr, expected) in cases {
assert_eq!(
is_transport_rejection(status, stderr),
expected,
"status {status} stderr {stderr:?}"
);
}
}
#[test]
fn admission_never_admits_more_than_the_limit() {
let gate = DestinationGate::new(2);
let in_flight = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let threads: Vec<_> = (0..12)
.map(|_| {
let gate = Arc::clone(&gate);
let in_flight = Arc::clone(&in_flight);
let peak = Arc::clone(&peak);
std::thread::spawn(move || {
for _ in 0..25 {
let permit = gate.acquire();
let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
std::thread::yield_now();
in_flight.fetch_sub(1, Ordering::SeqCst);
drop(permit);
}
})
})
.collect();
for thread in threads {
thread.join().expect("admission worker must not panic");
}
assert!(
peak.load(Ordering::SeqCst) <= 2,
"admission let {} connections run against a 2-permit gate",
peak.load(Ordering::SeqCst)
);
assert_eq!(in_flight.load(Ordering::SeqCst), 0);
}
#[test]
fn admission_blocks_once_every_permit_is_held() {
let gate = DestinationGate::new(2);
let first = gate.acquire();
let second = gate.acquire();
let waiter = {
let gate = Arc::clone(&gate);
std::thread::spawn(move || {
let permit = gate.acquire();
drop(permit);
})
};
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(!waiter.is_finished());
drop(first);
waiter
.join()
.expect("waiter must be admitted once a permit frees");
drop(second);
}
}