From 8640dce7bc37c747ecfda5e5447e9acd4f3e7b8a Mon Sep 17 00:00:00 2001 From: Kaare Hoff Skovgaard Date: Tue, 5 Aug 2025 21:59:07 +0200 Subject: [PATCH] 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. --- .forgejo/workflows/push.yaml | 3 +- nix/lib/mkBwEnv/default.nix | 71 ----- nix/packages/bitwarden-to-vault/default.nix | 26 +- nix/packages/bw-opentofu/default.nix | 14 - nix/packages/bw-opentofu/secrets-map.nix | 34 --- nix/packages/configure-instance/default.nix | 8 +- nix/packages/create-instance/default.nix | 21 +- nix/packages/destroy-instance/default.nix | 11 +- nix/packages/infrastructure/default.nix | 16 + nix/packages/instance-opentofu/default.nix | 33 -- nix/packages/nixos-install/default.nix | 26 -- nix/packages/openbao-helper/default.nix | 19 -- nix/packages/provision-instance/default.nix | 8 +- nix/packages/provision/default.nix | 38 --- .../default.nix | 4 +- rust/Cargo.lock | 27 +- rust/lib/common/src/bitwarden.rs | 105 +++---- .../Cargo.toml | 5 +- .../src/command/instance/configure.rs | 30 ++ .../src/command/instance/create.rs | 110 +++++++ .../src/command/instance/destroy.rs | 67 +++++ .../src/command/instance/mod.rs | 54 ++++ .../src/command/instance/update.rs | 41 +++ .../program/infrastructure/src/command/mod.rs | 120 ++++++++ rust/program/infrastructure/src/main.rs | 225 ++++++++++++++ .../infrastructure/src/secrets/endpoints.rs | 268 +++++++++++++++++ .../program/infrastructure/src/secrets/mod.rs | 128 ++++++++ .../src/secrets/openbao.rs} | 29 +- rust/program/openbao-helper/src/main.rs | 281 ------------------ rust/program/provision/Cargo.toml | 14 - rust/program/provision/src/main.rs | 281 ------------------ 31 files changed, 1159 insertions(+), 958 deletions(-) delete mode 100644 nix/lib/mkBwEnv/default.nix delete mode 100644 nix/packages/bw-opentofu/default.nix delete mode 100644 nix/packages/bw-opentofu/secrets-map.nix create mode 100644 nix/packages/infrastructure/default.nix delete mode 100644 nix/packages/instance-opentofu/default.nix delete mode 100644 nix/packages/nixos-install/default.nix delete mode 100644 nix/packages/openbao-helper/default.nix delete mode 100644 nix/packages/provision/default.nix rename rust/program/{openbao-helper => infrastructure}/Cargo.toml (77%) create mode 100644 rust/program/infrastructure/src/command/instance/configure.rs create mode 100644 rust/program/infrastructure/src/command/instance/create.rs create mode 100644 rust/program/infrastructure/src/command/instance/destroy.rs create mode 100644 rust/program/infrastructure/src/command/instance/mod.rs create mode 100644 rust/program/infrastructure/src/command/instance/update.rs create mode 100644 rust/program/infrastructure/src/command/mod.rs create mode 100644 rust/program/infrastructure/src/main.rs create mode 100644 rust/program/infrastructure/src/secrets/endpoints.rs create mode 100644 rust/program/infrastructure/src/secrets/mod.rs rename rust/program/{openbao-helper/src/enventry.rs => infrastructure/src/secrets/openbao.rs} (79%) delete mode 100644 rust/program/openbao-helper/src/main.rs delete mode 100644 rust/program/provision/Cargo.toml delete mode 100644 rust/program/provision/src/main.rs diff --git a/.forgejo/workflows/push.yaml b/.forgejo/workflows/push.yaml index aa2ea8a..41f36dc 100644 --- a/.forgejo/workflows/push.yaml +++ b/.forgejo/workflows/push.yaml @@ -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: diff --git a/nix/lib/mkBwEnv/default.nix b/nix/lib/mkBwEnv/default.nix deleted file mode 100644 index 8a842f4..0000000 --- a/nix/lib/mkBwEnv/default.nix +++ /dev/null @@ -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}" "$@" - ''; - }; -} diff --git a/nix/packages/bitwarden-to-vault/default.nix b/nix/packages/bitwarden-to-vault/default.nix index 911a55b..44d460f 100644 --- a/nix/packages/bitwarden-to-vault/default.nix +++ b/nix/packages/bitwarden-to-vault/default.nix @@ -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 + ''; } diff --git a/nix/packages/bw-opentofu/default.nix b/nix/packages/bw-opentofu/default.nix deleted file mode 100644 index 2bf7af7..0000000 --- a/nix/packages/bw-opentofu/default.nix +++ /dev/null @@ -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; -} diff --git a/nix/packages/bw-opentofu/secrets-map.nix b/nix/packages/bw-opentofu/secrets-map.nix deleted file mode 100644 index 9f53065..0000000 --- a/nix/packages/bw-opentofu/secrets-map.nix +++ /dev/null @@ -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"; - }; -} diff --git a/nix/packages/configure-instance/default.nix b/nix/packages/configure-instance/default.nix index 5205d83..fb5140f 100644 --- a/nix/packages/configure-instance/default.nix +++ b/nix/packages/configure-instance/default.nix @@ -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 "$@" ''; } diff --git a/nix/packages/create-instance/default.nix b/nix/packages/create-instance/default.nix index c94be95..ed0fa04 100644 --- a/nix/packages/create-instance/default.nix +++ b/nix/packages/create-instance/default.nix @@ -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 "$@" ''; } diff --git a/nix/packages/destroy-instance/default.nix b/nix/packages/destroy-instance/default.nix index e6174ca..b976994 100644 --- a/nix/packages/destroy-instance/default.nix +++ b/nix/packages/destroy-instance/default.nix @@ -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 "$@" ''; } diff --git a/nix/packages/infrastructure/default.nix b/nix/packages/infrastructure/default.nix new file mode 100644 index 0000000..872ef99 --- /dev/null +++ b/nix/packages/infrastructure/default.nix @@ -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 + ]; +} diff --git a/nix/packages/instance-opentofu/default.nix b/nix/packages/instance-opentofu/default.nix deleted file mode 100644 index 686d8e6..0000000 --- a/nix/packages/instance-opentofu/default.nix +++ /dev/null @@ -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 - ''; -} diff --git a/nix/packages/nixos-install/default.nix b/nix/packages/nixos-install/default.nix deleted file mode 100644 index 4d1b428..0000000 --- a/nix/packages/nixos-install/default.nix +++ /dev/null @@ -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" - ''; -} diff --git a/nix/packages/openbao-helper/default.nix b/nix/packages/openbao-helper/default.nix deleted file mode 100644 index 6d7144d..0000000 --- a/nix/packages/openbao-helper/default.nix +++ /dev/null @@ -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 - ]; -} diff --git a/nix/packages/provision-instance/default.nix b/nix/packages/provision-instance/default.nix index 1dd64a5..4906744 100644 --- a/nix/packages/provision-instance/default.nix +++ b/nix/packages/provision-instance/default.nix @@ -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 "$@" ''; } diff --git a/nix/packages/provision/default.nix b/nix/packages/provision/default.nix deleted file mode 100644 index e6e139a..0000000 --- a/nix/packages/provision/default.nix +++ /dev/null @@ -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" - ''; -} diff --git a/nix/packages/upload-openstack-base-debian-image/default.nix b/nix/packages/upload-openstack-base-debian-image/default.nix index 42de37b..def0db3 100644 --- a/nix/packages/upload-openstack-base-debian-image/default.nix +++ b/nix/packages/upload-openstack-base-debian-image/default.nix @@ -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 ''; } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 20fd84f..6c173a7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/lib/common/src/bitwarden.rs b/rust/lib/common/src/bitwarden.rs index 82f22ae..2c3a8fa 100644 --- a/rust/lib/common/src/bitwarden.rs +++ b/rust/lib/common/src/bitwarden.rs @@ -108,7 +108,7 @@ pub enum BitwardenEntryFieldType { #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct BitwardenEntryField { - pub name: String, + pub name: Option, pub value: Option, #[serde(rename = "type")] pub field_type: BitwardenEntryFieldType, @@ -154,6 +154,7 @@ pub enum BitwardenOrganizationUserStatus { pub struct BitwardenSession { session_id: Option, + items: Option>, } impl BitwardenSession { @@ -171,11 +172,8 @@ impl BitwardenSession { Ok(()) } - pub fn new_or_authenticate( - username: Option<&str>, - bw_unlock_purpose: &str, - ) -> anyhow::Result { - let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, true)?; + pub fn unlock() -> anyhow::Result { + 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> { + fn new_if_authenticated(sync: bool) -> anyhow::Result> { 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> { - log::info!("Listing bitwarden items..."); - self.bw_command() - // Pretty format for better error messages during json decoding issues - .args(["--pretty", "list", "items"]) - .try_spawn_to_json() + pub fn list_items(&mut self) -> anyhow::Result<&[BitwardenEntry]> { + if self.items.is_none() { + log::info!("Listing bitwarden items..."); + let result = self + .bw_command() + // Pretty format for better error messages during json decoding issues + .args(["--pretty", "list", "items"]) + .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> { - 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> { + let items = self.list_items()?; + Ok(items.iter().find(|e| e.name() == name)) } - pub fn get_attachment(&self, entry: &BitwardenEntry, name: &str) -> anyhow::Result> { + pub fn get_attachment( + &mut self, + entry: &BitwardenEntry, + name: &str, + ) -> anyhow::Result> { 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> { - 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 { + pub fn create_item(&mut self, item: &CommandBitwardenEntry) -> anyhow::Result { 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, diff --git a/rust/program/openbao-helper/Cargo.toml b/rust/program/infrastructure/Cargo.toml similarity index 77% rename from rust/program/openbao-helper/Cargo.toml rename to rust/program/infrastructure/Cargo.toml index b73ed62..8a8bdf4 100644 --- a/rust/program/openbao-helper/Cargo.toml +++ b/rust/program/infrastructure/Cargo.toml @@ -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" } diff --git a/rust/program/infrastructure/src/command/instance/configure.rs b/rust/program/infrastructure/src/command/instance/configure.rs new file mode 100644 index 0000000..45b06a9 --- /dev/null +++ b/rust/program/infrastructure/src/command/instance/configure.rs @@ -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 + } +} diff --git a/rust/program/infrastructure/src/command/instance/create.rs b/rust/program/infrastructure/src/command/instance/create.rs new file mode 100644 index 0000000..589744a --- /dev/null +++ b/rust/program/infrastructure/src/command/instance/create.rs @@ -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(()) +} diff --git a/rust/program/infrastructure/src/command/instance/destroy.rs b/rust/program/infrastructure/src/command/instance/destroy.rs new file mode 100644 index 0000000..1a14b34 --- /dev/null +++ b/rust/program/infrastructure/src/command/instance/destroy.rs @@ -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 + } +} diff --git a/rust/program/infrastructure/src/command/instance/mod.rs b/rust/program/infrastructure/src/command/instance/mod.rs new file mode 100644 index 0000000..6d4ec5f --- /dev/null +++ b/rust/program/infrastructure/src/command/instance/mod.rs @@ -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), + } + } +} diff --git a/rust/program/infrastructure/src/command/instance/update.rs b/rust/program/infrastructure/src/command/instance/update.rs new file mode 100644 index 0000000..2811578 --- /dev/null +++ b/rust/program/infrastructure/src/command/instance/update.rs @@ -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 + } +} diff --git a/rust/program/infrastructure/src/command/mod.rs b/rust/program/infrastructure/src/command/mod.rs new file mode 100644 index 0000000..8e98a1d --- /dev/null +++ b/rust/program/infrastructure/src/command/mod.rs @@ -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, + /// Command to wrap + #[arg(allow_hyphen_values = true, last = true)] + pub cmd: Vec, +} + +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, SEK: AsRef, SEV: AsRef>( + filename: &CStr, + args: &[SA], + environ: &[(SEK, SEV)], +) -> anyhow::Result { + 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"), + } +} diff --git a/rust/program/infrastructure/src/main.rs b/rust/program/infrastructure/src/main.rs new file mode 100644 index 0000000..59cb54e --- /dev/null +++ b/rust/program/infrastructure/src/main.rs @@ -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, + endpoints_read: BTreeSet, + env: BTreeMap, String>, +} + +impl EndpointReader { + pub fn new() -> Self { + Self { + bw_session: None, + endpoints_read: BTreeSet::new(), + env: BTreeMap::new(), + } + } + pub fn env(&self) -> &BTreeMap, 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, + endpoints: Vec, +} + +struct WorkDir { + path: PathBuf, +} + +impl WorkDir { + pub fn try_new(template: &str) -> anyhow::Result { + 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, V: AsRef>( + &self, + instance: &str, + env_map: &'e BTreeMap, + ) -> anyhow::Result>> { + 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, +} + +impl<'e, K: AsRef, V: AsRef> 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 serde::Deserialize<'de>>(&mut self) -> 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, "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 { + 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) + } +} diff --git a/rust/program/infrastructure/src/secrets/endpoints.rs b/rust/program/infrastructure/src/secrets/endpoints.rs new file mode 100644 index 0000000..366844e --- /dev/null +++ b/rust/program/infrastructure/src/secrets/endpoints.rs @@ -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, 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, 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 = 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( + endpoint: E, + session: &mut BitwardenSession, + all_entries: &mut Vec, +) -> 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::>() + .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; + +#[derive(Deserialize)] +struct KvItemMetadataVersion { + created_time: String, + destroyed: bool, +} diff --git a/rust/program/infrastructure/src/secrets/mod.rs b/rust/program/infrastructure/src/secrets/mod.rs new file mode 100644 index 0000000..3cd6a72 --- /dev/null +++ b/rust/program/infrastructure/src/secrets/mod.rs @@ -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, String>, + ) -> anyhow::Result<()>; + + fn read_from_openbao( + &self, + map: &mut BTreeMap, String>, + ) -> anyhow::Result<()>; +} + +impl EndpointReader for E { + fn read_from_bitwarden( + &self, + session: &mut BitwardenSession, + map: &mut BTreeMap, 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, String>, + ) -> anyhow::Result<()> { + let result = read_bao_data::()?; + for (key, value) in result { + map.insert(key.into(), value); + } + Ok(()) + } +} diff --git a/rust/program/openbao-helper/src/enventry.rs b/rust/program/infrastructure/src/secrets/openbao.rs similarity index 79% rename from rust/program/openbao-helper/src/enventry.rs rename to rust/program/infrastructure/src/secrets/openbao.rs index 0a38665..237e3bd 100644 --- a/rust/program/openbao-helper/src/enventry.rs +++ b/rust/program/infrastructure/src/secrets/openbao.rs @@ -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(Vec<(&'static str, String)>, PhantomData); -impl EnvEntry { +impl EnvEntry { pub fn try_new_from_env() -> anyhow::Result { - 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 EnvEntry { } pub fn read_from_bao() -> anyhow::Result { - read_bao_data() + read_bao_data::() } } @@ -44,7 +41,7 @@ impl IntoIterator for EnvEntry { } } -impl<'de, T: EnvEntryConfig> serde::Deserialize<'de> for EnvEntry { +impl<'de, T: Endpoint> serde::Deserialize<'de> for EnvEntry { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -54,13 +51,13 @@ impl<'de, T: EnvEntryConfig> serde::Deserialize<'de> for EnvEntry { } struct EnvEntryVisitor(PhantomData); -impl<'de, T: EnvEntryConfig> serde::de::Visitor<'de> for EnvEntryVisitor { +impl<'de, T: Endpoint> serde::de::Visitor<'de> for EnvEntryVisitor { type Value = EnvEntry; 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 { { 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 { data: T, } -fn read_bao_data() -> anyhow::Result> { +pub fn read_bao_data() -> anyhow::Result> { 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> = cmd.try_spawn_to_json()?; Ok(result.data.data) } diff --git a/rust/program/openbao-helper/src/main.rs b/rust/program/openbao-helper/src/main.rs deleted file mode 100644 index 2993082..0000000 --- a/rust/program/openbao-helper/src/main.rs +++ /dev/null @@ -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, - /// Command to wrap - #[arg(allow_hyphen_values = true, last = true)] - pub cmd: Vec, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] -pub enum Endpoint { - #[value(name = "openstack")] - Openstack, - #[value(name = "cloudflare")] - Cloudflare, - #[value(name = "aws")] - Aws, - #[value(name = "hcloud")] - Hcloud, - #[value(name = "unifi")] - Unifi, - #[value(name = "vault")] - Vault, - #[value(name = "mx.kaareskovgaard.net")] - MxKaareSkovgaardNet, -} - -impl Endpoint { - pub fn try_into_env_data(self) -> anyhow::Result> { - 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(entry: EnvEntry) -> 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, SEK: AsRef, SEV: AsRef>( - filename: &CStr, - args: &[SA], - environ: &[(SEK, SEV)], -) -> anyhow::Result { - 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::>>()?; - 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, SEK: AsRef, SEV: AsRef>( - filename: &CStr, - args: &[SA], - environ: &[(SEK, SEV)], -) -> anyhow::Result { - 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"), - } -} diff --git a/rust/program/provision/Cargo.toml b/rust/program/provision/Cargo.toml deleted file mode 100644 index b7954de..0000000 --- a/rust/program/provision/Cargo.toml +++ /dev/null @@ -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" } diff --git a/rust/program/provision/src/main.rs b/rust/program/provision/src/main.rs deleted file mode 100644 index 02efec0..0000000 --- a/rust/program/provision/src/main.rs +++ /dev/null @@ -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, - endpoints: Vec, -} - -struct WorkDir { - path: PathBuf, -} - -impl WorkDir { - pub fn try_new(template: &str) -> anyhow::Result { - 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, V: AsRef>( - &self, - instance: &str, - env_map: &'e BTreeMap, - ) -> anyhow::Result>> { - 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, -} - -impl<'e, K: AsRef, V: AsRef> 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 serde::Deserialize<'de>>(&mut self) -> 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, "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 { - 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::::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(()) -}