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

@ -18,8 +18,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: |
nix build --no-link '.#packages.x86_64-linux.ed25519-helper'
nix build --no-link '.#packages.x86_64-linux.hetzner-static-ip'
nix build --no-link '.#packages.x86_64-linux.openbao-helper'
nix build --no-link '.#packages.x86_64-linux.infrastructure'
terraform-providers:
runs-on: cache.kaareskovgaard.net
steps:

View file

@ -1,71 +0,0 @@
{ ... }:
{
mkBwEnv =
{
items,
pkgs,
name ? "bw-env",
exe,
}:
let
assertMsg = pred: msg: pred || builtins.throw msg;
concatLines = builtins.concatStringsSep "\n";
itemNames = builtins.attrNames items;
exports = builtins.map (
itemName:
let
item = items.${itemName};
varNames = builtins.attrNames item;
vars = builtins.map (
varName:
let
var = item.${varName};
script =
if var == "login.password" || var == "login.username" then
"jq -r '.${var}'"
else
"jq -r --arg name '${var}' \"$JQ_FIELD_SCRIPT\"";
in
assert assertMsg (builtins.match "^[a-zA-Z0-9_]+$" != null) "Invalid variable name: ${varName}";
''
${varName}="$(${script} <<< "$item")"
if [[ "''$${varName}" == "null" ]]; then
echo "Could not read ${varName} from ${itemName}" 1>&2
exit 1
fi
export ${varName}
''
) varNames;
in
''
item="$(bw get item '${itemName}')"
${concatLines vars}
''
) itemNames;
in
pkgs.writeShellApplication {
inherit name;
runtimeInputs = [
pkgs.jq
pkgs.bitwarden-cli
];
meta = {
mainProgram = name;
};
text = ''
if [ "''${DEBUG:-0}" == "1" ]; then
set -x
fi
if [ -z "''${BW_SESSION+x}" ]; then
BW_SESSION="$(bw unlock --raw)"
echo "Bitwarden session (export as BW_SESSION to avoid repeating entries): $BW_SESSION" 1>&2
export BW_SESSION
fi
JQ_FIELD_SCRIPT='.fields | map(select(.name == $'"name))[0].value"
${concatLines exports}
exec "${exe}" "$@"
'';
};
}

View file

