Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403use std::net::SocketAddr;use std::path::{Path, PathBuf};use std::time::Duration;
use anyhow::{Result, bail};
use crate::instance_store;use crate::process;
/// SSH client binary. On macOS the nix-store `ssh` is blocked from the/// guest's vmnet subnet by Local Network Privacy (connect → EHOSTUNREACH);/// Apple's `/usr/bin/ssh` carries the local-network entitlement, so use the/// system tools there. On Linux, resolve from PATH as usual.#[cfg(target_os = "macos")]pub const SSH_PROGRAM: &str = "/usr/bin/ssh";#[cfg(not(target_os = "macos"))]pub const SSH_PROGRAM: &str = "ssh";
/// `ssh-keyscan` binary — same Local Network Privacy constraint as [`SSH_PROGRAM`].#[cfg(target_os = "macos")]pub const SSH_KEYSCAN_PROGRAM: &str = "/usr/bin/ssh-keyscan";#[cfg(not(target_os = "macos"))]pub const SSH_KEYSCAN_PROGRAM: &str = "ssh-keyscan";
/// How often `wait_for_ssh` retries. Kept well under a second so the ready/// time epi reports tracks the guest's actual boot rather than rounding up to/// the next poll.const SSH_POLL_INTERVAL: Duration = Duration::from_millis(200);
pub fn user() -> String { std::env::var("USER").unwrap_or_else(|_| "user".to_string())}
/// Returns the path to the SSH config file for an instance.pub fn config_path(instance: &str) -> PathBuf { instance_store::instance_path(instance, "ssh_config")}
/// Generate an SSH config file for an instance and write it to the given path.////// When `known_hosts` is `None`, host key checking is disabled (for initial boot)./// When `Some(path)`, strict host key checking is enabled using the given known_hosts file.pub fn generate_config( output: &Path, instance: &str, ssh: SocketAddr, username: &str, ssh_key_path: &Path, known_hosts: Option<&Path>, extra_config: &[String],) -> Result<()> { let (strict_checking, known_hosts_file) = match known_hosts { Some(path) => ("yes", path.display().to_string()), None => ("no", "/dev/null".to_string()), }; let mut contents = format!( "Host {instance}\n\ \x20 HostName {host}\n\ \x20 Port {port}\n\ \x20 User {username}\n\ \x20 IdentityFile {key}\n\ \x20 IdentitiesOnly yes\n\ \x20 StrictHostKeyChecking {strict_checking}\n\ \x20 UserKnownHostsFile {known_hosts_file}\n\ \x20 LogLevel ERROR\n", host = ssh.ip(), port = ssh.port(), key = ssh_key_path.display(), ); for line in extra_config { contents.push_str(&format!(" {line}\n")); } std::fs::write(output, &contents)?; Ok(())}
/// Returns the path to the known_hosts file for an instance.pub fn known_hosts_path(instance: &str) -> PathBuf { instance_store::instance_path(instance, "known_hosts")}
/// Record the guest's SSH host key using ssh-keyscan.////// Returns `true` if the key was successfully recorded, `false` if keyscan failed./// Does not return an error on keyscan failure — the caller can fall back to the/// untrusted config.pub fn record_host_key(ssh: SocketAddr, known_hosts: &Path) -> Result<bool> { let port_str = ssh.port().to_string(); let host_str = ssh.ip().to_string(); let out = process::run(SSH_KEYSCAN_PROGRAM, &["-p", &port_str, &host_str])?;
if !out.success() || out.stdout.is_empty() { return Ok(false); }
std::fs::write(known_hosts, &out.stdout)?; Ok(true)}
/// Record the guest host key and rewrite the SSH config to enable strict checking.////// If keyscan fails, logs a warning and leaves the untrusted config in place.pub fn trust_host_key( instance: &str, ssh: SocketAddr, username: &str, ssh_key_path: &Path, extra_config: &[String],) -> Result<()> { let known_hosts = known_hosts_path(instance); if record_host_key(ssh, &known_hosts)? { generate_config( &config_path(instance), instance, ssh, username, ssh_key_path, Some(&known_hosts), extra_config, )?; } else { eprintln!( "warning: could not record host key for {instance}, keeping untrusted SSH config" ); } Ok(())}
/// Copy a nix store closure to a running instance via SSH.pub fn nix_copy_closure(instance: &str, store_path: &str) -> Result<()> { let config = config_path(instance); // nix invokes ssh from PATH; NIX_SSHOPTS only carries options. On macOS // point it at the entitled system ssh via an absolute -o is not possible, // so prepend /usr/bin to PATH so `nix copy` picks up /usr/bin/ssh. let ssh_opts = format!("-F {}", config.display()); let target = format!("ssh://{instance}"); let mut env: Vec<(&str, &str)> = vec![("NIX_SSHOPTS", &ssh_opts)]; let patched_path; if cfg!(target_os = "macos") { let existing = std::env::var("PATH").unwrap_or_default(); patched_path = format!("/usr/bin:{existing}"); env.push(("PATH", &patched_path)); } let out = process::run_with_env("nix", &["copy", "--to", &target, store_path], &env)?; if !out.success() { bail!("nix copy failed (exit {}): {}", out.status, out.stderr); } Ok(())}
/// Run a command on a running instance via SSH.pub fn run_on_guest(instance: &str, command: &str) -> Result<()> { let config = config_path(instance); let config_str = config.to_string_lossy(); let out = process::run(SSH_PROGRAM, &["-F", &config_str, instance, "--", command])?; if !out.success() { bail!( "remote command failed (exit {}): {}", out.status, out.stderr ); } Ok(())}
/// Poll until SSH is reachable via the config file, or timeout.pub fn wait_for_ssh(config: &Path, instance: &str, timeout_seconds: u64) -> Result<()> { let start = std::time::Instant::now(); let timeout = Duration::from_secs(timeout_seconds); let config_str = config.to_string_lossy();
loop { if start.elapsed() >= timeout { bail!("SSH not reachable after {timeout_seconds}s — instance may still be booting"); }
let out = process::run( SSH_PROGRAM, &[ "-F", &config_str, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", instance, "true", ], )?;
if out.success() { return Ok(()); }
std::thread::sleep(SSH_POLL_INTERVAL); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn ssh_poll_interval_is_finer_than_boot_time_differences() { // A coarse interval quantises the ready time epi reports, hiding // guest boot improvements smaller than one poll. assert!(SSH_POLL_INTERVAL <= Duration::from_millis(250)); }
#[test] fn ssh_programs_are_system_tools_on_macos() { if cfg!(target_os = "macos") { assert_eq!(SSH_PROGRAM, "/usr/bin/ssh"); assert_eq!(SSH_KEYSCAN_PROGRAM, "/usr/bin/ssh-keyscan"); } else { assert_eq!(SSH_PROGRAM, "ssh"); assert_eq!(SSH_KEYSCAN_PROGRAM, "ssh-keyscan"); } }
#[test] fn test_generate_config_untrusted() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("ssh_config"); let key_path = Path::new("/home/adam/.epi/state/myvm/id_ed25519");
generate_config( &config, "myvm", "127.0.0.1:12345".parse().unwrap(), "adam", key_path, None, &[], ) .unwrap();
let contents = std::fs::read_to_string(&config).unwrap(); assert!(contents.starts_with("Host myvm\n")); assert!(contents.contains("HostName 127.0.0.1")); assert!(contents.contains("Port 12345")); assert!(contents.contains("User adam")); assert!(contents.contains(&format!("IdentityFile {}", key_path.display()))); assert!(contents.contains("IdentitiesOnly yes")); assert!(contents.contains("StrictHostKeyChecking no")); assert!(contents.contains("UserKnownHostsFile /dev/null")); assert!(contents.contains("LogLevel ERROR"));
// Verify it's valid SSH config format - all options indented under Host let lines: Vec<&str> = contents.lines().collect(); assert!(!lines[0].starts_with(' ')); for line in &lines[1..] { assert!(line.starts_with(" ")); } }
#[test] fn test_generate_config_never_contains_remote_command() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("ssh_config"); let key_path = Path::new("/home/adam/.epi/state/myvm/id_ed25519");
generate_config( &config, "myvm", "127.0.0.1:12345".parse().unwrap(), "adam", key_path, None, &[], ) .unwrap();
let contents = std::fs::read_to_string(&config).unwrap(); assert!(!contents.contains("RemoteCommand")); assert!(!contents.contains("RequestTTY")); }
#[test] fn test_generate_config_with_extra_lines() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("ssh_config"); let key_path = Path::new("/home/adam/.epi/state/myvm/id_ed25519");
let extra = vec![ "LocalForward /tmp/local.sock /tmp/remote.sock".to_string(), "ForwardAgent yes".to_string(), ]; generate_config( &config, "myvm", "127.0.0.1:12345".parse().unwrap(), "adam", key_path, None, &extra, ) .unwrap();
let contents = std::fs::read_to_string(&config).unwrap(); assert!(contents.contains(" LocalForward /tmp/local.sock /tmp/remote.sock\n")); assert!(contents.contains(" ForwardAgent yes\n"));
// Extra lines should come after the standard config let local_fwd_pos = contents.find("LocalForward").unwrap(); let log_level_pos = contents.find("LogLevel").unwrap(); assert!(local_fwd_pos > log_level_pos); }
#[test] fn test_generate_config_with_empty_extra_lines() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("ssh_config"); let key_path = Path::new("/home/adam/.epi/state/myvm/id_ed25519");
generate_config( &config, "myvm", "127.0.0.1:12345".parse().unwrap(), "adam", key_path, None, &[], ) .unwrap();
let contents = std::fs::read_to_string(&config).unwrap(); // Should be identical to the no-extra-config case assert!(contents.ends_with("LogLevel ERROR\n")); }
#[test] fn test_generate_config_writes_supplied_host() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("ssh_config"); let key_path = Path::new("/home/adam/.epi/state/myvm/id_ed25519");
generate_config( &config, "myvm", "192.168.64.42:22".parse().unwrap(), "adam", key_path, None, &[], ) .unwrap();
let contents = std::fs::read_to_string(&config).unwrap(); assert!(contents.contains("HostName 192.168.64.42")); assert!(contents.contains("Port 22")); assert!(!contents.contains("HostName 127.0.0.1")); }
#[test] fn test_generate_config_writes_ipv6_host() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("ssh_config"); let key_path = Path::new("/home/adam/.epi/state/myvm/id_ed25519");
generate_config( &config, "myvm", "[::1]:22".parse().unwrap(), "adam", key_path, None, &[], ) .unwrap();
let contents = std::fs::read_to_string(&config).unwrap(); assert!(contents.contains("HostName ::1")); assert!(contents.contains("Port 22")); }
#[test] fn test_generate_config_trusted() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("ssh_config"); let key_path = Path::new("/home/adam/.epi/state/myvm/id_ed25519"); let known_hosts = dir.path().join("known_hosts");
generate_config( &config, "myvm", "127.0.0.1:12345".parse().unwrap(), "adam", key_path, Some(&known_hosts), &[], ) .unwrap();
let contents = std::fs::read_to_string(&config).unwrap(); assert!(contents.contains("StrictHostKeyChecking yes")); assert!(contents.contains(&format!("UserKnownHostsFile {}", known_hosts.display()))); // Should NOT contain the untrusted values assert!(!contents.contains("StrictHostKeyChecking no")); assert!(!contents.contains("UserKnownHostsFile /dev/null")); }}