From e61b3b06f3f3d8e32ba84f47a8eef50aa0a8bc08 Mon Sep 17 00:00:00 2001 From: Kaare Hoff Skovgaard Date: Mon, 7 Jul 2025 23:10:53 +0200 Subject: [PATCH] Begin adding support for using opentofu through openbao secrets --- .../nixos/hetzner-instance/default.nix | 10 +- nix/modules/nixos/provisioning/default.nix | 14 +- nix/packages/bitwarden-to-vault/default.nix | 19 + nix/packages/bw-opentofu/default.nix | 20 +- nix/packages/bw-opentofu/secrets-map.nix | 24 ++ nix/packages/hetzner-static-ip/default.nix | 9 +- nix/packages/openbao-helper/default.nix | 19 + rust/Cargo.lock | 49 +++ rust/Cargo.toml | 1 + rust/default.nix | 24 +- rust/program/openbao-helper/Cargo.toml | 14 + rust/program/openbao-helper/src/main.rs | 387 ++++++++++++++++++ 12 files changed, 551 insertions(+), 39 deletions(-) create mode 100644 nix/packages/bitwarden-to-vault/default.nix create mode 100644 nix/packages/bw-opentofu/secrets-map.nix create mode 100644 nix/packages/openbao-helper/default.nix create mode 100644 rust/program/openbao-helper/Cargo.toml create mode 100644 rust/program/openbao-helper/src/main.rs diff --git a/nix/modules/nixos/hetzner-instance/default.nix b/nix/modules/nixos/hetzner-instance/default.nix index 704a2d7..72b0237 100644 --- a/nix/modules/nixos/hetzner-instance/default.nix +++ b/nix/modules/nixos/hetzner-instance/default.nix @@ -229,12 +229,10 @@ in khscodes.provisioning.pre = { modules = modules; secretsSource = cfg.secretsSource; - variablesNeeded = [ - "TF_VAR_cloudflare_token" - "TF_VAR_cloudflare_email" - "AWS_ACCESS_KEY_ID" - "AWS_SECRET_ACCESS_KEY" - "TF_VAR_hcloud_api_token" + endspoints = [ + "aws" + "cloudflare" + "hcloud" ]; }; } diff --git a/nix/modules/nixos/provisioning/default.nix b/nix/modules/nixos/provisioning/default.nix index be59a46..5a58ded 100644 --- a/nix/modules/nixos/provisioning/default.nix +++ b/nix/modules/nixos/provisioning/default.nix @@ -21,9 +21,17 @@ let description = "Where to get the secrets for the provisioning from"; default = "vault"; }; - variablesNeeded = lib.mkOption { - type = lib.types.listOf lib.types.str; - description = "Needed environment variables for the provisioning"; + endspoints = lib.mkOption { + type = lib.types.listOf ( + lib.types.enum [ + "openstack" + "aws" + "unifi" + "hcloud" + "cloudflare" + ] + ); + description = "Needed endpoints to be used during provisioning"; default = [ ]; }; }; diff --git a/nix/packages/bitwarden-to-vault/default.nix b/nix/packages/bitwarden-to-vault/default.nix new file mode 100644 index 0000000..911a55b --- /dev/null +++ b/nix/packages/bitwarden-to-vault/default.nix @@ -0,0 +1,19 @@ +{ pkgs, lib, ... }: +let + script = pkgs.writeShellApplication { + name = "bitwarden-to-vault-wrapped"; + meta = { + mainProgram = "bitwarden-to-vault-wrapped"; + }; + runtimeInputs = [ pkgs.khscodes.openbao-helper ]; + text = '' + openbao-helper transfer + ''; + }; +in +lib.khscodes.mkBwEnv { + inherit pkgs; + name = "bitwarden-to-vault"; + items = import ../bw-opentofu/secrets-map.nix; + exe = lib.getExe script; +} diff --git a/nix/packages/bw-opentofu/default.nix b/nix/packages/bw-opentofu/default.nix index 376de5a..305c922 100644 --- a/nix/packages/bw-opentofu/default.nix +++ b/nix/packages/bw-opentofu/default.nix @@ -4,25 +4,7 @@ let # TODO: We should figure out a way of passing the secrets map at runtime instead of build time. # for now this map just needs to include every secret we could need, which also makes the reading of secrets take way longer than # needed. - secrets = { - "KHS Openstack" = { - TF_VAR_openstack_username = "login.username"; - TF_VAR_openstack_password = "login.password"; - TF_VAR_openstack_tenant_name = "Project Name"; - TF_VAR_openstack_auth_url = "Auth URL"; - TF_VAR_openstack_endpoint_type = "Interface"; - TF_VAR_openstack_region = "Region Name"; - }; - "Cloudflare" = { - TF_VAR_cloudflare_token = "DNS API Token"; - TF_VAR_cloudflare_email = "login.username"; - AWS_ACCESS_KEY_ID = "BW Terraform access key id"; - AWS_SECRET_ACCESS_KEY = "BW Terraform secret access key"; - }; - "Hetzner Cloud" = { - TF_VAR_hcloud_api_token = "Terraform API Token"; - }; - }; + secrets = import ./secrets-map.nix; wrappedScript = pkgs.writeShellApplication { name = "bw-opentofu-wrapped"; runtimeInputs = [ diff --git a/nix/packages/bw-opentofu/secrets-map.nix b/nix/packages/bw-opentofu/secrets-map.nix new file mode 100644 index 0000000..6b613f8 --- /dev/null +++ b/nix/packages/bw-opentofu/secrets-map.nix @@ -0,0 +1,24 @@ +{ + "KHS Openstack" = { + TF_VAR_openstack_username = "login.username"; + TF_VAR_openstack_password = "login.password"; + TF_VAR_openstack_tenant_name = "Project Name"; + TF_VAR_openstack_auth_url = "Auth URL"; + TF_VAR_openstack_endpoint_type = "Interface"; + TF_VAR_openstack_region = "Region Name"; + }; + "Cloudflare" = { + TF_VAR_cloudflare_token = "DNS API Token"; + TF_VAR_cloudflare_email = "login.username"; + AWS_ACCESS_KEY_ID = "BW Terraform access key id"; + AWS_SECRET_ACCESS_KEY = "BW Terraform secret access key"; + }; + "Hetzner Cloud" = { + TF_VAR_hcloud_api_token = "Terraform API Token"; + }; + "Ubiquiti" = { + TF_VAR_unifi_username = "Terraform username"; + TF_VAR_unifi_password = "Terraform password"; + TF_VAR_unifi_url = "Terraform URL"; + }; +} diff --git a/nix/packages/hetzner-static-ip/default.nix b/nix/packages/hetzner-static-ip/default.nix index 1a67c93..99e0ae7 100644 --- a/nix/packages/hetzner-static-ip/default.nix +++ b/nix/packages/hetzner-static-ip/default.nix @@ -3,4 +3,11 @@ pkgs, inputs, }: -(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage "hetzner-static-ip" +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage { + crateName = "hetzner-static-ip"; + runtimeInputs = [ + pkgs.curl + pkgs.uutils-coreutils-noprefix + pkgs.iproute2 + ]; +} diff --git a/nix/packages/openbao-helper/default.nix b/nix/packages/openbao-helper/default.nix new file mode 100644 index 0000000..6d7144d --- /dev/null +++ b/nix/packages/openbao-helper/default.nix @@ -0,0 +1,19 @@ +{ + lib, + pkgs, + inputs, +}: +(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage { + crateName = "openbao-helper"; + # Not replacing path for openbao helper as it will execve other processes. + # Ideally I would like to not touch this process' path at all, perhaps by + # placing a file along with the compiled binary listing where the programs are located + # such that no tampering of the ENV can take place. But doing it this way at least + # it will just suffix the paths. + replacePath = false; + runtimeInputs = [ + pkgs.curl + pkgs.uutils-coreutils-noprefix + pkgs.openbao + ]; +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6a46319..1b73eff 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -73,6 +73,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.40" @@ -244,6 +262,12 @@ dependencies = [ "syn", ] +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + [[package]] name = "libyml" version = "0.0.5" @@ -266,12 +290,37 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "once_cell_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openbao-helper" +version = "1.0.0" +dependencies = [ + "anyhow", + "clap", + "common", + "hakari", + "log", + "nix", + "serde", +] + [[package]] name = "portable-atomic" version = "1.11.1" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 86a3a55..7db53b4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,6 +21,7 @@ clap = { version = "4.5.39", default-features = false, features = [ "derive", ] } log = { version = "0.4.27", default-features = false, features = ["std"] } +nix = { version = "0.30.1", default-features = false, features = ["process"] } serde = { version = "1.0.219", default-features = false, features = [ "derive", "std", diff --git a/rust/default.nix b/rust/default.nix index 3a4e54c..5e716c9 100644 --- a/rust/default.nix +++ b/rust/default.nix @@ -33,7 +33,11 @@ let in { buildRustPackage = - crateName: + { + crateName, + runtimeInputs, + replacePath ? false, + }: craneLib.buildPackage ( individualCrateArgs // { @@ -41,15 +45,15 @@ in cargoExtraArgs = "-p ${crateName}"; src = fileSetForCrate crateName; nativeBuildInputs = [ pkgs.makeWrapper ]; - postFixup = '' - wrapProgram $out/bin/${crateName} --set PATH "${ - lib.makeBinPath [ - pkgs.curl - pkgs.uutils-coreutils-noprefix - pkgs.iproute2 - ] - }" - ''; + postFixup = + if replacePath then + '' + wrapProgram $out/bin/${crateName} --set PATH "${lib.makeBinPath runtimeInputs}" + '' + else + '' + wrapProgram $out/bin/${crateName} --suffix PATH : "${lib.makeBinPath runtimeInputs}" + ''; meta = { mainProgram = crateName; }; diff --git a/rust/program/openbao-helper/Cargo.toml b/rust/program/openbao-helper/Cargo.toml new file mode 100644 index 0000000..b85f031 --- /dev/null +++ b/rust/program/openbao-helper/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "openbao-helper" +edition = "2024" +version = "1.0.0" +metadata.crane.name = "openbao-helper" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +common = { path = "../../lib/common" } +log = { workspace = true } +nix = { workspace = true } +serde = { workspace = true } +hakari = { version = "0.1", path = "../../lib/hakari" } diff --git a/rust/program/openbao-helper/src/main.rs b/rust/program/openbao-helper/src/main.rs new file mode 100644 index 0000000..96370c8 --- /dev/null +++ b/rust/program/openbao-helper/src/main.rs @@ -0,0 +1,387 @@ +use std::{collections::BTreeSet, ffi::CString}; + +use anyhow::Context as _; +use clap::{Parser, Subcommand}; +use serde::Deserialize; + +fn main() { + common::entrypoint(program); +} + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Args { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + /// Transfers secrets from the current environment into their respective secrets in openbao + Transfer, + /// Wraps the program by reading the specified data from bao and setting respective environment variables + WrapProgram(WrapProgram), +} + +#[derive(Debug, Clone, clap::Args)] +pub struct WrapProgram { + /// The endpoints to fetch, + #[arg(short = 'e', long = "endpoint", number_of_values = 1)] + pub endpoint: Vec, + /// Command to wrap + #[arg(allow_hyphen_values = true, last = true)] + pub cmd: Vec, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] +pub enum Endpoint { + #[value(name = "openstack")] + Openstack, + #[value(name = "cloudflare")] + Cloudflare, + #[value(name = "aws")] + Aws, + #[value(name = "hcloud")] + Hcloud, + #[value(name = "unifi")] + Unifi, +} + +impl Endpoint { + pub fn try_into_env_data(self) -> anyhow::Result> { + match self { + Self::Openstack => { + let data = OpenstackData::read_from_bao()?; + Ok(data.into_env_data()) + } + Self::Aws => { + let data = AwsData::read_from_bao()?; + Ok(data.into_env_data()) + } + Self::Hcloud => { + let data = HcloudData::read_from_bao()?; + Ok(data.into_env_data()) + } + Self::Cloudflare => { + let data = CloudflareData::read_from_bao()?; + Ok(data.into_env_data()) + } + Self::Unifi => { + let data = UnifiData::read_from_bao()?; + Ok(data.into_env_data()) + } + } + } +} + +fn program() -> anyhow::Result<()> { + let args = Args::parse(); + match args.command { + Commands::Transfer => transfer(), + Commands::WrapProgram(w) => wrap_program(w), + } +} + +#[derive(Debug, Deserialize)] +struct OpenBaoKvEntry { + data: OpenBaoKvEntryData, +} + +#[derive(Debug, Deserialize)] +struct OpenBaoKvEntryData { + data: T, +} + +#[derive(Debug, Deserialize)] +struct OpenstackData { + username: String, + password: String, + tenant_name: String, + auth_url: String, + endpoint_type: String, + region: String, +} + +fn read_bao_data Deserialize<'de>>(key: &str) -> anyhow::Result { + let mut cmd = common::proc::Command::new("bao"); + cmd.args(["kv", "get", "-format=json", "-mount=opentofu", key]); + let result: OpenBaoKvEntry = cmd.try_spawn_to_json()?; + Ok(result.data.data) +} + +impl OpenstackData { + pub fn read_from_env() -> anyhow::Result { + let username = common::env::read_env("TF_VAR_openstack_username")?; + let password = common::env::read_env("TF_VAR_openstack_password")?; + let tenant_name = common::env::read_env("TF_VAR_openstack_tenant_name")?; + let auth_url = common::env::read_env("TF_VAR_openstack_auth_url")?; + let endpoint_type = common::env::read_env("TF_VAR_openstack_endpoint_type")?; + let region = common::env::read_env("TF_VAR_openstack_region")?; + Ok(Self { + username, + password, + tenant_name, + auth_url, + endpoint_type, + region, + }) + } + + pub fn read_from_bao() -> anyhow::Result { + let data = read_bao_data("openstack")?; + Ok(data) + } + + pub fn into_env_data(self) -> Vec<(&'static str, String)> { + vec![ + ("TF_VAR_openstack_username", self.username), + ("TF_VAR_openstack_password", self.password), + ("TF_VAR_openstack_tenant_name", self.tenant_name), + ("TF_VAR_openstack_auth_url", self.auth_url), + ("TF_VAR_openstack_endpoint_type", self.endpoint_type), + ("TF_VAR_openstack_region", self.region), + ] + } +} + +impl IntoIterator for OpenstackData { + type Item = (&'static str, String); + + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + ("username", self.username), + ("password", self.password), + ("tenant_name", self.tenant_name), + ("auth_url", self.auth_url), + ("endpoint_type", self.endpoint_type), + ("region", self.region), + ] + .into_iter() + } +} + +#[derive(Debug, Deserialize)] +struct CloudflareData { + token: String, + email: String, +} + +impl CloudflareData { + pub fn read_from_env() -> anyhow::Result { + let token = common::env::read_env("TF_VAR_cloudflare_token")?; + let email = common::env::read_env("TF_VAR_cloudflare_email")?; + Ok(Self { token, email }) + } + + pub fn read_from_bao() -> anyhow::Result { + let data = read_bao_data("cloudflare")?; + Ok(data) + } + + pub fn into_env_data(self) -> Vec<(&'static str, String)> { + vec![ + ("TF_VAR_cloudflare_token", self.token), + ("TF_VAR_cloudflare_email", self.email), + ] + } +} + +impl IntoIterator for CloudflareData { + type Item = (&'static str, String); + + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![("token", self.token), ("email", self.email)].into_iter() + } +} + +#[derive(Debug, Deserialize)] +struct AwsData { + key_id: String, + secret_access_key: String, +} + +impl AwsData { + pub fn read_from_env() -> anyhow::Result { + let key_id = common::env::read_env("AWS_ACCESS_KEY_ID")?; + let secret_access_key = common::env::read_env("AWS_SECRET_ACCESS_KEY")?; + Ok(Self { + key_id, + secret_access_key, + }) + } + + pub fn read_from_bao() -> anyhow::Result { + let data = read_bao_data("aws")?; + Ok(data) + } + + pub fn into_env_data(self) -> Vec<(&'static str, String)> { + vec![ + ("AWS_ACCESS_KEY_ID", self.key_id), + ("AWS_SECRET_ACCESS_KEY", self.secret_access_key), + ] + } +} + +impl IntoIterator for AwsData { + type Item = (&'static str, String); + + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + ("key_id", self.key_id), + ("secret_access_key", self.secret_access_key), + ] + .into_iter() + } +} + +#[derive(Debug, Deserialize)] +struct HcloudData { + api_token: String, +} + +impl HcloudData { + pub fn read_from_env() -> anyhow::Result { + let api_token = common::env::read_env("TF_VAR_hcloud_api_token")?; + Ok(Self { api_token }) + } + + pub fn read_from_bao() -> anyhow::Result { + let data = read_bao_data("hcloud")?; + Ok(data) + } + + pub fn into_env_data(self) -> Vec<(&'static str, String)> { + vec![("TF_VAR_hcloud_api_token", self.api_token)] + } +} + +impl IntoIterator for HcloudData { + type Item = (&'static str, String); + + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![("api_token", self.api_token)].into_iter() + } +} + +#[derive(Debug, Deserialize)] +struct UnifiData { + username: String, + password: String, + url: String, +} + +impl UnifiData { + pub fn read_from_env() -> anyhow::Result { + let username = common::env::read_env("TF_VAR_unifi_username")?; + let password = common::env::read_env("TF_VAR_unifi_password")?; + let url = common::env::read_env("TF_VAR_unifi_url")?; + Ok(Self { + username, + password, + url, + }) + } + + pub fn read_from_bao() -> anyhow::Result { + let data = read_bao_data("unifi")?; + Ok(data) + } + + pub fn into_env_data(self) -> Vec<(&'static str, String)> { + vec![ + ("TF_VAR_unifi_username", self.username), + ("TF_VAR_unifi_password", self.password), + ("TF_VAR_unifi_url", self.url), + ] + } +} + +impl IntoIterator for UnifiData { + type Item = (&'static str, String); + + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + ("username", self.username), + ("password", self.password), + ("url", self.url), + ] + .into_iter() + } +} + +fn transfer() -> anyhow::Result<()> { + let openstack = OpenstackData::read_from_env()?; + let cloudflare = CloudflareData::read_from_env()?; + let aws = AwsData::read_from_env()?; + let hcloud = HcloudData::read_from_env()?; + let unifi = UnifiData::read_from_env()?; + + write_kv_data("openstack", openstack)?; + write_kv_data("cloudflare", cloudflare)?; + write_kv_data("aws", aws)?; + write_kv_data("hcloud", hcloud)?; + write_kv_data("unifi", unifi)?; + Ok(()) +} + +fn write_kv_data( + key: &str, + data: impl IntoIterator, +) -> anyhow::Result<()> { + let mut cmd = common::proc::Command::new("bao"); + cmd.args(["kv", "put", "-mount=opentofu"]); + cmd.arg(key); + for (key, value) in data { + cmd.arg(format!("{key}={value}")); + } + cmd.try_spawn_to_string()?; + Ok(()) +} + +fn wrap_program(wrap_program: WrapProgram) -> anyhow::Result<()> { + let (args, env) = { + let WrapProgram { cmd, endpoint } = wrap_program; + if endpoint.is_empty() { + return Err(anyhow::format_err!("Must specify at least one endpoint")); + } + if cmd.is_empty() { + return Err(anyhow::format_err!("No command to execute was specified")); + } + let unique: BTreeSet<_> = BTreeSet::from_iter(endpoint); + let mut env = Vec::::new(); + for (key, value) in std::env::vars() { + env.push( + CString::new(format!("{key}={value}")) + .with_context(|| format!("Environment variable {key} contained a null byte"))?, + ); + } + for env_set in unique { + for (key, value) in env_set.try_into_env_data()? { + env.push(CString::new(format!("{key}={value}")).with_context(|| { + format!("Environment variable {key} contained a null byte") + })?); + } + } + let mut args = Vec::new(); + for arg in cmd { + let arg = CString::new(arg) + .context("Argument to program to wrap cannot contain null bytes")?; + args.push(arg); + } + (args, env) + }; + nix::unistd::execvpe(&args[0], args.as_slice(), env.as_slice())?; + // This will never get executed + Ok(()) +}