@ -1,19 +1,11 @@
{ 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;
{ pkgs, ... }:
pkgs.writeShellApplication {
name = "bitwarden-to-vault";
items = import ../bw-opentofu/secrets-map.nix;
exe = lib.getExe script;
meta = {
mainProgram = "bitwarden-to-vault";
};
runtimeInputs = [ pkgs.khscodes.infrastructure ];
text = ''
infrastructure secrets-to-openbao
'';
}

View file

@ -1,14 +0,0 @@
{ pkgs, lib, ... }:
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 = import ./secrets-map.nix;
wrappedScript = pkgs.khscodes.instance-opentofu;
in
lib.khscodes.mkBwEnv {
inherit pkgs;
name = "bw-opentofu";
items = secrets;
exe = lib.getExe wrappedScript;
}

View file

@ -1,34 +0,0 @@
{
"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" = {
UNIFI_USERNAME = "Terraform username";
UNIFI_PASSWORD = "Terraform password";
UNIFI_API = "Terraform URL";
};
"auth.kaareskovgaard.net" = {
"AUTHENTIK_TOKEN" = "Admin API Token";
"TF_VAR_authentik_username" = "login.username";
};
"secrets.kaareskovgaard.net" = {
"VAULT_TOKEN" = "Initial root token";
};
"mx.kaareskovgaard.net" = {
"MX_KAARESKOVGAARD_NET_ZROOT_ENCRYPTION_KEY" = "ZROOT_ENCRYPTION_KEY";
};
}

View file

@ -1,10 +1,8 @@
{ pkgs, ... }:
{ pkgs, inputs, ... }:
pkgs.writeShellApplication {
name = "configure-instance";
runtimeInputs = [ pkgs.khscodes.provision ];
runtimeInputs = [ pkgs.khscodes.infrastructure ];
text = ''
instance="''${1:-}"
cmd="''${2:-apply}"
provision "$instance" configuration "$cmd"
FLAKE_PATH=${inputs.self} infrastructure instance configure "$@"
'';
}

View file

@ -2,26 +2,9 @@
pkgs.writeShellApplication {
name = "create-instance";
runtimeInputs = [
pkgs.khscodes.provision
pkgs.khscodes.nixos-install
pkgs.jq
pkgs.khscodes.infrastructure
];
text = ''
hostname="$1"
# Build the configuration to ensure it doesn't fail when trying to install it on the host
nix build --no-link '${inputs.self}#nixosConfigurations."'"$hostname"'".config.system.build.toplevel'
# First ensure the persistence exists
provision "$hostname" persistence apply
# Then bring up the base instance *without* the persistence disks attached
output="$(provision "$hostname" compute apply)"
ipv4_addr="$(echo "$output" | jq --raw-output '.ipv4_address.value')"
nixos-install "$hostname" "$ipv4_addr" "no"
# After nixos-anywhere has messed with the ephemeral disks, then mount the remaining disks
provision "$hostname" combinedPersistenceAttachAndCompute apply
# Finally reboot the instance, to ensure everything boots up properly
ssh -t -o StrictHostKeyChecking=false -o UserKnownHostsFile=/dev/null "$ipv4_addr" -- sudo reboot
FLAKE_PATH=${inputs.self} infrastructure instance create "$@"
'';
}

View file

@ -1,15 +1,10 @@
{ pkgs, ... }:
{ pkgs, inputs, ... }:
pkgs.writeShellApplication {
name = "destroy-instance";
runtimeInputs = [
pkgs.khscodes.provision
pkgs.khscodes.infrastructure
];
text = ''
instance="''${1:-}"
with_persistence="''${2:-none}"
provision "$instance" combinedPersistenceAttachAndCompute destroy
if [[ "$with_persistence" == "all" ]]; then
provision "$instance" persistence destroy
fi
FLAKE_PATH=${inputs.self} infrastructure instance destroy "$@"
'';
}

View file

@ -0,0 +1,16 @@
{
lib,
inputs,
pkgs,
}:
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage {
crateName = "infrastructure";
runtimeInputs = [
pkgs.openssh
pkgs.openbao
pkgs.khscodes.opentofu
pkgs.nixos-anywhere
pkgs.uutils-coreutils-noprefix
pkgs.nix
];
}

View file

@ -1,33 +0,0 @@
{ pkgs, lib, ... }:
let
opentofu = pkgs.khscodes.opentofu;
opentofuExe = lib.getExe opentofu;
in
pkgs.writeShellApplication {
name = "instance-opentofu";
runtimeInputs = [
pkgs.uutils-coreutils-noprefix
]
++ [
# Needed for the data.external stuff in dkim for mailserver
pkgs.openssl
pkgs.uutils-coreutils-noprefix
pkgs.jq
];
text = ''
fqdn="$1"
config="$2"
cmd="''${3:-apply}"
dir="$(mktemp -dt "$fqdn-provision.XXXXXX")"
mkdir -p "$dir"
cat "''${config}" > "$dir/config.tf.json"
${opentofuExe} -chdir="$dir" init > /dev/null
if [[ "$cmd" == "apply" ]]; then
${opentofuExe} -chdir="$dir" "$cmd" >&2
${opentofuExe} -chdir="$dir" output -json
else
${opentofuExe} -chdir="$dir" "$cmd"
fi
'';
}

View file

@ -1,26 +0,0 @@
{
inputs,
pkgs,
}:
pkgs.writeShellApplication {
name = "nixos-install";
runtimeInputs = [
pkgs.nix
pkgs.nixos-anywhere
];
# TODO: Use secret source and required secrets to set up the correct env variables
text = ''
hostname="$1"
# Allow overriding the host to connec tto, this is useful when testing and the DNS entries are stale with older IPs.
host="''${2:-$1}"
verify="''${3:-yes}"
if [[ "$verify" == "yes" ]]; then
# Build the configuration to ensure it doesn't fail when trying to install it on the host
nix build --no-link '${inputs.self}#nixosConfigurations."'"$hostname"'".config.system.build.toplevel'
fi
baseAttr='${inputs.self}#nixosConfigurations."'"$hostname"'".config.khscodes.infrastructure'
username="$(nix eval --raw "''${baseAttr}.provisioning.imageUsername")"
nixos-anywhere --flake "${inputs.self}#$hostname" --target-host "$username@$host"
'';
}

View file

@ -1,19 +0,0 @@
{
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
];
}

View file

@ -1,10 +1,8 @@
{ pkgs, ... }:
{ pkgs, inputs, ... }:
pkgs.writeShellApplication {
name = "provision-instance";
runtimeInputs = [ pkgs.khscodes.provision ];
runtimeInputs = [ pkgs.khscodes.infrastructure ];
text = ''
instance="''${1:-}"
provision "$instance" persistence apply
provision "$instance" combinedPersistenceAttachAndCompute apply
FLAKE_PATH=${inputs.self} infrastructure instance update "$@"
'';
}

View file

@ -1,38 +0,0 @@
{
inputs,
pkgs,
}:
pkgs.writeShellApplication {
name = "provision";
runtimeInputs = [
pkgs.nix
pkgs.khscodes.bw-opentofu
pkgs.khscodes.instance-opentofu
pkgs.khscodes.openbao-helper
pkgs.jq
];
# TODO: Use secret source and required secrets to set up the correct env variables
text = ''
hostname="$1"
stage="$2"
cmd="''${3:-apply}"
baseAttr='${inputs.self}#nixosConfigurations."'"$hostname"'".config.khscodes.infrastructure.provisioning'
if [[ "$(nix eval "''${baseAttr}.''${stage}.config")" != "null" ]]; then
config="$(nix build --no-link --print-out-paths "''${baseAttr}.''${stage}.config")"
else
config="null"
fi
secretsSource="$(nix eval --raw "''${baseAttr}.secretsSource")"
endpoints="$(nix eval --show-trace --json "''${baseAttr}.''${stage}.endpoints")"
if [[ "$config" == "null" ]]; then
echo "No ''${stage} provisioning needed"
exit 0
fi
if [[ "$secretsSource" == "vault" ]]; then
readarray -t endpoints_args < <(echo "$endpoints" | jq -cr 'map(["-e", .])[][]')
openbao-helper wrap-program "''${endpoints_args[@]}" -- instance-opentofu "$hostname" "$config" "$cmd"
exit 0
fi
bw-opentofu "$hostname" "$config" "$cmd"
'';
}

View file

@ -45,10 +45,10 @@ in
pkgs.writeShellApplication {
name = "upload-openstack-base-debian-image";
runtimeInputs = [
pkgs.khscodes.openbao-helper
pkgs.khscodes.infrastructure
script
];
text = ''
openbao-helper wrap-program -e openstack -- upload-openstack-base-debian-image-wrapped
infrastructure wrap-bao-program -e openstack -- upload-openstack-base-debian-image-wrapped
'';
}

27
rust/Cargo.lock generated
View file

