Remove openbao helper and replace it with more general program
This gets rid of the messy nix code for handling bitwarden secrets, and unifies it all into a nice single program in rust. Ensuring that only the needed secrets are loaded.
This commit is contained in:
parent
e6a152e95c
commit
8640dce7bc
31 changed files with 1159 additions and 958 deletions
|
@ -18,8 +18,9 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: |
|
- 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.hetzner-static-ip'
|
||||||
nix build --no-link '.#packages.x86_64-linux.openbao-helper'
|
nix build --no-link '.#packages.x86_64-linux.infrastructure'
|
||||||
terraform-providers:
|
terraform-providers:
|
||||||
runs-on: cache.kaareskovgaard.net
|
runs-on: cache.kaareskovgaard.net
|
||||||
steps:
|
steps:
|
||||||
|
|
|
@ -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}" "$@"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,19 +1,11 @@
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, ... }:
|
||||||
let
|
pkgs.writeShellApplication {
|
||||||
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;
|
|
||||||
name = "bitwarden-to-vault";
|
name = "bitwarden-to-vault";
|
||||||
items = import ../bw-opentofu/secrets-map.nix;
|
meta = {
|
||||||
exe = lib.getExe script;
|
mainProgram = "bitwarden-to-vault";
|
||||||
|
};
|
||||||
|
runtimeInputs = [ pkgs.khscodes.infrastructure ];
|
||||||
|
text = ''
|
||||||
|
infrastructure secrets-to-openbao
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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";
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,10 +1,8 @@
|
||||||
{ pkgs, ... }:
|
{ pkgs, inputs, ... }:
|
||||||
pkgs.writeShellApplication {
|
pkgs.writeShellApplication {
|
||||||
name = "configure-instance";
|
name = "configure-instance";
|
||||||
runtimeInputs = [ pkgs.khscodes.provision ];
|
runtimeInputs = [ pkgs.khscodes.infrastructure ];
|
||||||
text = ''
|
text = ''
|
||||||
instance="''${1:-}"
|
FLAKE_PATH=${inputs.self} infrastructure instance configure "$@"
|
||||||
cmd="''${2:-apply}"
|
|
||||||
provision "$instance" configuration "$cmd"
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,26 +2,9 @@
|
||||||
pkgs.writeShellApplication {
|
pkgs.writeShellApplication {
|
||||||
name = "create-instance";
|
name = "create-instance";
|
||||||
runtimeInputs = [
|
runtimeInputs = [
|
||||||
pkgs.khscodes.provision
|
pkgs.khscodes.infrastructure
|
||||||
pkgs.khscodes.nixos-install
|
|
||||||
pkgs.jq
|
|
||||||
];
|
];
|
||||||
text = ''
|
text = ''
|
||||||
hostname="$1"
|
FLAKE_PATH=${inputs.self} infrastructure instance create "$@"
|
||||||
# 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
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
{ pkgs, ... }:
|
{ pkgs, inputs, ... }:
|
||||||
pkgs.writeShellApplication {
|
pkgs.writeShellApplication {
|
||||||
name = "destroy-instance";
|
name = "destroy-instance";
|
||||||
runtimeInputs = [
|
runtimeInputs = [
|
||||||
pkgs.khscodes.provision
|
pkgs.khscodes.infrastructure
|
||||||
];
|
];
|
||||||
text = ''
|
text = ''
|
||||||
instance="''${1:-}"
|
FLAKE_PATH=${inputs.self} infrastructure instance destroy "$@"
|
||||||
with_persistence="''${2:-none}"
|
|
||||||
provision "$instance" combinedPersistenceAttachAndCompute destroy
|
|
||||||
if [[ "$with_persistence" == "all" ]]; then
|
|
||||||
provision "$instance" persistence destroy
|
|
||||||
fi
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
16
nix/packages/infrastructure/default.nix
Normal file
16
nix/packages/infrastructure/default.nix
Normal 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
|
||||||
|
];
|
||||||
|
}
|
|
@ -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
|
|
||||||
'';
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
'';
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
];
|
|
||||||
}
|
|
|
@ -1,10 +1,8 @@
|
||||||
{ pkgs, ... }:
|
{ pkgs, inputs, ... }:
|
||||||
pkgs.writeShellApplication {
|
pkgs.writeShellApplication {
|
||||||
name = "provision-instance";
|
name = "provision-instance";
|
||||||
runtimeInputs = [ pkgs.khscodes.provision ];
|
runtimeInputs = [ pkgs.khscodes.infrastructure ];
|
||||||
text = ''
|
text = ''
|
||||||
instance="''${1:-}"
|
FLAKE_PATH=${inputs.self} infrastructure instance update "$@"
|
||||||
provision "$instance" persistence apply
|
|
||||||
provision "$instance" combinedPersistenceAttachAndCompute apply
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
|
||||||
'';
|
|
||||||
}
|
|
|
@ -45,10 +45,10 @@ in
|
||||||
pkgs.writeShellApplication {
|
pkgs.writeShellApplication {
|
||||||
name = "upload-openstack-base-debian-image";
|
name = "upload-openstack-base-debian-image";
|
||||||
runtimeInputs = [
|
runtimeInputs = [
|
||||||
pkgs.khscodes.openbao-helper
|
pkgs.khscodes.infrastructure
|
||||||
script
|
script
|
||||||
];
|
];
|
||||||
text = ''
|
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
27
rust/Cargo.lock
generated
|
@ -555,6 +555,20 @@ dependencies = [
|
||||||
"hashbrown 0.15.4",
|
"hashbrown 0.15.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "infrastructure"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"common",
|
||||||
|
"hakari",
|
||||||
|
"log",
|
||||||
|
"nix",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.1"
|
version = "1.70.1"
|
||||||
|
@ -795,19 +809,6 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "provision"
|
|
||||||
version = "1.0.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"clap",
|
|
||||||
"common",
|
|
||||||
"hakari",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.40"
|
version = "1.0.40"
|
||||||
|
|
|
@ -108,7 +108,7 @@ pub enum BitwardenEntryFieldType {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
pub struct BitwardenEntryField {
|
pub struct BitwardenEntryField {
|
||||||
pub name: String,
|
pub name: Option<String>,
|
||||||
pub value: Option<String>,
|
pub value: Option<String>,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub field_type: BitwardenEntryFieldType,
|
pub field_type: BitwardenEntryFieldType,
|
||||||
|
@ -154,6 +154,7 @@ pub enum BitwardenOrganizationUserStatus {
|
||||||
|
|
||||||
pub struct BitwardenSession {
|
pub struct BitwardenSession {
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
|
items: Option<Vec<BitwardenEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BitwardenSession {
|
impl BitwardenSession {
|
||||||
|
@ -171,11 +172,8 @@ impl BitwardenSession {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_or_authenticate(
|
pub fn unlock() -> anyhow::Result<Self> {
|
||||||
username: Option<&str>,
|
let bw = BitwardenSession::new_if_authenticated(true)?;
|
||||||
bw_unlock_purpose: &str,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
let bw = BitwardenSession::new_if_authenticated(username, bw_unlock_purpose, true)?;
|
|
||||||
if let Some(bw) = bw {
|
if let Some(bw) = bw {
|
||||||
bw.sync()?;
|
bw.sync()?;
|
||||||
Ok(bw)
|
Ok(bw)
|
||||||
|
@ -187,7 +185,7 @@ impl BitwardenSession {
|
||||||
.stderr_inherit()
|
.stderr_inherit()
|
||||||
.try_spawn_to_string()?;
|
.try_spawn_to_string()?;
|
||||||
// Just logged in, no point in syncing
|
// 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 {
|
if let Some(bw) = bw {
|
||||||
Ok(bw)
|
Ok(bw)
|
||||||
} else {
|
} else {
|
||||||
|
@ -198,67 +196,61 @@ impl BitwardenSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_if_authenticated(
|
fn new_if_authenticated(sync: bool) -> anyhow::Result<Option<Self>> {
|
||||||
username: Option<&str>,
|
|
||||||
bw_unlock_purpose: &str,
|
|
||||||
sync: bool,
|
|
||||||
) -> anyhow::Result<Option<Self>> {
|
|
||||||
let status: BitwardenAuthenticationStatus = proc::Command::new("bw")
|
let status: BitwardenAuthenticationStatus = proc::Command::new("bw")
|
||||||
.args(["--nointeraction", "status"])
|
.args(["--nointeraction", "status"])
|
||||||
.try_spawn_to_json()?;
|
.try_spawn_to_json()?;
|
||||||
|
|
||||||
let Some(user) = status.user() else {
|
let Some(_) = status.user() else {
|
||||||
return Ok(None);
|
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(_));
|
let is_unlocked: bool = matches!(status, BitwardenAuthenticationStatus::Unlocked(_));
|
||||||
if sync && !is_unlocked {
|
if sync && !is_unlocked {
|
||||||
log::info!("Syncing Bitwarden...");
|
log::info!("Syncing Bitwarden...");
|
||||||
let _ = proc::Command::new("bw").arg("sync").try_spawn_to_string()?;
|
let _ = proc::Command::new("bw").arg("sync").try_spawn_to_string()?;
|
||||||
}
|
}
|
||||||
log::info!("Unlocking bitwarden...");
|
|
||||||
let session_id = if is_unlocked {
|
let session_id = if is_unlocked {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
log::info!("Unlocking bitwarden...");
|
||||||
Some(
|
Some(
|
||||||
proc::Command::new("bitwarden-unlock")
|
proc::Command::new("bw")
|
||||||
.args(["--purpose", bw_unlock_purpose])
|
.args(["unlock", "--raw"])
|
||||||
|
.stderr_inherit()
|
||||||
|
.stdin_inherit()
|
||||||
.try_spawn_to_string()?,
|
.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]> {
|
||||||
log::info!("Listing bitwarden items...");
|
if self.items.is_none() {
|
||||||
self.bw_command()
|
log::info!("Listing bitwarden items...");
|
||||||
// Pretty format for better error messages during json decoding issues
|
let result = self
|
||||||
.args(["--pretty", "list", "items"])
|
.bw_command()
|
||||||
.try_spawn_to_json()
|
// 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<Option<BitwardenEntry>> {
|
pub fn get_item(&mut self, name: &str) -> anyhow::Result<Option<&BitwardenEntry>> {
|
||||||
let mut items = self.list_items()?;
|
let items = self.list_items()?;
|
||||||
let Some(idx) = items
|
Ok(items.iter().find(|e| e.name() == name))
|
||||||
.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_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()
|
self.bw_command()
|
||||||
.args(["get", "attachment", name, "--itemid"])
|
.args(["get", "attachment", name, "--itemid"])
|
||||||
.arg(entry.id.as_str())
|
.arg(entry.id.as_str())
|
||||||
|
@ -266,22 +258,19 @@ impl BitwardenSession {
|
||||||
.try_spawn_to_bytes()
|
.try_spawn_to_bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_own_items(&self) -> anyhow::Result<Vec<BitwardenEntry>> {
|
pub fn create_item(&mut self, item: &CommandBitwardenEntry) -> anyhow::Result<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> {
|
|
||||||
log::info!("Creating bitwarden entry {name}", name = item.name);
|
log::info!("Creating bitwarden entry {name}", name = item.name);
|
||||||
self.bw_command()
|
let res = self
|
||||||
|
.bw_command()
|
||||||
.args(["create", "item"])
|
.args(["create", "item"])
|
||||||
.stdin_json_base64(item)?
|
.stdin_json_base64(item)?
|
||||||
.try_spawn_to_json()
|
.try_spawn_to_json()?;
|
||||||
|
let _ = self.items.take();
|
||||||
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_item(
|
pub fn update_item(
|
||||||
&self,
|
&mut self,
|
||||||
to_update: &BitwardenEntry,
|
to_update: &BitwardenEntry,
|
||||||
update_with: &CommandBitwardenEntry,
|
update_with: &CommandBitwardenEntry,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
@ -295,10 +284,11 @@ impl BitwardenSession {
|
||||||
.args(["edit", "item", &to_update.id])
|
.args(["edit", "item", &to_update.id])
|
||||||
.stdin_json_base64(update_with)?
|
.stdin_json_base64(update_with)?
|
||||||
.try_spawn_to_string()?;
|
.try_spawn_to_string()?;
|
||||||
|
let _ = self.items.take();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_item(&self, to_delete: &BitwardenEntry) -> anyhow::Result<()> {
|
pub fn delete_item(&mut self, to_delete: &BitwardenEntry) -> anyhow::Result<()> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Deleting bitwarden entry {name}, with id: {id}",
|
"Deleting bitwarden entry {name}, with id: {id}",
|
||||||
id = to_delete.id,
|
id = to_delete.id,
|
||||||
|
@ -308,13 +298,13 @@ impl BitwardenSession {
|
||||||
.bw_command()
|
.bw_command()
|
||||||
.args(["delete", "item", &to_delete.id])
|
.args(["delete", "item", &to_delete.id])
|
||||||
.try_spawn_to_string()?;
|
.try_spawn_to_string()?;
|
||||||
|
let _ = self.items.take();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for BitwardenSession {
|
impl Drop for BitwardenSession {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
log::info!("Locking bitwarden session...");
|
|
||||||
if self.session_id.is_some() {
|
if self.session_id.is_some() {
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.bw_command()
|
.bw_command()
|
||||||
|
@ -349,9 +339,6 @@ impl BitwardenAuthenticationStatus {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct BitwardenAuthenticationUser {
|
struct BitwardenAuthenticationUser {
|
||||||
#[serde(rename = "userEmail")]
|
|
||||||
user_email: String,
|
|
||||||
|
|
||||||
#[serde(rename = "userId")]
|
#[serde(rename = "userId")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
[package]
|
[package]
|
||||||
name = "openbao-helper"
|
name = "infrastructure"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
metadata.crane.name = "openbao-helper"
|
metadata.crane.name = "infrastructure"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
@ -11,4 +11,5 @@ common = { path = "../../lib/common" }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
nix = { workspace = true, features = ["env", "process"] }
|
nix = { workspace = true, features = ["env", "process"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
hakari = { version = "0.1", path = "../../lib/hakari" }
|
hakari = { version = "0.1", path = "../../lib/hakari" }
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
110
rust/program/infrastructure/src/command/instance/create.rs
Normal file
110
rust/program/infrastructure/src/command/instance/create.rs
Normal 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(())
|
||||||
|
}
|
67
rust/program/infrastructure/src/command/instance/destroy.rs
Normal file
67
rust/program/infrastructure/src/command/instance/destroy.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
54
rust/program/infrastructure/src/command/instance/mod.rs
Normal file
54
rust/program/infrastructure/src/command/instance/mod.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
41
rust/program/infrastructure/src/command/instance/update.rs
Normal file
41
rust/program/infrastructure/src/command/instance/update.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
120
rust/program/infrastructure/src/command/mod.rs
Normal file
120
rust/program/infrastructure/src/command/mod.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
225
rust/program/infrastructure/src/main.rs
Normal file
225
rust/program/infrastructure/src/main.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
268
rust/program/infrastructure/src/secrets/endpoints.rs
Normal file
268
rust/program/infrastructure/src/secrets/endpoints.rs
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
use std::{borrow::Cow, collections::BTreeMap};
|
||||||
|
|
||||||
|
use common::bitwarden::BitwardenSession;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::secrets::{BitwardenKey, Endpoint, EndpointReader};
|
||||||
|
|
||||||
|
pub struct Openstack;
|
||||||
|
|
||||||
|
impl Endpoint for Openstack {
|
||||||
|
const NAME: &'static str = "openstack";
|
||||||
|
const BITWARDEN_KEY: &'static str = "KHS Openstack";
|
||||||
|
const ENV_KEYS: &'static [&'static str] = &[
|
||||||
|
"TF_VAR_openstack_username",
|
||||||
|
"TF_VAR_openstack_password",
|
||||||
|
"TF_VAR_openstack_tenant_name",
|
||||||
|
"TF_VAR_openstack_auth_url",
|
||||||
|
"TF_VAR_openstack_endpoint_type",
|
||||||
|
"TF_VAR_openstack_region",
|
||||||
|
];
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
|
||||||
|
BitwardenKey::Username,
|
||||||
|
BitwardenKey::Password,
|
||||||
|
BitwardenKey::Field("Project Name"),
|
||||||
|
BitwardenKey::Field("Auth URL"),
|
||||||
|
BitwardenKey::Field("Interface"),
|
||||||
|
BitwardenKey::Field("Region Name"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Aws;
|
||||||
|
|
||||||
|
impl Endpoint for Aws {
|
||||||
|
const NAME: &'static str = "aws";
|
||||||
|
|
||||||
|
const BITWARDEN_KEY: &'static str = "Cloudflare";
|
||||||
|
|
||||||
|
const ENV_KEYS: &'static [&'static str] = &["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
|
||||||
|
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
|
||||||
|
BitwardenKey::Field("BW Terraform access key id"),
|
||||||
|
BitwardenKey::Field("BW Terraform secret access key"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Cloudflare;
|
||||||
|
|
||||||
|
impl Endpoint for Cloudflare {
|
||||||
|
const NAME: &'static str = "cloudflare";
|
||||||
|
|
||||||
|
const BITWARDEN_KEY: &'static str = "Cloudflare";
|
||||||
|
|
||||||
|
const ENV_KEYS: &'static [&'static str] =
|
||||||
|
&["TF_VAR_cloudflare_token", "TF_VAR_cloudflare_email"];
|
||||||
|
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey] =
|
||||||
|
&[BitwardenKey::Field("DNS API Token"), BitwardenKey::Username];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Hcloud;
|
||||||
|
|
||||||
|
impl Endpoint for Hcloud {
|
||||||
|
const NAME: &'static str = "hcloud";
|
||||||
|
|
||||||
|
const BITWARDEN_KEY: &'static str = "Hetzner Cloud";
|
||||||
|
|
||||||
|
const ENV_KEYS: &'static [&'static str] = &["TF_VAR_hcloud_api_token"];
|
||||||
|
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("Terraform API Token")];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Unifi;
|
||||||
|
|
||||||
|
impl Endpoint for Unifi {
|
||||||
|
const NAME: &'static str = "unifi";
|
||||||
|
|
||||||
|
const BITWARDEN_KEY: &'static str = "Ubiquiti";
|
||||||
|
|
||||||
|
const ENV_KEYS: &'static [&'static str] = &["UNIFI_USERNAME", "UNIFI_PASSWORD", "UNIFI_API"];
|
||||||
|
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
|
||||||
|
BitwardenKey::Field("Terraform username"),
|
||||||
|
BitwardenKey::Field("Terraform password"),
|
||||||
|
BitwardenKey::Field("Terraform URL"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Vault;
|
||||||
|
|
||||||
|
impl Endpoint for Vault {
|
||||||
|
const NAME: &'static str = "vault";
|
||||||
|
|
||||||
|
const BITWARDEN_KEY: &'static str = "secrets.kaareskovgaard.net";
|
||||||
|
|
||||||
|
const ENV_KEYS: &'static [&'static str] = &["VAULT_TOKEN"];
|
||||||
|
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("Initial root token")];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MxKaareskovgaardNet;
|
||||||
|
|
||||||
|
impl Endpoint for MxKaareskovgaardNet {
|
||||||
|
const NAME: &'static str = "mx.kaareskovgaard.net";
|
||||||
|
|
||||||
|
const BITWARDEN_KEY: &'static str = "mx.kaareskovgaard.net";
|
||||||
|
|
||||||
|
const ENV_KEYS: &'static [&'static str] = &["MX_KAARESKOVGAARD_NET_ZROOT_ENCRYPTION_KEY"];
|
||||||
|
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("ZROOT_ENCRYPTION_KEY")];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
|
||||||
|
pub enum CliEndpoint {
|
||||||
|
#[serde(rename = "openstack")]
|
||||||
|
#[value(name = "openstack")]
|
||||||
|
Openstack,
|
||||||
|
#[serde(rename = "cloudflare")]
|
||||||
|
#[value(name = "cloudflare")]
|
||||||
|
Cloudflare,
|
||||||
|
#[serde(rename = "aws")]
|
||||||
|
#[value(name = "aws")]
|
||||||
|
Aws,
|
||||||
|
#[serde(rename = "hcloud")]
|
||||||
|
#[value(name = "hcloud")]
|
||||||
|
Hcloud,
|
||||||
|
#[serde(rename = "unifi")]
|
||||||
|
#[value(name = "unifi")]
|
||||||
|
Unifi,
|
||||||
|
#[serde(rename = "vault")]
|
||||||
|
#[value(name = "vault")]
|
||||||
|
Vault,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliEndpoint {
|
||||||
|
pub fn read_from_bitwarden(
|
||||||
|
&self,
|
||||||
|
session: &mut BitwardenSession,
|
||||||
|
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Aws => Aws.read_from_bitwarden(session, map),
|
||||||
|
Self::Cloudflare => Cloudflare.read_from_bitwarden(session, map),
|
||||||
|
Self::Hcloud => Hcloud.read_from_bitwarden(session, map),
|
||||||
|
Self::Openstack => Openstack.read_from_bitwarden(session, map),
|
||||||
|
Self::Unifi => Unifi.read_from_bitwarden(session, map),
|
||||||
|
Self::Vault => Vault.read_from_bitwarden(session, map),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn read_from_openbao(
|
||||||
|
&self,
|
||||||
|
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Aws => Aws.read_from_openbao(map),
|
||||||
|
Self::Cloudflare => Cloudflare.read_from_openbao(map),
|
||||||
|
Self::Hcloud => Hcloud.read_from_openbao(map),
|
||||||
|
Self::Openstack => Openstack.read_from_openbao(map),
|
||||||
|
Self::Unifi => Openstack.read_from_openbao(map),
|
||||||
|
// We don't transfer the root token to openbao itself, but relies on the user being authenticated
|
||||||
|
// through oauth.
|
||||||
|
Self::Vault => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transfer_from_bitwarden_to_vault(session: &mut BitwardenSession) -> anyhow::Result<()> {
|
||||||
|
let mut all_entries_proc = common::proc::Command::new("bao");
|
||||||
|
all_entries_proc.args(["kv", "list", "-format=json", "--mount=opentofu"]);
|
||||||
|
let mut all_entries: Vec<String> = all_entries_proc.try_spawn_to_json()?;
|
||||||
|
transfer_endpoint(Openstack, session, &mut all_entries)?;
|
||||||
|
transfer_endpoint(Hcloud, session, &mut all_entries)?;
|
||||||
|
transfer_endpoint(Unifi, session, &mut all_entries)?;
|
||||||
|
transfer_endpoint(Aws, session, &mut all_entries)?;
|
||||||
|
transfer_endpoint(Cloudflare, session, &mut all_entries)?;
|
||||||
|
transfer_endpoint(MxKaareskovgaardNet, session, &mut all_entries)?;
|
||||||
|
|
||||||
|
for entry in all_entries {
|
||||||
|
let mut delete_entry_proc = common::proc::Command::new("bao");
|
||||||
|
delete_entry_proc.args(["kv", "metadata", "delete", "-mount=opentofu", &entry]);
|
||||||
|
log::info!("Deleting entry {entry}...");
|
||||||
|
delete_entry_proc.try_spawn_to_bytes()?;
|
||||||
|
log::info!("Entry deleted {entry}.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transfer_endpoint<E: Endpoint>(
|
||||||
|
endpoint: E,
|
||||||
|
session: &mut BitwardenSession,
|
||||||
|
all_entries: &mut Vec<String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
endpoint.read_from_bitwarden(session, &mut map)?;
|
||||||
|
|
||||||
|
log::info!("Transferring {}...", E::NAME);
|
||||||
|
let mut write_proc = common::proc::Command::new("bao");
|
||||||
|
write_proc.args(["kv", "put", "-mount=opentofu"]);
|
||||||
|
write_proc.arg(E::NAME);
|
||||||
|
for (key, value) in map {
|
||||||
|
// TODO: This should use some sort of sensitive wrapper to avoid ever logging the value to the console
|
||||||
|
write_proc.arg(format!("{key}={value}"));
|
||||||
|
}
|
||||||
|
write_proc.try_spawn_to_string()?;
|
||||||
|
destroy_openbao_old_versions(E::NAME)?;
|
||||||
|
log::info!("Transferred {}.", E::NAME);
|
||||||
|
if let Some(idx) = all_entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find_map(|(idx, e)| if e == E::NAME { Some(idx) } else { None })
|
||||||
|
{
|
||||||
|
let _ = all_entries.swap_remove(idx);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destroy_openbao_old_versions(name: &str) -> anyhow::Result<()> {
|
||||||
|
let mut proc = common::proc::Command::new("bao");
|
||||||
|
proc.args([
|
||||||
|
"kv",
|
||||||
|
"metadata",
|
||||||
|
"get",
|
||||||
|
"-mount=opentofu",
|
||||||
|
"-format=json",
|
||||||
|
name,
|
||||||
|
]);
|
||||||
|
let metadata: KvItemMetadata = proc.try_spawn_to_json()?;
|
||||||
|
let mut versions: Vec<_> = metadata.data.versions.into_iter().collect();
|
||||||
|
versions.sort_by(|a, b| {
|
||||||
|
let dest = a.1.destroyed.cmp(&b.1.destroyed).reverse();
|
||||||
|
if !dest.is_eq() {
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
a.1.created_time.cmp(&b.1.created_time).reverse()
|
||||||
|
});
|
||||||
|
let versions_to_destroy = versions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, e)| !e.destroyed)
|
||||||
|
.map(|(version, _)| version.as_str())
|
||||||
|
.skip(1)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
if !versions_to_destroy.is_empty() {
|
||||||
|
let mut delete_proc = common::proc::Command::new("bao");
|
||||||
|
delete_proc.args(["kv", "destroy", "-mount=opentofu"]);
|
||||||
|
delete_proc.arg(format!("-versions={versions_to_destroy}"));
|
||||||
|
delete_proc.arg(name);
|
||||||
|
delete_proc.try_spawn_to_bytes()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvItemMetadata {
|
||||||
|
data: KvItemMetadataData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvItemMetadataData {
|
||||||
|
versions: KvItemMetadataVersions,
|
||||||
|
}
|
||||||
|
|
||||||
|
type KvItemMetadataVersions = BTreeMap<String, KvItemMetadataVersion>;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvItemMetadataVersion {
|
||||||
|
created_time: String,
|
||||||
|
destroyed: bool,
|
||||||
|
}
|
128
rust/program/infrastructure/src/secrets/mod.rs
Normal file
128
rust/program/infrastructure/src/secrets/mod.rs
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
use std::{borrow::Cow, collections::BTreeMap};
|
||||||
|
|
||||||
|
use common::bitwarden::{BitwardenEntry, BitwardenEntryTypeData, BitwardenSession};
|
||||||
|
|
||||||
|
use crate::secrets::openbao::read_bao_data;
|
||||||
|
|
||||||
|
mod endpoints;
|
||||||
|
mod openbao;
|
||||||
|
|
||||||
|
pub use endpoints::{CliEndpoint, transfer_from_bitwarden_to_vault};
|
||||||
|
|
||||||
|
pub trait Endpoint {
|
||||||
|
const NAME: &'static str;
|
||||||
|
const BITWARDEN_KEY: &'static str;
|
||||||
|
const ENV_KEYS: &'static [&'static str];
|
||||||
|
const BITWARDEN_KEYS: &'static [BitwardenKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub enum BitwardenKey {
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
Field(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitwardenKey {
|
||||||
|
pub fn read_from_entry(self, entry: &BitwardenEntry) -> anyhow::Result<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Username => match &entry.data {
|
||||||
|
BitwardenEntryTypeData::Login(bitwarden_entry_type_login) => {
|
||||||
|
let Some(username) = bitwarden_entry_type_login.username.as_deref() else {
|
||||||
|
return Err(anyhow::format_err!(
|
||||||
|
"Login entry {} has no username set",
|
||||||
|
entry.name()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(username)
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::format_err!(
|
||||||
|
"Could not read username from entry {}. Entry is not a login entry",
|
||||||
|
entry.name()
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Self::Password => match &entry.data {
|
||||||
|
BitwardenEntryTypeData::Login(bitwarden_entry_type_login) => {
|
||||||
|
let Some(password) = bitwarden_entry_type_login.password.as_deref() else {
|
||||||
|
return Err(anyhow::format_err!(
|
||||||
|
"Login entry {} has no password set",
|
||||||
|
entry.name()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(password)
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::format_err!(
|
||||||
|
"Could not read password from entry {}. Entry is not a login entry",
|
||||||
|
entry.name()
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Self::Field(field_name) => {
|
||||||
|
let Some(field) = entry
|
||||||
|
.fields
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.name.as_deref() == Some(field_name))
|
||||||
|
else {
|
||||||
|
return Err(anyhow::format_err!(
|
||||||
|
"Entry {} has no field named {field_name}",
|
||||||
|
entry.name()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let Some(value) = field.value.as_deref() else {
|
||||||
|
return Err(anyhow::format_err!(
|
||||||
|
"Entry {} has no value set for field {}",
|
||||||
|
entry.name(),
|
||||||
|
field_name
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EndpointReader {
|
||||||
|
fn read_from_bitwarden(
|
||||||
|
&self,
|
||||||
|
session: &mut BitwardenSession,
|
||||||
|
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
fn read_from_openbao(
|
||||||
|
&self,
|
||||||
|
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Endpoint> EndpointReader for E {
|
||||||
|
fn read_from_bitwarden(
|
||||||
|
&self,
|
||||||
|
session: &mut BitwardenSession,
|
||||||
|
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let Some(item) = session.get_item(Self::BITWARDEN_KEY)? else {
|
||||||
|
return Err(anyhow::format_err!(
|
||||||
|
"Bitwarden key {} does not exist",
|
||||||
|
Self::BITWARDEN_KEY
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (key, value) in Self::BITWARDEN_KEYS.iter().zip(Self::ENV_KEYS.iter()) {
|
||||||
|
let field_value = key.read_from_entry(item)?;
|
||||||
|
map.insert((*value).into(), field_value.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_from_openbao(
|
||||||
|
&self,
|
||||||
|
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let result = read_bao_data::<Self>()?;
|
||||||
|
for (key, value) in result {
|
||||||
|
map.insert(key.into(), value);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,17 +2,14 @@ use std::{collections::BTreeMap, marker::PhantomData, vec::IntoIter};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
pub trait EnvEntryConfig {
|
use crate::secrets::Endpoint;
|
||||||
const SECRETS: &'static [&'static str];
|
|
||||||
const BAO_KEY: &'static str;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct EnvEntry<T>(Vec<(&'static str, String)>, PhantomData<T>);
|
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> {
|
pub fn try_new_from_env() -> anyhow::Result<Self> {
|
||||||
let mut result = Vec::with_capacity(T::SECRETS.len());
|
let mut result = Vec::with_capacity(T::ENV_KEYS.len());
|
||||||
for key in T::SECRETS {
|
for key in T::ENV_KEYS {
|
||||||
let value = common::env::read_env(key)?;
|
let value = common::env::read_env(key)?;
|
||||||
result.push((*key, value));
|
result.push((*key, value));
|
||||||
}
|
}
|
||||||
|
@ -24,7 +21,7 @@ impl<T: EnvEntryConfig> EnvEntry<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_from_bao() -> anyhow::Result<Self> {
|
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>
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
|
@ -54,13 +51,13 @@ impl<'de, T: EnvEntryConfig> serde::Deserialize<'de> for EnvEntry<T> {
|
||||||
}
|
}
|
||||||
struct EnvEntryVisitor<T>(PhantomData<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>;
|
type Value = EnvEntry<T>;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
formatter.write_fmt(format_args!(
|
formatter.write_fmt(format_args!(
|
||||||
"a map with unique keys {} with string values",
|
"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();
|
let mut values = BTreeMap::<&'static str, String>::new();
|
||||||
while let Some((key, value)) = map.next_entry::<&'de str, String>()? {
|
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 {
|
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) {
|
if values.contains_key(key) {
|
||||||
return Err(serde::de::Error::duplicate_field(key));
|
return Err(serde::de::Error::duplicate_field(key));
|
||||||
}
|
}
|
||||||
values.insert(key, value);
|
values.insert(key, value);
|
||||||
}
|
}
|
||||||
for key in T::SECRETS {
|
for key in T::ENV_KEYS {
|
||||||
if !values.contains_key(key) {
|
if !values.contains_key(key) {
|
||||||
return Err(serde::de::Error::missing_field(key));
|
return Err(serde::de::Error::missing_field(key));
|
||||||
}
|
}
|
||||||
|
@ -100,9 +97,9 @@ struct OpenBaoKvEntryData<T> {
|
||||||
data: 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");
|
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()?;
|
let result: OpenBaoKvEntry<EnvEntry<T>> = cmd.try_spawn_to_json()?;
|
||||||
Ok(result.data.data)
|
Ok(result.data.data)
|
||||||
}
|
}
|
|
@ -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"),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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" }
|
|
|
@ -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(())
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue