From 2fef06859159b7eb6483b69cd5b5be98b2356c01 Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Sat, 18 Sep 2021 22:38:37 +0000 Subject: [PATCH] feat: secret installer --- --- /dev/null +++ b/cmds/install-secrets/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "install-secrets" +version = "0.1.0" +edition = "2018" + +[dependencies] +age = { version = "0.6.0", features = ["ssh"] } +anyhow = "1.0.44" +ascii85 = "0.2.1" +env_logger = "0.9.0" +log = "0.4.14" +nix = "0.22.1" +serde = "1.0.130" +serde_json = "1.0.68" +structopt = "0.3.23" +tempfile = "3.2.0" +z85 = "3.0.3" --- /dev/null +++ b/cmds/install-secrets/src/main.rs @@ -0,0 +1,178 @@ +use age::Decryptor; +use anyhow::{anyhow, bail, Context, Result}; +use log::{error, warn}; +use nix::fcntl::{renameat2, RenameFlags}; +use nix::sys::stat::Mode; +use nix::unistd::{chown, Group, User}; +use serde::{Deserialize, Deserializer}; +use std::fs::{self, DirBuilder}; +use std::io::{self, Cursor, Read}; +use std::iter; +use std::os::unix::prelude::PermissionsExt; +use std::str::from_utf8; +use std::{ + collections::HashMap, + os::unix::fs::DirBuilderExt, + path::{Path, PathBuf}, +}; +use structopt::StructOpt; + +#[derive(StructOpt)] +#[structopt(author)] +struct Opts { + data: PathBuf, +} + +#[derive(Deserialize)] +struct DataItem { + group: String, + mode: String, + owner: String, + #[serde(deserialize_with = "from_z85")] + secret: Vec, +} + +fn from_z85<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + String::deserialize(deserializer) + .and_then(|string| z85::decode(&string).map_err(|err| Error::custom(err.to_string()))) +} + +type Data = HashMap; + +fn init_secret( + identity: &age::ssh::Identity, + dir: &Path, + name: &str, + value: DataItem, +) -> Result<()> { + let mut path = dir.to_path_buf(); + path.push(name); + if path.strip_prefix(&dir).is_err() { + bail!("found escaping name"); + } + + let secret_dir = path + .parent() + .expect("path is in tempdir, so it should have parent"); + + if secret_dir != dir { + DirBuilder::new() + .recursive(true) + // o: xrw + // g: xr + // a: xr + .mode(0o755) + .create( + path.parent() + .expect("path is in tempdir, so it should have parent"), + ) + .context("failed to create secret directory")?; + } + + let mode = Mode::from_bits( + u32::from_str_radix(&value.mode, 8).context("failed to parse mode as octal")?, + ) + .context("failed to parse mode")?; + let user = User::from_name(&value.owner) + .context("failed to get user")? + .ok_or_else(|| anyhow!("user not found"))?; + let group = Group::from_name(&value.group) + .context("failed to get group")? + .ok_or_else(|| anyhow!("group not found"))?; + let mut tempfile = + tempfile::NamedTempFile::new_in(secret_dir).context("failed to create tempfile")?; + // File is owned by root, and only root can modify it + + let decrypted = { + let mut input = Cursor::new(&value.secret); + let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?; + let decryptor = match decryptor { + Decryptor::Recipients(r) => r, + Decryptor::Passphrase(_) => bail!("should be recipients"), + }; + let mut decryptor = decryptor + .decrypt(iter::once(identity as &dyn age::Identity)) + .context("failed to decrypt, wrong key?")?; + + let mut decrypted = Vec::new(); + decryptor + .read_to_end(&mut decrypted) + .context("failed to decrypt")?; + decrypted + }; + + io::copy(&mut Cursor::new(decrypted), &mut tempfile) + .context("failed to write decrypted file")?; + + // Make file owned by specified user and group, then change mode + chown(tempfile.path(), Some(user.uid), Some(group.gid)) + .context("failed to apply user/group")?; + fs::set_permissions(tempfile.path(), fs::Permissions::from_mode(mode.bits())).unwrap(); + tempfile.persist(path).context("failed to persist")?; + + Ok(()) +} + +fn main() -> anyhow::Result<()> { + env_logger::Builder::new() + .filter_level(log::LevelFilter::Info) + .init(); + + let opts = Opts::from_args(); + let data = fs::read(&opts.data).context("failed to read secrets data")?; + let data_str = from_utf8(&data).context("failed to read data to string")?; + let data: Data = serde_json::from_str(data_str).context("failed to parse data")?; + + let tempdir = + tempfile::tempdir_in("/run/secrets.d").context("failed to create secrets tempdir")?; + + let identity = age::ssh::Identity::from_buffer( + &mut Cursor::new( + fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?, + ), + None, + ) + .context("failed to parse identity")?; + + let mut failed = false; + for (name, value) in data { + if let Err(e) = init_secret(&identity, tempdir.path(), &name, value) { + error!( + "{:?}", + e.context(format!("failed to initialize secret {}", name)) + ); + failed = true; + } + } + if failed { + bail!("one or more secrets failed"); + } + + if fs::metadata("/run/secrets.d/secrets.fleet") + .map(|m| m.is_dir()) + .unwrap_or(false) + { + // Already linked + renameat2( + None, + tempdir.path(), + None, + "/run/secrets.d/secrets.fleet", + RenameFlags::RENAME_EXCHANGE, + ) + .context("failed to exchange secret directories")?; + if tempdir.close().is_err() { + warn!("failed to unlink old secrets"); + } + } else { + // Link now + let persisted = tempdir.into_path(); + fs::rename(&persisted, "/run/secrets.d/secrets.fleet") + .context("failed to link secret directory")?; + } + Ok(()) +} -- gitstuff