@ -555,6 +555,20 @@ dependencies = [
"hashbrown 0.15.4",
]
[[package]]
name = "infrastructure"
version = "1.0.0"
dependencies = [
"anyhow",
"clap",
"common",
"hakari",
"log",
"nix",
"serde",
"serde_json",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@ -795,19 +809,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "provision"
version = "1.0.0"
dependencies = [
"anyhow",
"clap",
"common",
"hakari",
"log",
"serde",
"serde_json",
]
[[package]]
name = "quote"
version = "1.0.40"

View file

@ -108,7 +108,7 @@ pub enum BitwardenEntryFieldType {
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct BitwardenEntryField {
pub name: String,
pub name: Option<String>,
pub value: Option<String>,
#[serde(rename = "type")]
pub field_type: BitwardenEntryFieldType,
@ -154,6 +154,7 @@ pub enum BitwardenOrganizationUserStatus {
pub struct BitwardenSession {
session_id: Option<String>,
items: Option<Vec<BitwardenEntry>>,
}
impl BitwardenSession {
@ -171,11 +172,8 @@ impl BitwardenSession {
Ok(())
}
pub fn new_or_authenticate(
username: Option<&str>,
bw_unlock_purpose: &str,
) -> anyhow::Result<Self> {
let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, true)?;
pub fn unlock() -> anyhow::Result<Self> {
let bw = BitwardenSession::new_if_authenticated(true)?;
if let Some(bw) = bw {
bw.sync()?;
Ok(bw)
@ -187,7 +185,7 @@ impl BitwardenSession {
.stderr_inherit()
.try_spawn_to_string()?;
// Just logged in, no point in syncing
let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, false)?;
let bw = BitwardenSession::new_if_authenticated(false)?;
if let Some(bw) = bw {
Ok(bw)
} else {
@ -198,67 +196,61 @@ impl BitwardenSession {
}
}
fn new_if_authenticated(
username: Option<&str>,
bw_unlock_purpose: &str,
sync: bool,
) -> anyhow::Result<Option<Self>> {
fn new_if_authenticated(sync: bool) -> anyhow::Result<Option<Self>> {
let status: BitwardenAuthenticationStatus = proc::Command::new("bw")
.args(["--nointeraction", "status"])
.try_spawn_to_json()?;
let Some(user) = status.user() else {
let Some(_) = status.user() else {
return Ok(None);
};
if let Some(username) = username {
if user.user_email != username {
return Err(anyhow::format_err!(
"Authenticated user in bitwarden does not match the expected user of {}, was {}",
username,
user.user_email
));
}
}
let is_unlocked: bool = matches!(status, BitwardenAuthenticationStatus::Unlocked(_));
if sync && !is_unlocked {
log::info!("Syncing Bitwarden...");
let _ = proc::Command::new("bw").arg("sync").try_spawn_to_string()?;
}
log::info!("Unlocking bitwarden...");
let session_id = if is_unlocked {
None
} else {
log::info!("Unlocking bitwarden...");
Some(
proc::Command::new("bitwarden-unlock")
.args(["--purpose", bw_unlock_purpose])
proc::Command::new("bw")
.args(["unlock", "--raw"])
.stderr_inherit()
.stdin_inherit()
.try_spawn_to_string()?,
)
};
Ok(Some(Self { session_id }))
Ok(Some(Self {
session_id,
items: None,
}))
}
pub fn list_items(&self) -> anyhow::Result<Vec<BitwardenEntry>> {
pub fn list_items(&mut self) -> anyhow::Result<&[BitwardenEntry]> {
if self.items.is_none() {
log::info!("Listing bitwarden items...");
self.bw_command()
let result = self
.bw_command()
// Pretty format for better error messages during json decoding issues
.args(["--pretty", "list", "items"])
.try_spawn_to_json()
.try_spawn_to_json()?;
let _ = self.items.insert(result);
}
Ok(self.items.as_deref().expect("Items have just been set"))
}
pub fn get_item(&self, name: &str) -> anyhow::Result<Option<BitwardenEntry>> {
let mut items = self.list_items()?;
let Some(idx) = items
.iter()
.enumerate()
.find_map(|(idx, e)| if e.name() == name { Some(idx) } else { None })
else {
return Ok(None);
};
let item = items.swap_remove(idx);
Ok(Some(item))
pub fn get_item(&mut self, name: &str) -> anyhow::Result<Option<&BitwardenEntry>> {
let items = self.list_items()?;
Ok(items.iter().find(|e| e.name() == name))
}
pub fn get_attachment(&self, entry: &BitwardenEntry, name: &str) -> anyhow::Result<Vec<u8>> {
pub fn get_attachment(
&mut self,
entry: &BitwardenEntry,
name: &str,
) -> anyhow::Result<Vec<u8>> {
self.bw_command()
.args(["get", "attachment", name, "--itemid"])
.arg(entry.id.as_str())
@ -266,22 +258,19 @@ impl BitwardenSession {
.try_spawn_to_bytes()
}
pub fn list_own_items(&self) -> anyhow::Result<Vec<BitwardenEntry>> {
let mut items = self.list_items()?;
items.retain(|i| i.organization_id.as_ref().is_none_or(|o| o.is_empty()));
Ok(items)
}
pub fn create_item(&self, item: &CommandBitwardenEntry) -> anyhow::Result<BitwardenEntry> {
pub fn create_item(&mut self, item: &CommandBitwardenEntry) -> anyhow::Result<BitwardenEntry> {
log::info!("Creating bitwarden entry {name}", name = item.name);
self.bw_command()
let res = self
.bw_command()
.args(["create", "item"])
.stdin_json_base64(item)?
.try_spawn_to_json()
.try_spawn_to_json()?;
let _ = self.items.take();
Ok(res)
}
pub fn update_item(
&self,
&mut self,
to_update: &BitwardenEntry,
update_with: &CommandBitwardenEntry,
) -> anyhow::Result<()> {
@ -295,10 +284,11 @@ impl BitwardenSession {
.args(["edit", "item", &to_update.id])
.stdin_json_base64(update_with)?
.try_spawn_to_string()?;
let _ = self.items.take();
Ok(())
}
pub fn delete_item(&self, to_delete: &BitwardenEntry) -> anyhow::Result<()> {
pub fn delete_item(&mut self, to_delete: &BitwardenEntry) -> anyhow::Result<()> {
log::info!(
"Deleting bitwarden entry {name}, with id: {id}",
id = to_delete.id,
@ -308,13 +298,13 @@ impl BitwardenSession {
.bw_command()
.args(["delete", "item", &to_delete.id])
.try_spawn_to_string()?;
let _ = self.items.take();
Ok(())
}
}
impl Drop for BitwardenSession {
fn drop(&mut self) {
log::info!("Locking bitwarden session...");
if self.session_id.is_some() {
if let Err(e) = self
.bw_command()
@ -349,9 +339,6 @@ impl BitwardenAuthenticationStatus {
#[derive(Debug, Deserialize)]
struct BitwardenAuthenticationUser {
#[serde(rename = "userEmail")]
user_email: String,
#[serde(rename = "userId")]
#[allow(dead_code)]
user_id: String,

View file

@ -1,8 +1,8 @@
[package]
name = "openbao-helper"
name = "infrastructure"
edition = "2024"
version = "1.0.0"
metadata.crane.name = "openbao-helper"
metadata.crane.name = "infrastructure"
[dependencies]
anyhow = { workspace = true }
@ -11,4 +11,5 @@ 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

@ -2,17 +2,14 @@ use std::{collections::BTreeMap, marker::PhantomData, vec::IntoIter};
use serde::Deserialize;
pub trait EnvEntryConfig {
const SECRETS: &'static [&'static str];
const BAO_KEY: &'static str;
}
use crate::secrets::Endpoint;
pub struct EnvEntry<T>(Vec<(&'static str, String)>, PhantomData<T>);
impl<T: EnvEntryConfig> EnvEntry<T> {
impl<T: Endpoint> EnvEntry<T> {
pub fn try_new_from_env() -> anyhow::Result<Self> {
let mut result = Vec::with_capacity(T::SECRETS.len());
for key in T::SECRETS {
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));
}
@ -24,7 +21,7 @@ impl<T: EnvEntryConfig> EnvEntry<T> {
}
pub fn read_from_bao() -> anyhow::Result<Self> {
read_bao_data()
read_bao_data::<T>()
}
}
@ -44,7 +41,7 @@ impl<T> IntoIterator for EnvEntry<T> {
}
}
impl<'de, T: EnvEntryConfig> serde::Deserialize<'de> for EnvEntry<T> {
impl<'de, T: Endpoint> serde::Deserialize<'de> for EnvEntry<T> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
@ -54,13 +51,13 @@ impl<'de, T: EnvEntryConfig> serde::Deserialize<'de> for EnvEntry<T> {
}
struct EnvEntryVisitor<T>(PhantomData<T>);
impl<'de, T: EnvEntryConfig> serde::de::Visitor<'de> for EnvEntryVisitor<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::SECRETS.join(", "),
T::ENV_KEYS.join(", "),
))
}
@ -70,16 +67,16 @@ impl<'de, T: EnvEntryConfig> serde::de::Visitor<'de> for EnvEntryVisitor<T> {
{
let mut values = BTreeMap::<&'static str, String>::new();
while let Some((key, value)) = map.next_entry::<&'de str, String>()? {
let mapped_key = T::SECRETS.iter().find(|n| **n == key).copied();
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::SECRETS));
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::SECRETS {
for key in T::ENV_KEYS {
if !values.contains_key(key) {
return Err(serde::de::Error::missing_field(key));
}
@ -100,9 +97,9 @@ struct OpenBaoKvEntryData<T> {
data: T,
}
fn read_bao_data<T: EnvEntryConfig>() -> anyhow::Result<EnvEntry<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::BAO_KEY]);
cmd.args(["kv", "get", "-format=json", "-mount=opentofu", T::NAME]);
let result: OpenBaoKvEntry<EnvEntry<T>> = cmd.try_spawn_to_json()?;
Ok(result.data.data)
}

View file

@ -1,281 +0,0 @@
use std::{
collections::BTreeSet,
convert::Infallible,
ffi::{CStr, CString, OsStr, OsString},
};
use anyhow::Context as _;
use clap::{Parser, Subcommand};
mod enventry;
use enventry::*;
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<Endpoint>,
/// Command to wrap
#[arg(allow_hyphen_values = true, last = true)]
pub cmd: Vec<String>,
}
#[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,
#[value(name = "vault")]
Vault,
#[value(name = "mx.kaareskovgaard.net")]
MxKaareSkovgaardNet,
}
impl Endpoint {
pub fn try_into_env_data(self) -> anyhow::Result<Vec<(&'static str, String)>> {
match self {
Self::Openstack => {
let data = OpenstackData::read_from_bao()?;
Ok(data.into())
}
Self::Aws => {
let data = AwsData::read_from_bao()?;
Ok(data.into())
}
Self::Hcloud => {
let data = HcloudData::read_from_bao()?;
Ok(data.into())
}
Self::Cloudflare => {
let data = CloudflareData::read_from_bao()?;
Ok(data.into())
}
Self::Unifi => {
let data = UnifiData::read_from_bao()?;
Ok(data.into())
}
Self::Vault => {
let data = VaultData::read_from_bao()?;
Ok(data.into())
}
Self::MxKaareSkovgaardNet => {
let data = MxKaareSkovgaardNetData::read_from_bao()?;
Ok(data.into())
}
}
}
}
fn program() -> anyhow::Result<()> {
let args = Args::parse();
match args.command {
Commands::Transfer => transfer(),
Commands::WrapProgram(w) => wrap_program(w),
}
}
macro_rules! entry_definition {
($config_id:ident, $id: ident, $bao_key: expr, $secrets: expr) => {
struct $config_id;
impl EnvEntryConfig for $config_id {
const SECRETS: &'static [&'static str] = $secrets;
const BAO_KEY: &'static str = $bao_key;
}
type $id = EnvEntry<$config_id>;
};
}
entry_definition!(
OpenstackDataConfig,
OpenstackData,
"openstack",
&[
"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"
]
);
entry_definition!(
CloudflareDataConfig,
CloudflareData,
"cloudflare",
&["TF_VAR_cloudflare_token", "TF_VAR_cloudflare_email"]
);
entry_definition!(
AwsDataConfig,
AwsData,
"aws",
&["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
);
entry_definition!(
HcloudDataConfig,
HcloudData,
"hcloud",
&["TF_VAR_hcloud_api_token"]
);
entry_definition!(
UnifiDataConfig,
UnifiData,
"unifi",
&["UNIFI_USERNAME", "UNIFI_PASSWORD", "UNIFI_API"]
);
entry_definition!(VaultDataConfig, VaultData, "vault", &["VAULT_TOKEN"]);
entry_definition!(
MxKaareSkovgaardNetDataConfig,
MxKaareSkovgaardNetData,
"mx.kaareskovgaard.net",
&["MX_KAARESKOVGAARD_NET_ZROOT_ENCRYPTION_KEY"]
);
fn transfer() -> anyhow::Result<()> {
let openstack = OpenstackData::try_new_from_env()?;
let cloudflare = CloudflareData::try_new_from_env()?;
let aws = AwsData::try_new_from_env()?;
let hcloud = HcloudData::try_new_from_env()?;
let unifi = UnifiData::try_new_from_env()?;
let vault = VaultData::try_new_from_env()?;
let mx_kaareskovgaard_net = MxKaareSkovgaardNetData::try_new_from_env()?;
write_kv_data(openstack)?;
write_kv_data(cloudflare)?;
write_kv_data(aws)?;
write_kv_data(hcloud)?;
write_kv_data(unifi)?;
write_kv_data(vault)?;
write_kv_data(mx_kaareskovgaard_net)?;
Ok(())
}
fn write_kv_data<T: EnvEntryConfig>(entry: EnvEntry<T>) -> anyhow::Result<()> {
let mut cmd = common::proc::Command::new("bao");
cmd.args(["kv", "put", "-mount=opentofu"]);
cmd.arg(T::BAO_KEY);
for (key, value) in entry {
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::<(OsString, OsString)>::new();
for (key, value) in std::env::vars() {
env.push((OsString::from(key), OsString::from(value)));
}
for env_set in unique {
for (key, value) in env_set.try_into_env_data()? {
env.push((OsString::from(key), 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(not(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.
/// Note: On Linux specifically this safety requirement is not needed
unsafe fn execvpe<SA: AsRef<CStr>, SEK: AsRef<OsStr>, SEV: AsRef<OsStr>>(
filename: &CStr,
args: &[SA],
environ: &[(SEK, SEV)],
) -> anyhow::Result<Infallible> {
let environ = environ
.iter()
.map(|(k, v)| {
CString::new(format!(
"{k}={v}",
k = k.as_ref().display(),
v = v.as_ref().display()
))
.with_context(|| {
format!(
"Environment variable {k} contains null bytes",
k = k.as_ref().display()
)
})
})
.collect::<anyhow::Result<Vec<CString>>>()?;
Ok(nix::unistd::execvpe(filename, args, &environ)?)
}
#[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

@ -1,14 +0,0 @@
[package]
name = "provision"
edition = "2024"
version = "1.0.0"
metadata.crane.name = "provision"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
common = { path = "../../lib/common" }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
hakari = { version = "0.1", path = "../../lib/hakari" }

View file

@ -1,281 +0,0 @@
use serde::Deserialize;
use std::{
collections::BTreeMap,
net::Ipv4Addr,
path::{Path, PathBuf},
};
use clap::{Parser, Subcommand};
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 {
/// Creates the instance.
CreateInstance(CreateInstance),
/// Updates the provisioned resources for the instance.
Update(UpdateInstance),
/// Configures the software installed on he instance.
Configure(ConfigureInstance),
/// Destroys the instance, but leaves behind persistent data, unless forcing deletion.
Destroy(DestroyInstance),
}
#[derive(Debug, Clone, clap::Args)]
pub struct CreateInstance {
/// Name of the instance to create.
instance: String,
#[arg(short = 's')]
skip_sanitity_checks: bool,
}
#[derive(Debug, Clone, clap::Args)]
pub struct UpdateInstance {
/// Name of the instance to update.
instance: String,
}
#[derive(Debug, Clone, clap::Args)]
pub struct ConfigureInstance {
/// Name of the instance to configure.
instance: String,
}
#[derive(Debug, Clone, clap::Args)]
pub struct DestroyInstance {
/// Name of the instance to destroy.
instance: String,
}
fn program() -> anyhow::Result<()> {
let args = Args::parse();
let flake_path = common::env::read_path_env("FLAKE_PATH")?;
match args.command {
Commands::CreateInstance(instance) => create_instance(instance, &flake_path),
_ => Ok(()),
}
}
#[derive(Deserialize, Debug)]
struct ProvisioningData {
persistence: OpenTofuConfig,
compute: OpenTofuConfig,
compute_with_persistence_attached: OpenTofuConfig,
configuration: OpenTofuConfig,
secrets_source: SecretsSource,
image_username: String,
}
#[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<String>,
}
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(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(key.as_ref(), value.as_ref());
}
proc.args([&self.chdir_arg, action]);
proc.try_spawn_to_bytes()?;
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(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]);
let result = proc.stderr_inherit().try_spawn_to_json()?;
Ok(result)
}
}
fn nixos_install(
flake_path: &Path,
instance: &str,
username: &str,
ip_addr: &Ipv4Addr,
) -> anyhow::Result<()> {
let mut proc = common::proc::Command::new("nixos-install");
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-install didn't succeed"));
}
Ok(())
}
fn create_instance(c: CreateInstance, flake_path: &Path) -> anyhow::Result<()> {
let data = ProvisioningData::try_new(&c.instance, flake_path)?;
if data.compute.config.is_none() {
return Err(anyhow::format_err!(
"No compute resources allocated for {}",
c.instance
));
}
if !c.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(),
c.instance
),
]);
proc.stderr_inherit().try_spawn_to_bytes()?;
}
// TODO: Gather all needed endpoints and load the environments as needed from either Bitwarden or
// vault. Can probably get rid of the nix bw implementation and merge the rust helper program into this one
// to remove unneeded stuff.
let env = BTreeMap::<String, String>::new();
if let Some(mut persistence) = data.persistence.init(&c.instance, &env)? {
persistence.run("apply")?;
persistence.cleanup()?;
}
let mut compute = data
.compute
.init(&c.instance, &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_install(
flake_path,
&c.instance,
&data.image_username,
&output.ipv4_address.value,
)?;
if let Some(mut compute_with_persistence) = data
.compute_with_persistence_attached
.init(&c.instance, &env)?
{
compute_with_persistence.run("apply")?;
compute_with_persistence.cleanup()?;
}
Ok(())
}