use std::process::Command;
pub(crate) fn cmd_bootstrap(
addr: &str,
user: &str,
password_stdin: bool,
ssh_key_path: Option<&str>,
hostname: Option<&str>,
skip_key_if_working: bool,
) -> Result<(), String> {
if skip_key_if_working && key_auth_works(addr, user) {
eprintln!("SSH key auth already works for {user}@{addr}, skipping key copy");
} else {
copy_ssh_key(addr, user, password_stdin, ssh_key_path)?;
}
if !key_auth_works(addr, user) {
return Err(format!(
"SSH key auth failed for {user}@{addr} — check your key and try again"
));
}
eprintln!("SSH key auth: OK");
if user != "root" {
configure_sudo(addr, user)?;
}
if user != "root" && !sudo_works(addr, user) {
return Err(format!(
"passwordless sudo verification failed for {user}@{addr}"
));
}
eprintln!("Passwordless sudo: OK");
if let Some(name) = hostname {
set_hostname(addr, user, name)?;
}
println!("Machine {user}@{addr} bootstrapped. Run: forjar apply -f <config.yaml>");
Ok(())
}
fn key_auth_works(addr: &str, user: &str) -> bool {
Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
"-o",
"StrictHostKeyChecking=accept-new",
&format!("{user}@{addr}"),
"true",
])
.output()
.is_ok_and(|out| out.status.success())
}
fn copy_ssh_key(
addr: &str,
user: &str,
password_stdin: bool,
ssh_key_path: Option<&str>,
) -> Result<(), String> {
let pub_key = resolve_pub_key(ssh_key_path)?;
if password_stdin {
let password = read_password_stdin()?;
let status = Command::new("sshpass")
.args([
"-p",
&password,
"ssh-copy-id",
"-o",
"StrictHostKeyChecking=accept-new",
"-i",
&pub_key,
&format!("{user}@{addr}"),
])
.status()
.map_err(|e| format!("sshpass not found (install: apt install sshpass): {e}"))?;
if !status.success() {
return Err("ssh-copy-id failed — check password and connectivity".to_string());
}
} else {
let status = Command::new("ssh-copy-id")
.args([
"-o",
"StrictHostKeyChecking=accept-new",
"-i",
&pub_key,
&format!("{user}@{addr}"),
])
.status()
.map_err(|e| format!("ssh-copy-id failed: {e}"))?;
if !status.success() {
return Err("ssh-copy-id failed".to_string());
}
}
eprintln!("SSH key copied to {user}@{addr}");
Ok(())
}
fn resolve_pub_key(ssh_key_path: Option<&str>) -> Result<String, String> {
if let Some(path) = ssh_key_path {
return Ok(path.to_string());
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let ed25519 = format!("{home}/.ssh/id_ed25519.pub");
if std::path::Path::new(&ed25519).exists() {
return Ok(ed25519);
}
let rsa = format!("{home}/.ssh/id_rsa.pub");
if std::path::Path::new(&rsa).exists() {
return Ok(rsa);
}
Err("no SSH public key found (~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub) — generate one with: ssh-keygen -t ed25519".to_string())
}
fn read_password_stdin() -> Result<String, String> {
let mut password = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut password)
.map_err(|e| format!("failed to read password from stdin: {e}"))?;
Ok(password.trim().to_string())
}
fn configure_sudo(addr: &str, user: &str) -> Result<(), String> {
let sudoers_line = format!("{user} ALL=(ALL) NOPASSWD:ALL");
let sudoers_file = format!("/etc/sudoers.d/{user}-nopasswd");
let script = format!(
"echo '{sudoers_line}' | sudo tee '{sudoers_file}' > /dev/null && sudo chmod 0440 '{sudoers_file}'"
);
let output = Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
&format!("{user}@{addr}"),
&script,
])
.output()
.map_err(|e| format!("SSH failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("sudo configuration failed: {stderr}"));
}
eprintln!("Passwordless sudo configured for {user}");
Ok(())
}
fn sudo_works(addr: &str, user: &str) -> bool {
Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
&format!("{user}@{addr}"),
"sudo -n true",
])
.output()
.is_ok_and(|out| out.status.success())
}
fn set_hostname(addr: &str, user: &str, hostname: &str) -> Result<(), String> {
let script = format!(
"sudo hostnamectl set-hostname '{hostname}' 2>/dev/null || echo '{hostname}' | sudo tee /etc/hostname > /dev/null"
);
let output = Command::new("ssh")
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
&format!("{user}@{addr}"),
&script,
])
.output()
.map_err(|e| format!("set hostname failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("set hostname failed: {stderr}"));
}
eprintln!("Hostname set to {hostname}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_pub_key_explicit() {
let result = resolve_pub_key(Some("/tmp/test.pub"));
assert_eq!(result.unwrap(), "/tmp/test.pub");
}
#[test]
fn test_resolve_pub_key_default() {
let _result = resolve_pub_key(None);
}
#[test]
fn test_key_auth_nonexistent_host() {
assert!(!key_auth_works("192.168.255.254", "nobody"));
}
}