From 98c482d7903d059a6598a6e7280f0e7b431eac29 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 27 Feb 2021 11:28:36 +0200 Subject: feat: make ssh/config never use passwords, only use specified key --- ssh/config | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ssh/config b/ssh/config index eb1be72..b4cbdec 100644 --- a/ssh/config +++ b/ssh/config @@ -1,4 +1,6 @@ host * userknownhostsfile=/dev/null stricthostkeychecking=accept-new - identityfile=ssh/test.key \ No newline at end of file + identityfile=ssh/test.key + identitiesonly=yes + passwordauthentication=no -- cgit v1.2.1 From 1c5c97532c91faf8a59bbe7ebcfc645f653db697 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 27 Feb 2021 07:54:40 +0200 Subject: feat: export cloud-init config, set SSH host keys --- Cargo.lock | 7 ++ Cargo.toml | 1 + src/bin/vmadm.rs | 60 +++++++++-- src/cloudinit.rs | 307 +++++++++++++++++++++++++++++++++++++++++++---------- src/spec.rs | 10 ++ subplot/vmadm.py | 30 ++++++ subplot/vmadm.yaml | 6 ++ vmadm.md | 105 ++++++++++++++---- 8 files changed, 440 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23e8b95..2a03b2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,6 +336,12 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "shell-words" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074" + [[package]] name = "strsim" version = "0.8.0" @@ -487,6 +493,7 @@ dependencies = [ "pretty_env_logger", "serde", "serde_yaml", + "shell-words", "structopt", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 1772d0d..54db867 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ serde_yaml = "0.8" bytesize = "1" log = "0.4" pretty_env_logger = "0.4" +shell-words = "1" diff --git a/src/bin/vmadm.rs b/src/bin/vmadm.rs index 0da9af9..75e0cdf 100644 --- a/src/bin/vmadm.rs +++ b/src/bin/vmadm.rs @@ -17,14 +17,28 @@ const SSH_PORT: i32 = 22; #[derive(StructOpt, Debug)] enum Cli { New { - #[structopt(help = "create a new virtual machine", parse(from_os_str))] + #[structopt(parse(from_os_str))] spec: PathBuf, }, List, Delete { - #[structopt(help = "create a new virtual machine", parse(from_os_str))] + #[structopt(parse(from_os_str))] spec: PathBuf, }, + CloudInit { + #[structopt(parse(from_os_str))] + spec: PathBuf, + + #[structopt(parse(from_os_str))] + dirname: PathBuf, + }, + CloudInitIso { + #[structopt(parse(from_os_str))] + spec: PathBuf, + + #[structopt(parse(from_os_str))] + iso: PathBuf, + }, } fn main() -> anyhow::Result<()> { @@ -33,6 +47,8 @@ fn main() -> anyhow::Result<()> { Cli::New { spec } => new(&spec)?, Cli::List => list()?, Cli::Delete { spec } => delete(&spec)?, + Cli::CloudInit { spec, dirname } => cloud_init(&spec, &dirname)?, + Cli::CloudInitIso { spec, iso } => cloud_init_iso(&spec, &iso)?, } Ok(()) } @@ -44,13 +60,8 @@ fn new(spec: &Path) -> anyhow::Result<()> { let spec = fs::read(spec)?; let spec: Specification = serde_yaml::from_slice(&spec)?; - debug!("reading specified SSH public keys"); - let ssh_keys = spec.ssh_keys()?; - info!("creating cloud-init config"); - let mut init = CloudInitConfig::default(); - init.set_hostname(&spec.name); - init.set_authorized_keys(&ssh_keys); + let init = CloudInitConfig::from(&spec)?; info!( "creating VM image {} from {}", @@ -154,3 +165,36 @@ fn delete(spec: &Path) -> anyhow::Result<()> { } Ok(()) } + +fn cloud_init(spec: &Path, dirname: &Path) -> anyhow::Result<()> { + info!("generating cloud-init configuration"); + + debug!("reading specification from {}", spec.display()); + let spec = fs::read(spec)?; + let spec: Specification = serde_yaml::from_slice(&spec)?; + debug!("spec:\n{:#?}", spec); + + info!("creating cloud-init config"); + let init = CloudInitConfig::from(&spec)?; + + debug!("creating directory {}", dirname.display()); + std::fs::create_dir_all(dirname)?; + init.create_dir(dirname)?; + + Ok(()) +} + +fn cloud_init_iso(spec: &Path, iso: &Path) -> anyhow::Result<()> { + info!("generating cloud-init ISO"); + + debug!("reading specification from {}", spec.display()); + let spec = fs::read(spec)?; + let spec: Specification = serde_yaml::from_slice(&spec)?; + debug!("spec:\n{:#?}", spec); + + info!("creating cloud-init config"); + let init = CloudInitConfig::from(&spec)?; + init.create_iso(iso)?; + + Ok(()) +} diff --git a/src/cloudinit.rs b/src/cloudinit.rs index 6c52eff..f1b56ce 100644 --- a/src/cloudinit.rs +++ b/src/cloudinit.rs @@ -1,69 +1,288 @@ -use std::default::Default; +use crate::spec::{Specification, SpecificationError}; +use log::debug; +use serde::{Deserialize, Serialize}; +use shell_words::quote; 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() +"#; + #[derive(Debug, thiserror::Error)] pub enum CloudInitError { #[error("failed to create ISO image with genisoimage: {0}")] IsoFailed(String), + #[error(transparent)] + SpecificationError(#[from] SpecificationError), + #[error(transparent)] IoError(#[from] std::io::Error), #[error(transparent)] StringError(#[from] std::string::FromUtf8Error), + + #[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 marker = "---\n"; + let doc = if doc.starts_with(marker) { + &doc[marker.len()..] + } else { + doc + }; + if doc.ends_with("\n") { + &doc[..doc.len() - 1] + } else { + doc + }; + 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()?.lines().map(|s| s.to_string()).collect(), + ssh_keys: Hostkeys::from(spec), + runcmd: vec![ + format!("python3 -c {}", quote(SCRIPT)), + format!("systemctl reload ssh"), + ], + }) + } + + 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)] +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) -> Option { + 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() { + 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 { + None + } + } } -#[derive(Default, Debug, Clone)] +#[derive(Clone, Debug)] pub struct CloudInitConfig { - hostname: String, - authorized_keys: String, + metadata: Metadata, + userdata: Userdata, } impl CloudInitConfig { - pub fn hostname(&self) -> &str { - &self.hostname + pub fn from(spec: &Specification) -> Result { + let metadata = Metadata::from(spec); + let userdata = Userdata::from(spec)?; + Ok(CloudInitConfig { metadata, userdata }) } - pub fn set_hostname(&mut self, hostname: &str) { - self.hostname = hostname.to_string(); + pub fn dump(&self) { + println!("==== meta-data:\n{}", self.metadata().unwrap()); + println!("==== user-data:\n{}", self.userdata().unwrap()); } - pub fn authorized_keys(&self) -> &str { - &self.authorized_keys + fn metadata(&self) -> Result { + Ok(self.metadata.as_yaml()?) } - pub fn set_authorized_keys(&mut self, keys: &str) { - self.authorized_keys = keys.to_string(); + fn userdata(&self) -> Result { + Ok(self.userdata.as_yaml()?) } - fn write(&self, dir: &Path) -> std::io::Result<()> { - let metadata = dir.join("meta-data"); - let userdata = dir.join("user-data"); - - write( - &metadata, - format!( - "# Amazon EC2 style metadata\nlocal-hostname: {}\n", - self.hostname() - ), - )?; - write( - &userdata, - format!( - "#cloud-config\nssh_authorized_keys:\n- {}", - self.authorized_keys() - ), - )?; + 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(()) } pub fn create_iso(&self, filename: &Path) -> Result<(), CloudInitError> { let dir = tempdir()?; - self.write(dir.path())?; + self.create_dir(dir.path())?; let r = Command::new("genisoimage") .arg("-quiet") @@ -82,29 +301,3 @@ impl CloudInitConfig { Ok(()) } } - -#[cfg(test)] -mod test { - use super::CloudInitConfig; - - #[test] - fn is_empty_by_default() { - let init = CloudInitConfig::default(); - assert_eq!(init.hostname(), ""); - assert_eq!(init.authorized_keys(), ""); - } - - #[test] - fn sets_hostname() { - let mut init = CloudInitConfig::default(); - init.set_hostname("foo"); - assert_eq!(init.hostname(), "foo"); - } - - #[test] - fn sets_authorized_keys() { - let mut init = CloudInitConfig::default(); - init.set_authorized_keys("auth"); - assert_eq!(init.authorized_keys(), "auth"); - } -} diff --git a/src/spec.rs b/src/spec.rs index 1209040..2cb61f6 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -2,12 +2,22 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Specification { pub name: String, #[serde(default)] pub ssh_key_files: Vec, + pub rsa_host_key: Option, + pub rsa_host_cert: Option, + pub dsa_host_key: Option, + pub dsa_host_cert: Option, + pub ecdsa_host_key: Option, + pub ecdsa_host_cert: Option, + pub ed25519_host_key: Option, + pub ed25519_host_cert: Option, + pub base: PathBuf, pub image: PathBuf, pub image_size_gib: u64, diff --git a/subplot/vmadm.py b/subplot/vmadm.py index 2374baf..48737a3 100644 --- a/subplot/vmadm.py +++ b/subplot/vmadm.py @@ -1,4 +1,6 @@ +import logging import os +import yaml def install_vmadm(ctx): @@ -16,6 +18,34 @@ def install_vmadm(ctx): # This can be removed once the Subplot lib/files library creates # directories. os.mkdir(".ssh") + os.mkdir("expected") + + +def invoke_cloud_init(ctx, filename=None, dirname=None): + runcmd_run = globals()["runcmd_run"] + runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"] + + runcmd_run(ctx, ["vmadm", "cloud-init", filename, dirname]) + runcmd_exit_code_is_zero(ctx) + + +def directories_match(ctx, actual=None, expected=None): + assert_eq = globals()["assert_eq"] + + efiles = list(sorted(os.listdir(expected))) + afiles = list(sorted(os.listdir(expected))) + assert_eq(efiles, afiles) + for filename in efiles: + with open(os.path.join("expected", filename)) as f: + edata = yaml.safe_load(f) + with open(os.path.join("actual", filename)) as f: + adata = yaml.safe_load(f) + if "runcmd" in adata: + del adata["runcmd"] + logging.debug(f"directories_match: filename={filename}") + logging.debug(f"directories_match: edata={edata!r}") + logging.debug(f"directories_match: adata={adata!r}") + assert_eq(edata, adata) def create_vm(ctx, filename=None): diff --git a/subplot/vmadm.yaml b/subplot/vmadm.yaml index 40fce49..374391b 100644 --- a/subplot/vmadm.yaml +++ b/subplot/vmadm.yaml @@ -1,9 +1,15 @@ - given: "an installed vmadm" function: install_vmadm +- when: "I invoke vmadm cloud-init {filename} {dirname}" + function: invoke_cloud_init + - when: "I invoke vmadm new {filename}" function: create_vm cleanup: delete_vm - when: "I invoke ssh -F {config} {target} {args:text}" function: run_hostname_over_ssh + +- then: "directories {actual} and {expected} are identical" + function: directories_match diff --git a/vmadm.md b/vmadm.md index a63370c..9d05b2a 100644 --- a/vmadm.md +++ b/vmadm.md @@ -1,25 +1,6 @@ -# Create a virtual machine - -This scenario verifies that vmadm can create a virtual machine and -that the user can log into it as root via SSH after it has booted. -This requires that the environment it set up so that virtual machines -can be addressed by name. - -~~~scenario -given an installed vmadm -given file smoke.yaml -given file .ssh/id_rsa from ssh_key -given file .ssh/id_rsa.pub from ssh_key_pub -given file .ssh/config from ssh_config -when I invoke vmadm new smoke.yaml -when I invoke ssh -F .ssh/config debian@smoke hostname -then stdout contains "smoke" -when I invoke ssh -F .ssh/config debian@smoke df -h / -then stdout contains "4.9G" -when I invoke ssh -F .ssh/config debian@smoke free -m -then stdout contains "1997" -~~~ +# Data files for scenarios +This section has some data files used by scenarios. ~~~{#smoke.yaml .file .yaml} name: smoke @@ -74,6 +55,88 @@ host * passwordauthentication=no ~~~ +# Cloud-init configuration + +This scenario verifies that vmadm creates the cloud-init configuration +correctly. + +~~~scenario +given an installed vmadm +given file init.yaml +given file .ssh/id_rsa.pub from init_ssh_key_pub +given file expected/meta-data from init-metadata +given file expected/user-data from init-userdata +when I invoke vmadm cloud-init init.yaml actual +then directories actual and expected are identical +~~~ + +~~~{#init.yaml .file .yaml} +name: init-test +ssh_key_files: + - .ssh/id_rsa.pub +rsa_host_key: rsa-private +rsa_host_cert: rsa-certificate +dsa_host_key: dsa-private +dsa_host_cert: dsa-certificate +ecdsa_host_key: ecdsa-private +ecdsa_host_cert: ecdsa-certificate +ed25519_host_key: ed25519-private +ed25519_host_cert: ed25519-certificate +base: /home/liw/tmp/debian-10-openstack-amd64.qcow2 +image: init.qcow2 +image_size_gib: 5 +memory_mib: 2048 +cpus: 1 +~~~ + +~~~{#init_ssh_key_pub .file} +init-authz-key +~~~ + +~~~{#init-metadata .file} +# Amazon EC2 style metadata +local-hostname: init-test +~~~ + +~~~{#init-userdata .file} +#cloud-config +ssh_authorized_keys: + - init-authz-key +ssh_keys: + rsa_private: rsa-private + rsa_certificate: rsa-certificate + dsa_private: dsa-private + dsa_certificate: dsa-certificate + ecdsa_private: ecdsa-private + ecdsa_certificate: ecdsa-certificate + ed25519_private: ed25519-private + ed25519_certificate: ed25519-certificate +~~~ + +# Create a virtual machine + +This scenario verifies that vmadm can create a virtual machine and +that the user can log into it as root via SSH after it has booted. +This requires that the environment it set up so that virtual machines +can be addressed by name. + +~~~scenario +given an installed vmadm +given file smoke.yaml +given file .ssh/id_rsa from ssh_key +given file .ssh/id_rsa.pub from ssh_key_pub +given file .ssh/config from ssh_config +when I invoke vmadm new smoke.yaml +when I invoke ssh -F .ssh/config debian@smoke hostname +then stdout contains "smoke" +when I invoke ssh -F .ssh/config debian@smoke df -h / +then stdout contains "4.9G" +when I invoke ssh -F .ssh/config debian@smoke free -m +then stdout contains "1997" +~~~ + + + # Colophon -- cgit v1.2.1