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,15 @@
[package]
name = "infrastructure"
edition = "2024"
version = "1.0.0"
metadata.crane.name = "infrastructure"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
common = { path = "../../lib/common" }
log = { workspace = true }
nix = { workspace = true, features = ["env", "process"] }
serde = { workspace = true }
serde_json = { workspace = true }
hakari = { version = "0.1", path = "../../lib/hakari" }

View file

@ -0,0 +1,30 @@
use std::path::Path;
use super::Command;
use crate::{EndpointReader, ProvisioningData};
#[derive(Debug, Clone, clap::Args)]
pub struct Configure {
/// Name of the instance to configure.
instance: String,
}
impl Command for Configure {
fn run(&self, _flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
let mut endpoint_reader = EndpointReader::new();
endpoint_reader.read_endpoints(data.secrets_source, &data.configuration.endpoints)?;
let Some(mut configure) = data
.configuration
.init(&self.instance, endpoint_reader.env())?
else {
return Ok(());
};
configure.run("apply")?;
configure.cleanup()?;
Ok(())
}
fn instance_name(&self) -> &str {
&self.instance
}
}

View file

@ -0,0 +1,110 @@
use std::{net::Ipv4Addr, path::Path};
use serde::Deserialize;
use super::Command;
use crate::{EndpointReader, ProvisioningData};
#[derive(Debug, Clone, clap::Args)]
pub struct Create {
/// Name of the instance to create.
instance: String,
#[arg(short = 's')]
skip_sanitity_checks: bool,
}
impl Command for Create {
fn run(&self, flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
if data.compute.config.is_none() {
return Err(anyhow::format_err!(
"No compute resources allocated for {}",
self.instance
));
}
if !self.skip_sanitity_checks {
log::info!("Building system configuration to ensure it is installable");
// First lets make sure we can build what we're trying to create
let mut proc = common::proc::Command::new("nix");
proc.args([
"build",
"--no-link",
&format!(
"{}#nixosConfigurations.\"{}\".config.system.build.toplevel",
flake_path.display(),
self.instance
),
]);
proc.stderr_inherit().try_spawn_to_bytes()?;
}
let mut endpoint_reader = EndpointReader::new();
endpoint_reader.read_endpoints(data.secrets_source, &data.persistence.endpoints)?;
if let Some(mut persistence) = data
.persistence
.init(&self.instance, endpoint_reader.env())?
{
persistence.run("apply")?;
persistence.cleanup()?;
}
endpoint_reader.read_endpoints(data.secrets_source, &data.compute.endpoints)?;
let mut compute = data
.compute
.init(&self.instance, endpoint_reader.env())?
.expect("Verified earlier that config is not none");
compute.run("apply")?;
#[derive(Deserialize)]
struct Output {
ipv4_address: Ipv4AddrValue,
}
#[derive(Deserialize)]
struct Ipv4AddrValue {
value: Ipv4Addr,
}
let output: Output = compute.output()?;
compute.cleanup()?;
nixos_anywhere(
flake_path,
&self.instance,
&data.image_username,
&output.ipv4_address.value,
)?;
endpoint_reader.read_endpoints(
data.secrets_source,
&data.compute_with_persistence_attached.endpoints,
)?;
if let Some(mut compute_with_persistence) = data
.compute_with_persistence_attached
.init(&self.instance, endpoint_reader.env())?
{
compute_with_persistence.run("apply")?;
compute_with_persistence.cleanup()?;
}
Ok(())
}
fn instance_name(&self) -> &str {
&self.instance
}
}
fn nixos_anywhere(
flake_path: &Path,
instance: &str,
username: &str,
ip_addr: &Ipv4Addr,
) -> anyhow::Result<()> {
let mut proc = common::proc::Command::new("nixos-anywhere");
let flake_arg = format!("{}#{instance}", flake_path.display());
let host_arg = format!("{username}@{ip_addr}");
proc.args(["--flake", &flake_arg, "--target-host", &host_arg]);
if !proc.stderr_inherit().try_spawn_stdout_inherit()?.success() {
return Err(anyhow::format_err!("nixos-anywhere didn't succeed"));
}
Ok(())
}

View file

