Remove openbao helper and replace it with more general program
Some checks failed
/ check (push) Failing after 2m26s
/ terraform-providers (push) Successful in 58s
/ systems (push) Successful in 30m33s
/ dev-shell (push) Successful in 2m10s
/ rust-packages (push) Failing after 3m16s

This gets rid of the messy nix code for handling bitwarden
secrets, and unifies it all into a nice single program
in rust. Ensuring that only the needed secrets are loaded.
This commit is contained in:
Kaare Hoff Skovgaard 2025-08-05 21:59:07 +02:00
parent e6a152e95c
commit 8640dce7bc
Signed by: khs
GPG key ID: C7D890804F01E9F0
31 changed files with 1159 additions and 958 deletions

View file

@ -0,0 +1,268 @@
use std::{borrow::Cow, collections::BTreeMap};
use common::bitwarden::BitwardenSession;
use serde::Deserialize;
use crate::secrets::{BitwardenKey, Endpoint, EndpointReader};
pub struct Openstack;
impl Endpoint for Openstack {
const NAME: &'static str = "openstack";
const BITWARDEN_KEY: &'static str = "KHS Openstack";
const ENV_KEYS: &'static [&'static str] = &[
"TF_VAR_openstack_username",
"TF_VAR_openstack_password",
"TF_VAR_openstack_tenant_name",
"TF_VAR_openstack_auth_url",
"TF_VAR_openstack_endpoint_type",
"TF_VAR_openstack_region",
];
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
BitwardenKey::Username,
BitwardenKey::Password,
BitwardenKey::Field("Project Name"),
BitwardenKey::Field("Auth URL"),
BitwardenKey::Field("Interface"),
BitwardenKey::Field("Region Name"),
];
}
pub struct Aws;
impl Endpoint for Aws {
const NAME: &'static str = "aws";
const BITWARDEN_KEY: &'static str = "Cloudflare";
const ENV_KEYS: &'static [&'static str] = &["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
BitwardenKey::Field("BW Terraform access key id"),
BitwardenKey::Field("BW Terraform secret access key"),
];
}
pub struct Cloudflare;
impl Endpoint for Cloudflare {
const NAME: &'static str = "cloudflare";
const BITWARDEN_KEY: &'static str = "Cloudflare";
const ENV_KEYS: &'static [&'static str] =
&["TF_VAR_cloudflare_token", "TF_VAR_cloudflare_email"];
const BITWARDEN_KEYS: &'static [BitwardenKey] =
&[BitwardenKey::Field("DNS API Token"), BitwardenKey::Username];
}
pub struct Hcloud;
impl Endpoint for Hcloud {
const NAME: &'static str = "hcloud";
const BITWARDEN_KEY: &'static str = "Hetzner Cloud";
const ENV_KEYS: &'static [&'static str] = &["TF_VAR_hcloud_api_token"];
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("Terraform API Token")];
}
pub struct Unifi;
impl Endpoint for Unifi {
const NAME: &'static str = "unifi";
const BITWARDEN_KEY: &'static str = "Ubiquiti";
const ENV_KEYS: &'static [&'static str] = &["UNIFI_USERNAME", "UNIFI_PASSWORD", "UNIFI_API"];
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
BitwardenKey::Field("Terraform username"),
BitwardenKey::Field("Terraform password"),
BitwardenKey::Field("Terraform URL"),
];
}
pub struct Vault;
impl Endpoint for Vault {
const NAME: &'static str = "vault";
const BITWARDEN_KEY: &'static str = "secrets.kaareskovgaard.net";
const ENV_KEYS: &'static [&'static str] = &["VAULT_TOKEN"];
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("Initial root token")];
}
pub struct MxKaareskovgaardNet;
impl Endpoint for MxKaareskovgaardNet {
const NAME: &'static str = "mx.kaareskovgaard.net";
const BITWARDEN_KEY: &'static str = "mx.kaareskovgaard.net";
const ENV_KEYS: &'static [&'static str] = &["MX_KAARESKOVGAARD_NET_ZROOT_ENCRYPTION_KEY"];
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("ZROOT_ENCRYPTION_KEY")];
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
pub enum CliEndpoint {
#[serde(rename = "openstack")]
#[value(name = "openstack")]
Openstack,
#[serde(rename = "cloudflare")]
#[value(name = "cloudflare")]
Cloudflare,
#[serde(rename = "aws")]
#[value(name = "aws")]
Aws,
#[serde(rename = "hcloud")]
#[value(name = "hcloud")]
Hcloud,
#[serde(rename = "unifi")]
#[value(name = "unifi")]
Unifi,
#[serde(rename = "vault")]
#[value(name = "vault")]
Vault,
}
impl CliEndpoint {
pub fn read_from_bitwarden(
&self,
session: &mut BitwardenSession,
map: &mut BTreeMap<Cow<'static, str>, String>,
) -> anyhow::Result<()> {
match self {
Self::Aws => Aws.read_from_bitwarden(session, map),
Self::Cloudflare => Cloudflare.read_from_bitwarden(session, map),
Self::Hcloud => Hcloud.read_from_bitwarden(session, map),
Self::Openstack => Openstack.read_from_bitwarden(session, map),
Self::Unifi => Unifi.read_from_bitwarden(session, map),
Self::Vault => Vault.read_from_bitwarden(session, map),
}
}
pub fn read_from_openbao(
&self,
map: &mut BTreeMap<Cow<'static, str>, String>,
) -> anyhow::Result<()> {
match self {
Self::Aws => Aws.read_from_openbao(map),
Self::Cloudflare => Cloudflare.read_from_openbao(map),
Self::Hcloud => Hcloud.read_from_openbao(map),
Self::Openstack => Openstack.read_from_openbao(map),
Self::Unifi => Openstack.read_from_openbao(map),
// We don't transfer the root token to openbao itself, but relies on the user being authenticated
// through oauth.
Self::Vault => Ok(()),
}
}
}
pub fn transfer_from_bitwarden_to_vault(session: &mut BitwardenSession) -> anyhow::Result<()> {
let mut all_entries_proc = common::proc::Command::new("bao");
all_entries_proc.args(["kv", "list", "-format=json", "--mount=opentofu"]);
let mut all_entries: Vec<String> = all_entries_proc.try_spawn_to_json()?;
transfer_endpoint(Openstack, session, &mut all_entries)?;
transfer_endpoint(Hcloud, session, &mut all_entries)?;
transfer_endpoint(Unifi, session, &mut all_entries)?;
transfer_endpoint(Aws, session, &mut all_entries)?;
transfer_endpoint(Cloudflare, session, &mut all_entries)?;
transfer_endpoint(MxKaareskovgaardNet, session, &mut all_entries)?;
for entry in all_entries {
let mut delete_entry_proc = common::proc::Command::new("bao");
delete_entry_proc.args(["kv", "metadata", "delete", "-mount=opentofu", &entry]);
log::info!("Deleting entry {entry}...");
delete_entry_proc.try_spawn_to_bytes()?;
log::info!("Entry deleted {entry}.");
}
Ok(())
}
fn transfer_endpoint<E: Endpoint>(
endpoint: E,
session: &mut BitwardenSession,
all_entries: &mut Vec<String>,
) -> anyhow::Result<()> {
let mut map = BTreeMap::new();
endpoint.read_from_bitwarden(session, &mut map)?;
log::info!("Transferring {}...", E::NAME);
let mut write_proc = common::proc::Command::new("bao");
write_proc.args(["kv", "put", "-mount=opentofu"]);
write_proc.arg(E::NAME);
for (key, value) in map {
// TODO: This should use some sort of sensitive wrapper to avoid ever logging the value to the console
write_proc.arg(format!("{key}={value}"));
}
write_proc.try_spawn_to_string()?;
destroy_openbao_old_versions(E::NAME)?;
log::info!("Transferred {}.", E::NAME);
if let Some(idx) = all_entries
.iter()
.enumerate()
.find_map(|(idx, e)| if e == E::NAME { Some(idx) } else { None })
{
let _ = all_entries.swap_remove(idx);
}
Ok(())
}
fn destroy_openbao_old_versions(name: &str) -> anyhow::Result<()> {
let mut proc = common::proc::Command::new("bao");
proc.args([
"kv",
"metadata",
"get",
"-mount=opentofu",
"-format=json",
name,
]);
let metadata: KvItemMetadata = proc.try_spawn_to_json()?;
let mut versions: Vec<_> = metadata.data.versions.into_iter().collect();
versions.sort_by(|a, b| {
let dest = a.1.destroyed.cmp(&b.1.destroyed).reverse();
if !dest.is_eq() {
return dest;
}
a.1.created_time.cmp(&b.1.created_time).reverse()
});
let versions_to_destroy = versions
.iter()
.filter(|(_, e)| !e.destroyed)
.map(|(version, _)| version.as_str())
.skip(1)
.collect::<Vec<_>>()
.join(",");
if !versions_to_destroy.is_empty() {
let mut delete_proc = common::proc::Command::new("bao");
delete_proc.args(["kv", "destroy", "-mount=opentofu"]);
delete_proc.arg(format!("-versions={versions_to_destroy}"));
delete_proc.arg(name);
delete_proc.try_spawn_to_bytes()?;
}
Ok(())
}
#[derive(Deserialize)]
struct KvItemMetadata {
data: KvItemMetadataData,
}
#[derive(Deserialize)]
struct KvItemMetadataData {
versions: KvItemMetadataVersions,
}
type KvItemMetadataVersions = BTreeMap<String, KvItemMetadataVersion>;
#[derive(Deserialize)]
struct KvItemMetadataVersion {
created_time: String,
destroyed: bool,
}

View file

@ -0,0 +1,128 @@
use std::{borrow::Cow, collections::BTreeMap};
use common::bitwarden::{BitwardenEntry, BitwardenEntryTypeData, BitwardenSession};
use crate::secrets::openbao::read_bao_data;
mod endpoints;
mod openbao;
pub use endpoints::{CliEndpoint, transfer_from_bitwarden_to_vault};
pub trait Endpoint {
const NAME: &'static str;
const BITWARDEN_KEY: &'static str;
const ENV_KEYS: &'static [&'static str];
const BITWARDEN_KEYS: &'static [BitwardenKey];
}
#[derive(Copy, Clone)]
pub enum BitwardenKey {
Username,
Password,
Field(&'static str),
}
impl BitwardenKey {
pub fn read_from_entry(self, entry: &BitwardenEntry) -> anyhow::Result<&str> {
match self {
Self::Username => match &entry.data {
BitwardenEntryTypeData::Login(bitwarden_entry_type_login) => {
let Some(username) = bitwarden_entry_type_login.username.as_deref() else {
return Err(anyhow::format_err!(
"Login entry {} has no username set",
entry.name()
));
};
Ok(username)
}
_ => Err(anyhow::format_err!(
"Could not read username from entry {}. Entry is not a login entry",
entry.name()
)),
},
Self::Password => match &entry.data {
BitwardenEntryTypeData::Login(bitwarden_entry_type_login) => {
let Some(password) = bitwarden_entry_type_login.password.as_deref() else {
return Err(anyhow::format_err!(
"Login entry {} has no password set",
entry.name()
));
};
Ok(password)
}
_ => Err(anyhow::format_err!(
"Could not read password from entry {}. Entry is not a login entry",
entry.name()
)),
},
Self::Field(field_name) => {
let Some(field) = entry
.fields
.as_deref()
.unwrap_or_default()
.iter()
.find(|e| e.name.as_deref() == Some(field_name))
else {
return Err(anyhow::format_err!(
"Entry {} has no field named {field_name}",
entry.name()
));
};
let Some(value) = field.value.as_deref() else {
return Err(anyhow::format_err!(
"Entry {} has no value set for field {}",
entry.name(),
field_name
));
};
Ok(value)
}
}
}
}
pub trait EndpointReader {
fn read_from_bitwarden(
&self,
session: &mut BitwardenSession,
map: &mut BTreeMap<Cow<'static, str>, String>,
) -> anyhow::Result<()>;
fn read_from_openbao(
&self,
map: &mut BTreeMap<Cow<'static, str>, String>,
) -> anyhow::Result<()>;
}
impl<E: Endpoint> EndpointReader for E {
fn read_from_bitwarden(
&self,
session: &mut BitwardenSession,
map: &mut BTreeMap<Cow<'static, str>, String>,
) -> anyhow::Result<()> {
let Some(item) = session.get_item(Self::BITWARDEN_KEY)? else {
return Err(anyhow::format_err!(
"Bitwarden key {} does not exist",
Self::BITWARDEN_KEY
));
};
for (key, value) in Self::BITWARDEN_KEYS.iter().zip(Self::ENV_KEYS.iter()) {
let field_value = key.read_from_entry(item)?;
map.insert((*value).into(), field_value.to_string());
}
Ok(())
}
fn read_from_openbao(
&self,
map: &mut BTreeMap<Cow<'static, str>, String>,
) -> anyhow::Result<()> {
let result = read_bao_data::<Self>()?;
for (key, value) in result {
map.insert(key.into(), value);
}
Ok(())
}
}

View file

@ -0,0 +1,105 @@
use std::{collections::BTreeMap, marker::PhantomData, vec::IntoIter};
use serde::Deserialize;
use crate::secrets::Endpoint;
pub struct EnvEntry<T>(Vec<(&'static str, String)>, PhantomData<T>);
impl<T: Endpoint> EnvEntry<T> {
pub fn try_new_from_env() -> anyhow::Result<Self> {
let mut result = Vec::with_capacity(T::ENV_KEYS.len());
for key in T::ENV_KEYS {
let value = common::env::read_env(key)?;
result.push((*key, value));
}
Ok(Self(result, PhantomData))
}
fn new_from_values(values: Vec<(&'static str, String)>) -> Self {
Self(values, PhantomData)
}
pub fn read_from_bao() -> anyhow::Result<Self> {
read_bao_data::<T>()
}
}
impl<T> From<EnvEntry<T>> for Vec<(&'static str, String)> {
fn from(value: EnvEntry<T>) -> Self {
value.0
}
}
impl<T> IntoIterator for EnvEntry<T> {
type Item = (&'static str, String);
type IntoIter = IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl<'de, T: Endpoint> serde::Deserialize<'de> for EnvEntry<T> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_map(EnvEntryVisitor(PhantomData))
}
}
struct EnvEntryVisitor<T>(PhantomData<T>);
impl<'de, T: Endpoint> serde::de::Visitor<'de> for EnvEntryVisitor<T> {
type Value = EnvEntry<T>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_fmt(format_args!(
"a map with unique keys {} with string values",
T::ENV_KEYS.join(", "),
))
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut values = BTreeMap::<&'static str, String>::new();
while let Some((key, value)) = map.next_entry::<&'de str, String>()? {
let mapped_key = T::ENV_KEYS.iter().find(|n| **n == key).copied();
let Some(key) = mapped_key else {
return Err(serde::de::Error::unknown_field(key, T::ENV_KEYS));
};
if values.contains_key(key) {
return Err(serde::de::Error::duplicate_field(key));
}
values.insert(key, value);
}
for key in T::ENV_KEYS {
if !values.contains_key(key) {
return Err(serde::de::Error::missing_field(key));
}
}
let values = values.into_iter().collect();
let entry = EnvEntry::<T>::new_from_values(values);
Ok(entry)
}
}
#[derive(Debug, Deserialize)]
struct OpenBaoKvEntry<T> {
data: OpenBaoKvEntryData<T>,
}
#[derive(Debug, Deserialize)]
struct OpenBaoKvEntryData<T> {
data: T,
}
pub fn read_bao_data<T: Endpoint>() -> anyhow::Result<EnvEntry<T>> {
let mut cmd = common::proc::Command::new("bao");
cmd.args(["kv", "get", "-format=json", "-mount=opentofu", T::NAME]);
let result: OpenBaoKvEntry<EnvEntry<T>> = cmd.try_spawn_to_json()?;
Ok(result.data.data)
}