//! Prepare [cloud-init][] configuration. //! //! The configuration is provided to cloud-init via the "NoCloud" //! route, using an ISO image file. //! //! [cloud-init]: https://cloud-init.io/ use crate::spec::{Specification, SpecificationError}; use crate::sshkeys::{CaKey, KeyError, KeyKind, KeyPair}; use log::debug; use serde::{Deserialize, Serialize}; use shell_words::quote; use std::default::Default; use std::fs::write; // use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::tempdir; const SCRIPT: &str = include!(concat!(env!("OUT_DIR"), "/cloud-init.rs")); /// Errors from this module. #[derive(Debug, thiserror::Error)] pub enum CloudInitError { /// CA key is not specified. #[error("Host certificate requested, but no CA key specified")] NoCAKey, /// Something went wrong creating ISO image with configuration. #[error("failed to create ISO image with genisoimage: {0}")] IsoFailed(String), /// Error in the specification. #[error(transparent)] SpecificationError(#[from] SpecificationError), /// Error in generating SSH host key or certificate. #[error(transparent)] KeyError(#[from] KeyError), /// Something went wrong reading a file. #[error("could not read {0}: {1}")] ReadError(PathBuf, #[source] std::io::Error), /// Something went wrong doing I/O. #[error("could not write to {0}: {1}")] WriteError(PathBuf, #[source] std::io::Error), /// Something went wrong parsing a string as UTF8. #[error(transparent)] StringError(#[from] std::string::FromUtf8Error), /// Something went wrong parsing or serializing to YAML. #[error(transparent)] SerdeError(#[from] serde_yaml::Error), /// Could not create a temporary directory. #[error("couldn't create a temporary directory: {0}")] TempDirError(#[source] std::io::Error), /// Could not execute command. #[error("couldn't execute {0}: {1}")] CommandError(String, #[source] std::io::Error), } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "kebab-case")] struct Metadata { local_hostname: String, } impl Metadata { fn from(spec: &Specification) -> Self { Self { local_hostname: spec.name.to_string(), } } fn as_yaml(&self) -> Result { let yaml = serde_yaml::to_string(self)?; let yaml = cleanup_document(&yaml); Ok(format!("# Amazon EC2 style metadata\n{}", &yaml)) } } fn cleanup_document(doc: &str) -> String { let doc = doc.strip_prefix("---\n").unwrap_or(doc); let doc = doc.strip_suffix('\n').unwrap_or(doc); doc.to_string() } #[derive(Clone, Debug, Serialize)] struct Userdata { ssh_authorized_keys: Vec, #[serde(skip_serializing_if = "Option::is_none")] ssh_keys: Option, #[serde(skip_serializing_if = "Option::is_none")] user_ca_pubkey: Option, allow_authorized_keys: bool, runcmd: Vec, } impl Userdata { fn from(spec: &Specification) -> Result { let user_ca_pubkey = if let Some(filename) = &spec.user_ca_pubkey { let data = std::fs::read(filename) .map_err(|err| CloudInitError::ReadError(filename.to_path_buf(), err))?; Some(String::from_utf8(data)?) } else { None }; Ok(Self { ssh_authorized_keys: spec.ssh_keys.clone(), ssh_keys: Hostkeys::from(spec)?, user_ca_pubkey, allow_authorized_keys: spec.allow_authorized_keys, runcmd: vec![ format!("python3 -c {}", quote(SCRIPT)), "systemctl reload ssh".to_string(), ], }) } fn as_yaml(&self) -> Result { let yaml = serde_yaml::to_string(self)?; let yaml = cleanup_document(&yaml); debug!("userdata self:\n{:#?}", self); debug!("userdata yaml:\n{}", yaml); Ok(format!("#cloud-config\n{}", &yaml)) } } #[derive(Clone, Debug, Deserialize, Serialize, Default)] struct Hostkeys { #[serde(skip_serializing_if = "Option::is_none")] rsa_private: Option, #[serde(skip_serializing_if = "Option::is_none")] rsa_certificate: Option, #[serde(skip_serializing_if = "Option::is_none")] dsa_private: Option, #[serde(skip_serializing_if = "Option::is_none")] dsa_certificate: Option, #[serde(skip_serializing_if = "Option::is_none")] ecdsa_private: Option, #[serde(skip_serializing_if = "Option::is_none")] ecdsa_certificate: Option, #[serde(skip_serializing_if = "Option::is_none")] ed25519_private: Option, #[serde(skip_serializing_if = "Option::is_none")] ed25519_certificate: Option, } impl Hostkeys { fn from(spec: &Specification) -> Result, CloudInitError> { let rsa = spec.rsa_host_key.clone(); let rsa_cert = spec.rsa_host_cert.clone(); let dsa = spec.dsa_host_key.clone(); let dsa_cert = spec.dsa_host_cert.clone(); let ecdsa = spec.ecdsa_host_key.clone(); let ecdsa_cert = spec.ecdsa_host_cert.clone(); let ed25519 = spec.ed25519_host_key.clone(); let ed25519_cert = spec.ed25519_host_cert.clone(); if rsa.is_some() || dsa.is_some() || ecdsa.is_some() || ed25519.is_some() { debug!("At least one host key specified"); Ok(Some(Self { rsa_private: rsa, rsa_certificate: rsa_cert, dsa_private: dsa, dsa_certificate: dsa_cert, ecdsa_private: ecdsa, ecdsa_certificate: ecdsa_cert, ed25519_private: ed25519, ed25519_certificate: ed25519_cert, })) } else if spec.generate_host_certificate { if spec.ca_key.is_none() { debug!("No CA key specified"); return Err(CloudInitError::NoCAKey); } if let Some(filename) = &spec.ca_key { debug!("Generating host key and certificate"); let ca = CaKey::from_file(filename)?; let pair = KeyPair::generate(KeyKind::Ed25519)?; let cert = ca.certify_host(&pair, &spec.name)?; debug!("generated Ed25519 host certificate {:?}", cert); Ok(Some(Self { ed25519_private: Some(pair.private().to_string()), ed25519_certificate: Some(cert), ..Self::default() })) } else { Ok(None) } } else { debug!("No host keys specified, no host certificate wanted"); Ok(None) } } } /// Full cloud-init configuration. #[derive(Clone, Debug)] pub struct CloudInitConfig { metadata: Metadata, userdata: Userdata, } impl CloudInitConfig { /// Create from a specification. pub fn from(spec: &Specification) -> Result { let metadata = Metadata::from(spec); let userdata = Userdata::from(spec)?; Ok(CloudInitConfig { metadata, userdata }) } /// Debugging aid: dump cloud-init to stdout. pub fn dump(&self) { println!("==== meta-data:\n{}", self.metadata().unwrap()); println!("==== user-data:\n{}", self.userdata().unwrap()); } fn metadata(&self) -> Result { self.metadata.as_yaml() } fn userdata(&self) -> Result { self.userdata.as_yaml() } /// Put cloud-init configuration into a named directory. /// /// The files `meta-data` and `user-data` will be stored in the /// directory. cloud-init reads them on first boot and makes /// appropriate changes the host's configuration. pub fn create_dir(&self, path: &Path) -> Result<(), CloudInitError> { let metadata = path.join("meta-data"); debug!("writing metadata to {}", metadata.display()); write(&metadata, self.metadata()?) .map_err(|err| CloudInitError::WriteError(metadata, err))?; let userdata = path.join("user-data"); debug!("writing userdata to {}", userdata.display()); write(&userdata, self.userdata()?) .map_err(|err| CloudInitError::WriteError(userdata, err))?; // let scriptsdir = path.join("scripts/per-once"); // let script = scriptsdir.join("vmadm-configure-sshd"); // std::fs::create_dir_all(&scriptsdir)?; // debug!("writing script to {}", script.display()); // write(&script, SCRIPT)?; // let mut permissions = std::fs::metadata(&script)?.permissions(); // permissions.set_mode(0o755); // debug!("setting permissions on {:?} to {:?}", script, permissions); // std::fs::set_permissions(&script, permissions)?; Ok(()) } /// Create an ISO disk image file with the cloud-init configuration. /// /// The image will be attached to the VM when it starts. /// cloud-init finds it via the volume ID (file system label). pub fn create_iso(&self, filename: &Path) -> Result<(), CloudInitError> { let dir = match tempdir() { Ok(path) => path, Err(err) => return Err(CloudInitError::TempDirError(err)), }; self.create_dir(dir.path())?; let r = match Command::new("genisoimage") .arg("-quiet") .arg("-volid") .arg("cidata") .arg("-joliet") .arg("-rock") .arg("-output") .arg(filename) .arg(dir.path()) .output() { Ok(r) => r, Err(err) => return Err(CloudInitError::CommandError("genisoimage".to_string(), err)), }; if !r.status.success() { let stderr = String::from_utf8(r.stderr)?; return Err(CloudInitError::IsoFailed(stderr)); } Ok(()) } }