@ -0,0 +1,67 @@
use std::path::Path;
use super::Command;
use crate::{EndpointReader, ProvisioningData};
#[derive(Debug, Clone, clap::Args)]
pub struct Destroy {
/// Name of the instance to destroy.
instance: String,
/// Also destroy the persistence, ie. disks
#[arg(short = 'p', long = "delete-persistence")]
persistence: bool,
}
impl Command for Destroy {
fn run(&self, _flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
let mut endpoint_reader = EndpointReader::new();
endpoint_reader.read_endpoints(data.secrets_source, &data.configuration.endpoints)?;
log::trace!("Testing if configuration needs destroyed");
if let Some(mut configure) = data
.configuration
.init(&self.instance, endpoint_reader.env())?
{
log::info!("Destroying configuration...");
configure.run("destroy")?;
configure.cleanup()?;
log::info!("Configuration destroyed.");
} else {
log::trace!("Configuration does not need to be destroyed");
}
endpoint_reader.read_endpoints(
data.secrets_source,
&data.compute_with_persistence_attached.endpoints,
)?;
let Some(mut destroy) = data
.compute_with_persistence_attached
.init(&self.instance, endpoint_reader.env())?
else {
return Ok(());
};
log::info!("Destroying compute resources...");
destroy.run("destroy")?;
destroy.cleanup()?;
log::info!("Compute resources destroyed.");
if self.persistence {
endpoint_reader.read_endpoints(data.secrets_source, &data.persistence.endpoints)?;
let Some(mut persistence) = data
.persistence
.init(&self.instance, endpoint_reader.env())?
else {
return Ok(());
};
log::info!("Destroying persistence resources...");
persistence.run("destroy")?;
persistence.cleanup()?;
log::info!("Persistence resources destroyed.");
}
Ok(())
}
fn instance_name(&self) -> &str {
&self.instance
}
}

View file

@ -0,0 +1,54 @@
use std::path::Path;
use clap::Subcommand;
use crate::ProvisioningData;
mod configure;
mod create;
mod destroy;
mod update;
use configure::Configure;
use create::Create;
use destroy::Destroy;
use update::Update;
pub trait Command {
fn run(&self, flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()>;
fn instance_name(&self) -> &str;
}
#[derive(Debug, Subcommand)]
pub enum InstanceCommand {
/// Creates the instance.
Create(Create),
/// Updates the provisioned resources for the instance.
Update(Update),
/// Configures the software installed on he instance.
Configure(Configure),
/// Destroys the instance, but leaves behind persistent data, unless forcing deletion.
Destroy(Destroy),
}
impl InstanceCommand {
fn instance_name(&self) -> &str {
match self {
Self::Configure(c) => c.instance_name(),
Self::Create(c) => c.instance_name(),
Self::Update(u) => u.instance_name(),
Self::Destroy(d) => d.instance_name(),
}
}
pub fn run(&self) -> anyhow::Result<()> {
let flake_path = common::env::read_path_env("FLAKE_PATH")?;
let data = ProvisioningData::try_new(self.instance_name(), &flake_path)?;
match self {
Self::Create(create) => create.run(&flake_path, data),
Self::Destroy(destroy) => destroy.run(&flake_path, data),
Self::Configure(configure) => configure.run(&flake_path, data),
Self::Update(update) => update.run(&flake_path, data),
}
}
}

View file

@ -0,0 +1,41 @@
use std::path::Path;
use super::Command;
use crate::{EndpointReader, ProvisioningData};
#[derive(Debug, Clone, clap::Args)]
pub struct Update {
/// Name of the instance to update.
instance: String,
}
impl Command for Update {
fn run(&self, _flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
let mut endpoint_reader = EndpointReader::new();
endpoint_reader.read_endpoints(data.secrets_source, &data.persistence.endpoints)?;
if let Some(mut persistence) = data
.persistence
.init(&self.instance, endpoint_reader.env())?
{
persistence.run("apply")?;
persistence.cleanup()?;
}
endpoint_reader.read_endpoints(
data.secrets_source,
&data.compute_with_persistence_attached.endpoints,
)?;
let Some(mut configure) = data
.compute_with_persistence_attached
.init(&self.instance, endpoint_reader.env())?
else {
return Ok(());
};
configure.run("apply")?;
configure.cleanup()?;
Ok(())
}
fn instance_name(&self) -> &str {
&self.instance
}
}

View file

@ -0,0 +1,120 @@
use std::{
collections::{BTreeMap, BTreeSet},
ffi::{CString, OsString},
};
#[cfg(target_os = "macos")]
use std::{
convert::Infallible,
ffi::{CStr, OsStr},
};
use anyhow::Context as _;
use clap::Subcommand;
use common::bitwarden::BitwardenSession;
use crate::secrets::{CliEndpoint, transfer_from_bitwarden_to_vault};
mod instance;
#[derive(Debug, Subcommand)]
pub enum Command {
/// Runs commands against a single instance
Instance {
#[command(subcommand)]
command: instance::InstanceCommand,
},
/// Transfers secrets from bitwarden to openbao
#[command(name = "secrets-to-openbao")]
SecretsToOpenBao,
/// Wraps a program with environment variables fetched from openbao
#[command(name = "wrap-bao-program")]
WrapBaoProgram(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<CliEndpoint>,
/// Command to wrap
#[arg(allow_hyphen_values = true, last = true)]
pub cmd: Vec<String>,
}
impl Command {
pub fn run(self) -> anyhow::Result<()> {
match self {
Command::Instance { command } => command.run(),
Command::SecretsToOpenBao => {
let mut bw_session = BitwardenSession::unlock()?;
transfer_from_bitwarden_to_vault(&mut bw_session)
}
Command::WrapBaoProgram(wp) => wrap_program(wp),
}
}
}
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::<(OsString, OsString)>::new();
for (key, value) in std::env::vars() {
env.push((OsString::from(key), OsString::from(value)));
}
for env_set in unique {
let mut env_map = BTreeMap::new();
env_set.read_from_openbao(&mut env_map)?;
for (key, value) in env_map {
env.push((OsString::from(key.into_owned()), OsString::from(value)));
}
}
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)
};
unsafe {
execvpe(&args[0], args.as_slice(), env.as_slice())?;
}
// This will never get executed
Ok(())
}
#[cfg(target_os = "macos")]
/// Safety: No other threads may read or write environment variables when this function is called.
/// The easiest way to ensure this is using a single threaded program.
// Simple "bad" version of execvpe that also works on OSX
unsafe fn execvpe<SA: AsRef<CStr>, SEK: AsRef<OsStr>, SEV: AsRef<OsStr>>(
filename: &CStr,
args: &[SA],
environ: &[(SEK, SEV)],
) -> anyhow::Result<Infallible> {
let current_env = std::env::vars_os();
// Safety: Same as this function
unsafe { nix::env::clearenv()? };
for (key, val) in environ {
// Safety: Same as this function
unsafe { std::env::set_var(key.as_ref(), val.as_ref()) };
}
match nix::unistd::execvp(filename, args) {
Err(err) => {
unsafe { nix::env::clearenv()? };
for (key, val) in current_env {
unsafe { std::env::set_var(key.as_os_str(), val.as_os_str()) };
}
Err(err.into())
}
_ => unreachable!("execvp doesn't return on success"),
}
}

View file

@ -0,0 +1,225 @@
use common::bitwarden::BitwardenSession;
use serde::Deserialize;
use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use clap::Parser;
mod command;
mod secrets;
use crate::{command::Command, secrets::CliEndpoint};
fn main() {
common::entrypoint(program);
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[command(subcommand)]
pub command: Command,
}
fn program() -> anyhow::Result<()> {
let args = Args::parse();
args.command.run()
}
#[derive(Deserialize, Debug)]
struct ProvisioningData {
persistence: OpenTofuConfig,
compute: OpenTofuConfig,
compute_with_persistence_attached: OpenTofuConfig,
configuration: OpenTofuConfig,
secrets_source: SecretsSource,
image_username: String,
}
struct EndpointReader {
bw_session: Option<BitwardenSession>,
endpoints_read: BTreeSet<CliEndpoint>,
env: BTreeMap<Cow<'static, str>, String>,
}
impl EndpointReader {
pub fn new() -> Self {
Self {
bw_session: None,
endpoints_read: BTreeSet::new(),
env: BTreeMap::new(),
}
}
pub fn env(&self) -> &BTreeMap<Cow<'static, str>, String> {
&self.env
}
pub fn read_endpoints(
&mut self,
secrets_source: SecretsSource,
endpoints: &[CliEndpoint],
) -> anyhow::Result<()> {
let endpoints_to_read: Vec<_> = endpoints
.iter()
.copied()
.filter(|e| !self.endpoints_read.contains(e))
.collect();
if endpoints_to_read.is_empty() {
return Ok(());
}
match secrets_source {
SecretsSource::Bitwarden => {
if self.bw_session.is_none() {
let bw_session = BitwardenSession::unlock()?;
let _ = self.bw_session.insert(bw_session);
}
let session = self
.bw_session
.as_mut()
.expect("Should have bitwarden session");
for endpoint in endpoints_to_read {
endpoint.read_from_bitwarden(session, &mut self.env)?;
self.endpoints_read.insert(endpoint);
}
}
SecretsSource::Vault => {
for endpoint in endpoints_to_read {
endpoint.read_from_openbao(&mut self.env)?;
self.endpoints_read.insert(endpoint);
}
}
}
Ok(())
}
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
enum SecretsSource {
#[serde(rename = "bitwarden")]
Bitwarden,
#[serde(rename = "vault")]
Vault,
}
#[derive(Deserialize, Debug)]
struct OpenTofuConfig {
config: Option<PathBuf>,
endpoints: Vec<CliEndpoint>,
}
struct WorkDir {
path: PathBuf,
}
impl WorkDir {
pub fn try_new(template: &str) -> anyhow::Result<Self> {
let mut proc = common::proc::Command::new("mktemp");
proc.args(["-dt", template]);
let path: PathBuf = proc.try_spawn_to_string()?.into();
common::fs::create_dir_recursive(&path)?;
Ok(Self { path })
}
pub fn cleanup(self) -> anyhow::Result<()> {
common::fs::remove_dir_recursive(&self.path)?;
Ok(())
}
}
impl OpenTofuConfig {
pub fn init<'e, K: AsRef<str>, V: AsRef<str>>(
&self,
instance: &str,
env_map: &'e BTreeMap<K, V>,
) -> anyhow::Result<Option<OpenTofuInstance<'e, K, V>>> {
let Some(config) = self.config.as_ref() else {
return Ok(None);
};
let work_dir = WorkDir::try_new(&format!("{instance}-provision.XXXXXX"))?;
let config_path: PathBuf = work_dir.path.join("config.tf.json");
common::fs::create_link(config, &config_path)?;
let mut init_proc = common::proc::Command::new("tofu");
let chdir_arg = format!("-chdir={}", work_dir.path.display());
for (key, value) in env_map {
init_proc.env_sensitive(key.as_ref(), value.as_ref());
}
init_proc.args([&chdir_arg, "init"]);
init_proc.try_spawn_to_bytes()?;
Ok(Some(OpenTofuInstance {
work_dir,
env_map,
chdir_arg,
}))
}
}
struct OpenTofuInstance<'e, K, V> {
work_dir: WorkDir,
chdir_arg: String,
env_map: &'e BTreeMap<K, V>,
}
impl<'e, K: AsRef<str>, V: AsRef<str>> OpenTofuInstance<'e, K, V> {
pub fn run(&mut self, action: &str) -> anyhow::Result<()> {
let mut proc = common::proc::Command::new("tofu");
for (key, value) in self.env_map {
proc.env_sensitive(key.as_ref(), value.as_ref());
}
proc.args([&self.chdir_arg, action]);
proc.stdin_inherit();
proc.stderr_inherit();
proc.try_spawn_stdout_inherit()?;
Ok(())
}
pub fn output<D: for<'de> serde::Deserialize<'de>>(&mut self) -> anyhow::Result<D> {
let mut proc = common::proc::Command::new("tofu");
for (key, value) in self.env_map {
proc.env_sensitive(key.as_ref(), value.as_ref());
}
proc.args([&self.chdir_arg, "output", "-json"]);
proc.try_spawn_to_json()
}
pub fn cleanup(self) -> anyhow::Result<()> {
self.work_dir.cleanup()
}
}
impl ProvisioningData {
pub fn try_new(instance: &str, flake_path: &Path) -> anyhow::Result<Self> {
let mut proc = common::proc::Command::new("nix");
let base_attr = format!(
"{}#nixosConfigurations.\"{instance}\".config.khscodes.infrastructure.provisioning",
flake_path.display()
);
let script = r#"
let
data = prov: { inherit (prov) config endpoints; };
in
p: {
persistence = data p.persistence;
compute = data p.compute;
compute_with_persistence_attached = data p.combinedPersistenceAttachAndCompute;
configuration = data p.configuration;
secrets_source = p.secretsSource;
image_username = p.imageUsername;
}
"#;
proc.args(["eval", "--json", &base_attr, "--apply", script]);
log::info!("Building terranix configurations...");
let result = proc.stderr_inherit().try_spawn_to_json()?;
log::info!("Terranix configurations built.");
Ok(result)
}
}

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)
}