//! 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; use std::process::Command; use tempfile::tempdir; const SCRIPT: &str = r#" import os import yaml def log(msg): logfile.write(msg) logfile.write("\n") logfile.flush() logfile = open("/tmp/vmadm.script", "w") log("vmadm cloud-init script starting") if os.environ.get("VMADM_TESTING"): filename = "smoke/user-data" etc = "x" else: filename = "/var/lib/cloud/instance/user-data.txt" etc = "/etc/ssh" key_types = ("rsa", "dsa", "ecdsa", "ed25519") log(f"loading user-data from {filename}") obj = yaml.safe_load(open(filename)) ssh_keys = obj.get("ssh_keys", {}) # log(f"ssh_keys: {json.dumps(ssh_keys)}") keys = [] certs = [] for key_type in key_types: filename = os.path.join(etc, f"ssh_host_{key_type}_key.pub") if os.path.exists(filename): log(f"removing {filename}") os.remove(filename) else: log(f"file {filename} does not exist") for key_type in key_types: key = ssh_keys.get(f"{key_type}_private") cert = ssh_keys.get(f"{key_type}_certificate") log(f"key {key_type} {key}") log(f"cert {key_type} {cert }") if key: filename = os.path.join(etc, f"ssh_host_{key_type}_key") log(f"writing key {filename}") keys.append(filename) with open(filename, "w") as f: f.write(key) if cert: filename = os.path.join(etc, f"ssh_host_{key_type}_key-cert.pub") log(f"writing cert {filename}") certs.append(filename) with open(filename, "w") as f: f.write(cert) config = os.path.join(etc, "sshd_config") data = "" if os.path.exists(config): data = open(config).read() log(f"configuring sshd {config}") log(f"keys {keys}") log(f"certs {certs}") with open(config, "w") as f: for filename in keys: log(f"hostkey {filename}") f.write(f"hostkey {filename}\n") for filename in certs: log(f"hostcert {filename}") f.write(f"hostcertificate {filename}\n") f.write(data) log("vmadm cloud-init script ending") logfile.close() "#; /// 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 doing I/O. #[error(transparent)] IoError(#[from] 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), } #[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").or(Some(doc)).unwrap(); let doc = doc.strip_suffix("\n").or(Some(doc)).unwrap(); doc.to_string() } #[derive(Clone, Debug, Serialize)] struct Userdata { ssh_authorized_keys: Vec, #[serde(skip_serializing_if = "Option::is_none")] ssh_keys: Option, runcmd: Vec, } impl Userdata { fn from(spec: &Specification) -> Result { Ok(Self { ssh_authorized_keys: spec.ssh_keys.clone(), ssh_keys: Hostkeys::from(spec)?, 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 { Ok(self.metadata.as_yaml()?) } fn userdata(&self) -> Result { Ok(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()?)?; let userdata = path.join("user-data"); debug!("writing userdata to {}", userdata.display()); write(&userdata, self.userdata()?)?; // 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 = tempdir()?; self.create_dir(dir.path())?; let r = Command::new("genisoimage") .arg("-quiet") .arg("-volid") .arg("cidata") .arg("-joliet") .arg("-rock") .arg("-output") .arg(filename) .arg(dir.path()) .output()?; if !r.status.success() { let stderr = String::from_utf8(r.stderr)?; return Err(CloudInitError::IsoFailed(stderr)); } Ok(()) } }