Remove openbao helper and replace it with more general program
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:
parent
e6a152e95c
commit
8640dce7bc
31 changed files with 1159 additions and 958 deletions
268
rust/program/infrastructure/src/secrets/endpoints.rs
Normal file
268
rust/program/infrastructure/src/secrets/endpoints.rs
Normal 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,
|
||||
}
|
128
rust/program/infrastructure/src/secrets/mod.rs
Normal file
128
rust/program/infrastructure/src/secrets/mod.rs
Normal 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(())
|
||||
}
|
||||
}
|
105
rust/program/infrastructure/src/secrets/openbao.rs
Normal file
105
rust/program/infrastructure/src/secrets/openbao.rs
Normal 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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue