From fd3705e382cd251335a38ac5ed8070aaafd961ef Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 2 Mar 2026 15:50:11 -0500 Subject: [PATCH 1/3] wip(secret store): openbao/vault store implementation --- harmony_secret/src/config.rs | 16 ++ harmony_secret/src/lib.rs | 24 +- harmony_secret/src/store/mod.rs | 5 + harmony_secret/src/store/openbao.rs | 378 ++++++++++++++++++++++++++++ 4 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 harmony_secret/src/store/openbao.rs diff --git a/harmony_secret/src/config.rs b/harmony_secret/src/config.rs index 54494bf8..c22c40a5 100644 --- a/harmony_secret/src/config.rs +++ b/harmony_secret/src/config.rs @@ -1,3 +1,6 @@ +use lazy_static::lazy_static; +use std::env; + use lazy_static::lazy_static; lazy_static! { @@ -16,3 +19,16 @@ lazy_static! { pub static ref INFISICAL_CLIENT_SECRET: Option = std::env::var("HARMONY_SECRET_INFISICAL_CLIENT_SECRET").ok(); } + +lazy_static! { + // Openbao/Vault configuration + pub static ref OPENBAO_URL: Option = + env::var("OPENBAO_URL").or(env::var("VAULT_ADDR")).ok(); + pub static ref OPENBAO_TOKEN: Option = env::var("OPENBAO_TOKEN").ok(); + pub static ref OPENBAO_USERNAME: Option = env::var("OPENBAO_USERNAME").ok(); + pub static ref OPENBAO_PASSWORD: Option = env::var("OPENBAO_PASSWORD").ok(); + pub static ref OPENBAO_SKIP_TLS: bool = + env::var("OPENBAO_SKIP_TLS").map(|v| v == "true").unwrap_or(false); + pub static ref OPENBAO_KV_MOUNT: String = + env::var("OPENBAO_KV_MOUNT").unwrap_or_else(|_| "secret".to_string()); +} diff --git a/harmony_secret/src/lib.rs b/harmony_secret/src/lib.rs index 2ff566ca..a6a141eb 100644 --- a/harmony_secret/src/lib.rs +++ b/harmony_secret/src/lib.rs @@ -8,6 +8,12 @@ use config::INFISICAL_CLIENT_SECRET; use config::INFISICAL_ENVIRONMENT; use config::INFISICAL_PROJECT_ID; use config::INFISICAL_URL; +use config::OPENBAO_KV_MOUNT; +use config::OPENBAO_PASSWORD; +use config::OPENBAO_SKIP_TLS; +use config::OPENBAO_TOKEN; +use config::OPENBAO_URL; +use config::OPENBAO_USERNAME; use config::SECRET_STORE; use interactive_parse::InteractiveParseObj; use log::debug; @@ -17,6 +23,7 @@ use serde::{Serialize, de::DeserializeOwned}; use std::fmt; use store::InfisicalSecretStore; use store::LocalFileSecretStore; +use store::OpenbaoSecretStore; use thiserror::Error; use tokio::sync::OnceCell; @@ -69,11 +76,24 @@ async fn get_secret_manager() -> &'static SecretManager { /// The async initialization function for the SecretManager. async fn init_secret_manager() -> SecretManager { - let default_secret_score = "infisical".to_string(); - let store_type = SECRET_STORE.as_ref().unwrap_or(&default_secret_score); + let default_secret_store = "infisical".to_string(); + let store_type = SECRET_STORE.as_ref().unwrap_or(&default_secret_store); let store: Box = match store_type.as_str() { "file" => Box::new(LocalFileSecretStore::default()), + "openbao" | "vault" => { + let store = OpenbaoSecretStore::new( + OPENBAO_URL.clone().expect("Openbao/Vault URL must be set, see harmony_secret config for ways to provide it. You can try with OPENBAO_URL or VAULT_ADDR"), + OPENBAO_KV_MOUNT.clone(), + *OPENBAO_SKIP_TLS, + OPENBAO_TOKEN.clone(), + OPENBAO_USERNAME.clone(), + OPENBAO_PASSWORD.clone(), + ) + .await + .expect("Failed to initialize Openbao/Vault secret store"); + Box::new(store) + } "infisical" | _ => { let store = InfisicalSecretStore::new( INFISICAL_URL.clone().expect("Infisical url must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_URL"), diff --git a/harmony_secret/src/store/mod.rs b/harmony_secret/src/store/mod.rs index 9610e554..8634497b 100644 --- a/harmony_secret/src/store/mod.rs +++ b/harmony_secret/src/store/mod.rs @@ -1,4 +1,9 @@ mod infisical; mod local_file; +mod openbao; + +pub use infisical::InfisicalSecretStore; pub use infisical::*; +pub use local_file::LocalFileSecretStore; pub use local_file::*; +pub use openbao::OpenbaoSecretStore; diff --git a/harmony_secret/src/store/openbao.rs b/harmony_secret/src/store/openbao.rs new file mode 100644 index 00000000..58efc3e1 --- /dev/null +++ b/harmony_secret/src/store/openbao.rs @@ -0,0 +1,378 @@ +use crate::{SecretStore, SecretStoreError}; +use async_trait::async_trait; +use log::{debug, info, warn}; +use reqwest::{Certificate, Client, header}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Token response from Vault/Openbao auth endpoints +#[derive(Debug, Deserialize)] +struct TokenResponse { + auth: AuthInfo, +} + +#[derive(Debug, Deserialize)] +struct AuthInfo { + client_token: String, + #[serde(default)] + lease_duration: Option, +} + +/// Response for KV v2 secret read +#[derive(Debug, Deserialize)] +struct KvV2ReadResponse { + data: KvV2Data, +} + +#[derive(Debug, Deserialize)] +struct KvV2Data { + data: serde_json::Value, +} + +/// Response for KV v2 secret write +#[derive(Debug, Deserialize)] +struct KvV2WriteResponse { + data: KvV2WriteData, +} + +#[derive(Debug, Deserialize)] +struct KvV2WriteData { + version: u32, +} + +#[derive(Debug)] +pub struct OpenbaoSecretStore { + client: Client, + base_url: String, + kv_mount: String, + token: String, +} + +impl OpenbaoSecretStore { + /// Creates a new Openbao/Vault secret store with authentication + pub async fn new( + base_url: String, + kv_mount: String, + skip_tls: bool, + token: Option, + username: Option, + password: Option, + ) -> Result { + info!("OPENBAO_STORE: Initializing client for URL: {base_url}"); + + // Build HTTP client with TLS configuration + let mut client_builder = Client::builder(); + + if skip_tls { + warn!("OPENBAO_STORE: Skipping TLS verification - not recommended for production!"); + client_builder = client_builder.danger_accept_invalid_certs(true); + } + + let client = client_builder + .build() + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + // Get or authenticate token + let token = + Self::get_or_authenticate_token(&client, &base_url, token, username, password).await?; + + info!("OPENBAO_STORE: Client authenticated successfully."); + Ok(Self { + client, + base_url, + kv_mount, + token, + }) + } + + /// Get token from cache, env var, or authenticate + async fn get_or_authenticate_token( + client: &Client, + base_url: &str, + token: Option, + username: Option, + password: Option, + ) -> Result { + // 1. If token is provided via env var, use it directly + if let Some(t) = token { + debug!("OPENBAO_STORE: Using token from environment variable"); + return Ok(t); + } + + // 2. Try to load cached token + let cache_path = Self::get_token_cache_path(base_url); + if let Ok(cached_token) = Self::load_cached_token(&cache_path) { + debug!("OPENBAO_STORE: Found cached token, validating..."); + if Self::validate_token(client, base_url, &cached_token).await { + info!("OPENBAO_STORE: Cached token is valid"); + return Ok(cached_token); + } + warn!("OPENBAO_STORE: Cached token is invalid or expired"); + } + + // 3. Authenticate with username/password + let (user, pass) = match (username, password) { + (Some(u), Some(p)) => (u, p), + _ => { + return Err(SecretStoreError::Store( + "No valid token found and username/password not provided. \ + Set OPENBAO_TOKEN or OPENBAO_USERNAME/OPENBAO_PASSWORD environment variables." + .into(), + )); + } + }; + + let token = Self::authenticate_userpass(client, base_url, &user, &pass).await?; + + // Cache the token + if let Err(e) = Self::cache_token(&cache_path, &token) { + warn!("OPENBAO_STORE: Failed to cache token: {e}"); + } + + Ok(token) + } + + /// Get the cache file path for a given base URL + fn get_token_cache_path(base_url: &str) -> PathBuf { + let hash = Self::hash_url(base_url); + directories::BaseDirs::new() + .map(|dirs| { + dirs.data_dir() + .join("harmony") + .join("secrets") + .join(format!("openbao_token_{hash}")) + }) + .unwrap_or_else(|| PathBuf::from(format!("/tmp/openbao_token_{hash}"))) + } + + /// Create a simple hash of the URL for unique cache files + fn hash_url(url: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + } + + /// Load cached token from file + fn load_cached_token(path: &PathBuf) -> Result { + fs::read_to_string(path) + } + + /// Cache token to file + fn cache_token(path: &PathBuf, token: &str) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + // Set file permissions to 0600 (owner read/write only) + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + use std::io::Write; + file.write_all(token.as_bytes())?; + } + #[cfg(not(unix))] + { + fs::write(path, token)?; + } + Ok(()) + } + + /// Validate if a token is still valid + async fn validate_token(client: &Client, base_url: &str, token: &str) -> bool { + let url = format!("{}/v1/auth/token/lookup-self", base_url); + match client.get(&url).header("X-Vault-Token", token).send().await { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } + } + + /// Authenticate using username/password (userpass auth method) + async fn authenticate_userpass( + client: &Client, + base_url: &str, + username: &str, + password: &str, + ) -> Result { + let url = format!("{}/v1/auth/userpass/login/{username}", base_url); + info!("OPENBAO_STORE: Authenticating with username/password"); + + let response = client + .post(&url) + .json(&serde_json::json!({ "password": password })) + .send() + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SecretStoreError::Store( + format!("Authentication failed ({}): {}", status, body).into(), + )); + } + + let token_response: TokenResponse = response + .json() + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + Ok(token_response.auth.client_token) + } + + /// Build the KV v2 path for reading/writing secrets + fn build_kv_path(&self, namespace: &str, key: &str) -> String { + format!( + "{}/v1/{}/data/{}/{}", + self.base_url, self.kv_mount, namespace, key + ) + } + + /// Build the KV v2 path for listing secrets (optional, for future use) + #[allow(dead_code)] + fn build_kv_list_path(&self, namespace: &str) -> String { + format!( + "{}/v1/{}/metadata/{}", + self.base_url, self.kv_mount, namespace + ) + } +} + +#[async_trait] +impl SecretStore for OpenbaoSecretStore { + async fn get_raw(&self, namespace: &str, key: &str) -> Result, SecretStoreError> { + let url = self.build_kv_path(namespace, key); + info!("OPENBAO_STORE: Getting key '{key}' from namespace '{namespace}'"); + debug!("OPENBAO_STORE: Request URL: {url}"); + + let response = self + .client + .get(&url) + .header("X-Vault-Token", &self.token) + .header("X-Vault-Request", "true") + .send() + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + let status = response.status(); + + if status == http::StatusCode::NOT_FOUND { + return Err(SecretStoreError::NotFound { + namespace: namespace.to_string(), + key: key.to_string(), + }); + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(SecretStoreError::Store( + format!("Failed to get secret ({}): {}", status, body).into(), + )); + } + + let kv_response: KvV2ReadResponse = response + .json() + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + // Extract the actual secret value stored under the "value" key + let value = kv_response + .data + .data + .get("value") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + SecretStoreError::Store("Secret does not contain expected 'value' field".into()) + })?; + + Ok(value.as_bytes().to_vec()) + } + + async fn set_raw( + &self, + namespace: &str, + key: &str, + val: &[u8], + ) -> Result<(), SecretStoreError> { + let url = self.build_kv_path(namespace, key); + info!("OPENBAO_STORE: Setting key '{key}' in namespace '{namespace}'"); + debug!("OPENBAO_STORE: Request URL: {url}"); + + let value_str = + String::from_utf8(val.to_vec()).map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + // KV v2 requires wrapping data in a "data" object + let body = serde_json::json!({ + "data": { + "value": value_str + } + }); + + let response = self + .client + .post(&url) + .header("X-Vault-Token", &self.token) + .header("X-Vault-Request", "true") + .header(header::CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + let status = response.status(); + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(SecretStoreError::Store( + format!("Failed to set secret ({}): {}", status, body).into(), + )); + } + + info!("OPENBAO_STORE: Successfully stored secret '{key}'"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_url_consistency() { + let url = "https://vault.example.com:8200"; + let hash1 = OpenbaoSecretStore::hash_url(url); + let hash2 = OpenbaoSecretStore::hash_url(url); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 16); + } + + #[test] + fn test_hash_url_uniqueness() { + let hash1 = OpenbaoSecretStore::hash_url("https://vault1.example.com"); + let hash2 = OpenbaoSecretStore::hash_url("https://vault2.example.com"); + assert_ne!(hash1, hash2); + } + + #[test] + fn test_build_kv_path() { + let store = OpenbaoSecretStore { + client: Client::new(), + base_url: "https://vault.example.com:8200".to_string(), + kv_mount: "secret".to_string(), + token: "test-token".to_string(), + }; + + let path = store.build_kv_path("myapp-prod", "db-credentials"); + assert_eq!( + path, + "https://vault.example.com:8200/v1/secret/data/myapp-prod/db-credentials" + ); + } +} -- 2.39.5 From ac9fedf853faec483151b152bb1a72648aca5307 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 2 Mar 2026 16:50:29 -0500 Subject: [PATCH 2/3] wip(secret store): Fix openbao, refactor with rust client --- Cargo.lock | 143 ++++++++++++- harmony_secret/Cargo.toml | 1 + harmony_secret/src/config.rs | 2 - harmony_secret/src/store/openbao.rs | 317 +++++++++++----------------- 4 files changed, 264 insertions(+), 199 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 551c619f..91e293f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1008,7 +1008,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -1375,14 +1375,38 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -1395,17 +1419,28 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.106", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", "syn 2.0.106", ] @@ -1448,6 +1483,37 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -2875,6 +2941,7 @@ dependencies = [ "tempfile", "thiserror 2.0.16", "tokio", + "vaultrs", ] [[package]] @@ -3573,7 +3640,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" dependencies = [ - "darling", + "darling 0.20.11", "indoc", "proc-macro2", "quote", @@ -3865,7 +3932,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "079fc8c1c397538628309cfdee20696ebdcc26745f9fb17f89b78782205bd995" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "serde", @@ -5380,6 +5447,40 @@ dependencies = [ "semver", ] +[[package]] +name = "rustify" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759a090a17ce545d1adcffcc48207d5136c8984d8153bd8247b1ad4a71e49f5f" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http 1.3.1", + "reqwest 0.12.23", + "rustify_derive", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 1.0.69", + "tracing", + "url", +] + +[[package]] +name = "rustify_derive" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f07d43b2dbdbd99aaed648192098f0f413b762f0f352667153934ef3955f1793" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "serde_urlencoded", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "rustix" version = "0.38.44" @@ -5858,7 +5959,7 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.106", @@ -6333,6 +6434,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -7146,6 +7253,26 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vaultrs" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81eb4d9221ca29bad43d4b6871b6d2e7656e1af2cfca624a87e5d17880d831d" +dependencies = [ + "async-trait", + "bytes", + "derive_builder", + "http 1.3.1", + "reqwest 0.12.23", + "rustify", + "rustify_derive", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/harmony_secret/Cargo.toml b/harmony_secret/Cargo.toml index 0c76cfa8..a7ff7885 100644 --- a/harmony_secret/Cargo.toml +++ b/harmony_secret/Cargo.toml @@ -21,6 +21,7 @@ http.workspace = true inquire.workspace = true interactive-parse = "0.1.5" schemars = "0.8" +vaultrs = "0.7.4" [dev-dependencies] pretty_assertions.workspace = true diff --git a/harmony_secret/src/config.rs b/harmony_secret/src/config.rs index c22c40a5..1094b2e7 100644 --- a/harmony_secret/src/config.rs +++ b/harmony_secret/src/config.rs @@ -1,8 +1,6 @@ use lazy_static::lazy_static; use std::env; -use lazy_static::lazy_static; - lazy_static! { pub static ref SECRET_NAMESPACE: String = std::env::var("HARMONY_SECRET_NAMESPACE").expect("HARMONY_SECRET_NAMESPACE environment variable is required, it should contain the name of the project you are working on to access its secrets"); diff --git a/harmony_secret/src/store/openbao.rs b/harmony_secret/src/store/openbao.rs index 58efc3e1..0cbc7bf3 100644 --- a/harmony_secret/src/store/openbao.rs +++ b/harmony_secret/src/store/openbao.rs @@ -1,10 +1,13 @@ use crate::{SecretStore, SecretStoreError}; use async_trait::async_trait; use log::{debug, info, warn}; -use reqwest::{Certificate, Client, header}; use serde::{Deserialize, Serialize}; +use std::fmt::Debug; use std::fs; use std::path::PathBuf; +use vaultrs::auth; +use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder}; +use vaultrs::kv2; /// Token response from Vault/Openbao auth endpoints #[derive(Debug, Deserialize)] @@ -12,41 +15,36 @@ struct TokenResponse { auth: AuthInfo, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] struct AuthInfo { client_token: String, #[serde(default)] lease_duration: Option, + token_type: String, } -/// Response for KV v2 secret read -#[derive(Debug, Deserialize)] -struct KvV2ReadResponse { - data: KvV2Data, +impl From for AuthInfo { + fn from(value: vaultrs::api::AuthInfo) -> Self { + AuthInfo { + client_token: value.client_token, + token_type: value.token_type, + lease_duration: Some(value.lease_duration), + } + } } -#[derive(Debug, Deserialize)] -struct KvV2Data { - data: serde_json::Value, -} - -/// Response for KV v2 secret write -#[derive(Debug, Deserialize)] -struct KvV2WriteResponse { - data: KvV2WriteData, -} - -#[derive(Debug, Deserialize)] -struct KvV2WriteData { - version: u32, -} - -#[derive(Debug)] pub struct OpenbaoSecretStore { - client: Client, - base_url: String, + client: VaultClient, kv_mount: String, - token: String, +} + +impl Debug for OpenbaoSecretStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenbaoSecretStore") + .field("client", &self.client.settings) + .field("kv_mount", &self.kv_mount) + .finish() + } } impl OpenbaoSecretStore { @@ -61,52 +59,24 @@ impl OpenbaoSecretStore { ) -> Result { info!("OPENBAO_STORE: Initializing client for URL: {base_url}"); - // Build HTTP client with TLS configuration - let mut client_builder = Client::builder(); - - if skip_tls { - warn!("OPENBAO_STORE: Skipping TLS verification - not recommended for production!"); - client_builder = client_builder.danger_accept_invalid_certs(true); - } - - let client = client_builder - .build() - .map_err(|e| SecretStoreError::Store(Box::new(e)))?; - - // Get or authenticate token - let token = - Self::get_or_authenticate_token(&client, &base_url, token, username, password).await?; - - info!("OPENBAO_STORE: Client authenticated successfully."); - Ok(Self { - client, - base_url, - kv_mount, - token, - }) - } - - /// Get token from cache, env var, or authenticate - async fn get_or_authenticate_token( - client: &Client, - base_url: &str, - token: Option, - username: Option, - password: Option, - ) -> Result { // 1. If token is provided via env var, use it directly if let Some(t) = token { debug!("OPENBAO_STORE: Using token from environment variable"); - return Ok(t); + return Self::with_token(&base_url, skip_tls, &t, &kv_mount); } // 2. Try to load cached token - let cache_path = Self::get_token_cache_path(base_url); + let cache_path = Self::get_token_cache_path(&base_url); if let Ok(cached_token) = Self::load_cached_token(&cache_path) { debug!("OPENBAO_STORE: Found cached token, validating..."); - if Self::validate_token(client, base_url, &cached_token).await { + if Self::validate_token(&base_url, skip_tls, &cached_token.client_token).await { info!("OPENBAO_STORE: Cached token is valid"); - return Ok(cached_token); + return Self::with_token( + &base_url, + skip_tls, + &cached_token.client_token, + &kv_mount, + ); } warn!("OPENBAO_STORE: Cached token is invalid or expired"); } @@ -123,14 +93,43 @@ impl OpenbaoSecretStore { } }; - let token = Self::authenticate_userpass(client, base_url, &user, &pass).await?; + let token = + Self::authenticate_userpass(&base_url, &kv_mount, skip_tls, &user, &pass).await?; // Cache the token if let Err(e) = Self::cache_token(&cache_path, &token) { warn!("OPENBAO_STORE: Failed to cache token: {e}"); } - Ok(token) + Self::with_token(&base_url, skip_tls, &token.client_token, &kv_mount) + } + + /// Create a client with an existing token + fn with_token( + base_url: &str, + skip_tls: bool, + token: &str, + kv_mount: &str, + ) -> Result { + let mut settings = VaultClientSettingsBuilder::default(); + settings.address(base_url).token(token); + + if skip_tls { + warn!("OPENBAO_STORE: Skipping TLS verification - not recommended for production!"); + settings.verify(false); + } + + let client = VaultClient::new( + settings + .build() + .map_err(|e| SecretStoreError::Store(Box::new(e)))?, + ) + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + Ok(Self { + client, + kv_mount: kv_mount.to_string(), + }) } /// Get the cache file path for a given base URL @@ -156,12 +155,16 @@ impl OpenbaoSecretStore { } /// Load cached token from file - fn load_cached_token(path: &PathBuf) -> Result { - fs::read_to_string(path) + fn load_cached_token(path: &PathBuf) -> Result { + serde_json::from_str( + &fs::read_to_string(path) + .map_err(|e| format!("Could not load token from file {path:?} : {e}"))?, + ) + .map_err(|e| format!("Could not deserialize token from file {path:?} : {e}")) } /// Cache token to file - fn cache_token(path: &PathBuf, token: &str) -> Result<(), std::io::Error> { + fn cache_token(path: &PathBuf, token: &AuthInfo) -> Result<(), std::io::Error> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -176,7 +179,7 @@ impl OpenbaoSecretStore { .mode(0o600) .open(path)?; use std::io::Write; - file.write_all(token.as_bytes())?; + file.write_all(serde_json::to_string(token)?.as_bytes())?; } #[cfg(not(unix))] { @@ -185,112 +188,82 @@ impl OpenbaoSecretStore { Ok(()) } - /// Validate if a token is still valid - async fn validate_token(client: &Client, base_url: &str, token: &str) -> bool { - let url = format!("{}/v1/auth/token/lookup-self", base_url); - match client.get(&url).header("X-Vault-Token", token).send().await { - Ok(resp) => resp.status().is_success(), - Err(_) => false, + /// Validate if a token is still valid using vaultrs + async fn validate_token(base_url: &str, skip_tls: bool, token: &str) -> bool { + let mut settings = VaultClientSettingsBuilder::default(); + settings.address(base_url).token(token); + if skip_tls { + settings.verify(false); } + + if let Some(settings) = settings.build().ok() { + let client = match VaultClient::new(settings) { + Ok(s) => s, + Err(_) => return false, + }; + return vaultrs::token::lookup(&client, token).await.is_ok(); + } + false } /// Authenticate using username/password (userpass auth method) async fn authenticate_userpass( - client: &Client, base_url: &str, + kv_mount: &str, + skip_tls: bool, username: &str, password: &str, - ) -> Result { - let url = format!("{}/v1/auth/userpass/login/{username}", base_url); + ) -> Result { info!("OPENBAO_STORE: Authenticating with username/password"); - let response = client - .post(&url) - .json(&serde_json::json!({ "password": password })) - .send() - .await - .map_err(|e| SecretStoreError::Store(Box::new(e)))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(SecretStoreError::Store( - format!("Authentication failed ({}): {}", status, body).into(), - )); + // Create a client without a token for authentication + let mut settings = VaultClientSettingsBuilder::default(); + settings.address(base_url); + if skip_tls { + settings.verify(false); } - let token_response: TokenResponse = response - .json() + let client = VaultClient::new( + settings + .build() + .map_err(|e| SecretStoreError::Store(Box::new(e)))?, + ) + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + // Authenticate using userpass method + let token = auth::userpass::login(&client, kv_mount, username, password) .await .map_err(|e| SecretStoreError::Store(Box::new(e)))?; - Ok(token_response.auth.client_token) - } - - /// Build the KV v2 path for reading/writing secrets - fn build_kv_path(&self, namespace: &str, key: &str) -> String { - format!( - "{}/v1/{}/data/{}/{}", - self.base_url, self.kv_mount, namespace, key - ) - } - - /// Build the KV v2 path for listing secrets (optional, for future use) - #[allow(dead_code)] - fn build_kv_list_path(&self, namespace: &str) -> String { - format!( - "{}/v1/{}/metadata/{}", - self.base_url, self.kv_mount, namespace - ) + Ok(token.into()) } } #[async_trait] impl SecretStore for OpenbaoSecretStore { async fn get_raw(&self, namespace: &str, key: &str) -> Result, SecretStoreError> { - let url = self.build_kv_path(namespace, key); + let path = format!("{}/{}", namespace, key); info!("OPENBAO_STORE: Getting key '{key}' from namespace '{namespace}'"); - debug!("OPENBAO_STORE: Request URL: {url}"); + debug!("OPENBAO_STORE: Request path: {path}"); - let response = self - .client - .get(&url) - .header("X-Vault-Token", &self.token) - .header("X-Vault-Request", "true") - .send() + let data: serde_json::Value = kv2::read(&self.client, &self.kv_mount, &path) .await - .map_err(|e| SecretStoreError::Store(Box::new(e)))?; - - let status = response.status(); - - if status == http::StatusCode::NOT_FOUND { - return Err(SecretStoreError::NotFound { - namespace: namespace.to_string(), - key: key.to_string(), - }); - } - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(SecretStoreError::Store( - format!("Failed to get secret ({}): {}", status, body).into(), - )); - } - - let kv_response: KvV2ReadResponse = response - .json() - .await - .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + .map_err(|e| { + // Check for not found error + if e.to_string().contains("does not exist") || e.to_string().contains("404") { + SecretStoreError::NotFound { + namespace: namespace.to_string(), + key: key.to_string(), + } + } else { + SecretStoreError::Store(Box::new(e)) + } + })?; // Extract the actual secret value stored under the "value" key - let value = kv_response - .data - .data - .get("value") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - SecretStoreError::Store("Secret does not contain expected 'value' field".into()) - })?; + let value = data.get("value").and_then(|v| v.as_str()).ok_or_else(|| { + SecretStoreError::Store("Secret does not contain expected 'value' field".into()) + })?; Ok(value.as_bytes().to_vec()) } @@ -301,40 +274,22 @@ impl SecretStore for OpenbaoSecretStore { key: &str, val: &[u8], ) -> Result<(), SecretStoreError> { - let url = self.build_kv_path(namespace, key); + let path = format!("{}/{}", namespace, key); info!("OPENBAO_STORE: Setting key '{key}' in namespace '{namespace}'"); - debug!("OPENBAO_STORE: Request URL: {url}"); + debug!("OPENBAO_STORE: Request path: {path}"); let value_str = String::from_utf8(val.to_vec()).map_err(|e| SecretStoreError::Store(Box::new(e)))?; - // KV v2 requires wrapping data in a "data" object - let body = serde_json::json!({ - "data": { - "value": value_str - } + // Create the data structure expected by our format + let data = serde_json::json!({ + "value": value_str }); - let response = self - .client - .post(&url) - .header("X-Vault-Token", &self.token) - .header("X-Vault-Request", "true") - .header(header::CONTENT_TYPE, "application/json") - .json(&body) - .send() + kv2::set(&self.client, &self.kv_mount, &path, &data) .await .map_err(|e| SecretStoreError::Store(Box::new(e)))?; - let status = response.status(); - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(SecretStoreError::Store( - format!("Failed to set secret ({}): {}", status, body).into(), - )); - } - info!("OPENBAO_STORE: Successfully stored secret '{key}'"); Ok(()) } @@ -359,20 +314,4 @@ mod tests { let hash2 = OpenbaoSecretStore::hash_url("https://vault2.example.com"); assert_ne!(hash1, hash2); } - - #[test] - fn test_build_kv_path() { - let store = OpenbaoSecretStore { - client: Client::new(), - base_url: "https://vault.example.com:8200".to_string(), - kv_mount: "secret".to_string(), - token: "test-token".to_string(), - }; - - let path = store.build_kv_path("myapp-prod", "db-credentials"); - assert_eq!( - path, - "https://vault.example.com:8200/v1/secret/data/myapp-prod/db-credentials" - ); - } } -- 2.39.5 From d8338ad12ca22e741425c00f0a7b290f6ccaa823 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 4 Mar 2026 09:53:33 -0500 Subject: [PATCH 3/3] wip(sso): Openbao deploys fine, not fully tested yet, zitadel wip --- Cargo.lock | 20 ----- examples/openbao/src/main.rs | 56 +------------ harmony/src/domain/topology/ha_cluster.rs | 31 +++++++- harmony/src/modules/brocade/brocade_snmp.rs | 6 ++ harmony/src/modules/inventory/mod.rs | 6 ++ harmony/src/modules/mod.rs | 2 + harmony/src/modules/openbao/mod.rs | 88 +++++++++++++++++++++ harmony/src/modules/zitadel/mod.rs | 51 ++++++++++++ opnsense-config-xml/src/data/opnsense.rs | 1 + 9 files changed, 186 insertions(+), 75 deletions(-) create mode 100644 harmony/src/modules/openbao/mod.rs create mode 100644 harmony/src/modules/zitadel/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 91e293f5..31f96f6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3767,26 +3767,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "json-prompt" -version = "0.1.0" -dependencies = [ - "brocade", - "cidr", - "env_logger", - "harmony", - "harmony_cli", - "harmony_macros", - "harmony_secret", - "harmony_secret_derive", - "harmony_types", - "log", - "schemars 0.8.22", - "serde", - "tokio", - "url", -] - [[package]] name = "jsonpath-rust" version = "0.7.5" diff --git a/examples/openbao/src/main.rs b/examples/openbao/src/main.rs index ab8c0efa..1d5653b2 100644 --- a/examples/openbao/src/main.rs +++ b/examples/openbao/src/main.rs @@ -1,63 +1,13 @@ -use std::str::FromStr; - use harmony::{ - inventory::Inventory, - modules::helm::chart::{HelmChartScore, HelmRepository, NonBlankString}, - topology::K8sAnywhereTopology, + inventory::Inventory, modules::openbao::OpenbaoScore, topology::K8sAnywhereTopology, }; -use harmony_macros::hurl; #[tokio::main] async fn main() { - let values_yaml = Some( - r#"server: - standalone: - enabled: true - config: | - listener "tcp" { - tls_disable = true - address = "[::]:8200" - cluster_address = "[::]:8201" - } - - storage "file" { - path = "/openbao/data" - } - - service: - enabled: true - - dataStorage: - enabled: true - size: 10Gi - storageClass: null - accessMode: ReadWriteOnce - - auditStorage: - enabled: true - size: 10Gi - storageClass: null - accessMode: ReadWriteOnce"# - .to_string(), - ); - let openbao = HelmChartScore { - namespace: Some(NonBlankString::from_str("openbao").unwrap()), - release_name: NonBlankString::from_str("openbao").unwrap(), - chart_name: NonBlankString::from_str("openbao/openbao").unwrap(), - chart_version: None, - values_overrides: None, - values_yaml, - create_namespace: true, - install_only: true, - repository: Some(HelmRepository::new( - "openbao".to_string(), - hurl!("https://openbao.github.io/openbao-helm"), - true, - )), + let openbao = OpenbaoScore { + host: String::new(), }; - // TODO exec pod commands to initialize secret store if not already done - harmony_cli::run( Inventory::autoload(), K8sAnywhereTopology::from_env(), diff --git a/harmony/src/domain/topology/ha_cluster.rs b/harmony/src/domain/topology/ha_cluster.rs index b5a17d2b..ea4f2f88 100644 --- a/harmony/src/domain/topology/ha_cluster.rs +++ b/harmony/src/domain/topology/ha_cluster.rs @@ -8,7 +8,7 @@ use harmony_types::{ use log::debug; use log::info; -use crate::topology::PxeOptions; +use crate::topology::{HelmCommand, PxeOptions}; use crate::{data::FileContent, executors::ExecutorError, topology::node_exporter::NodeExporter}; use crate::{infra::network_manager::OpenShiftNmStateNetworkManager, topology::PortConfig}; @@ -18,7 +18,10 @@ use super::{ NetworkManager, PreparationError, PreparationOutcome, Router, Switch, SwitchClient, SwitchError, TftpServer, Topology, k8s::K8sClient, }; -use std::sync::{Arc, OnceLock}; +use std::{ + process::Command, + sync::{Arc, OnceLock}, +}; #[derive(Debug, Clone)] pub struct HAClusterTopology { @@ -52,6 +55,30 @@ impl Topology for HAClusterTopology { } } +impl HelmCommand for HAClusterTopology { + fn get_helm_command(&self) -> Command { + let mut cmd = Command::new("helm"); + if let Some(k) = &self.kubeconfig { + cmd.args(["--kubeconfig", k]); + } + + // FIXME we should support context anywhere there is a k8sclient + // This likely belongs in the k8sclient itself and should be extracted to a separate + // crate + // + // I feel like helm could very well be a feature of this external k8s client. + // + // Same for kustomize + // + // if let Some(c) = &self.k8s_context { + // cmd.args(["--kube-context", c]); + // } + + info!("Using helm command {cmd:?}"); + cmd + } +} + #[async_trait] impl K8sclient for HAClusterTopology { async fn k8s_client(&self) -> Result, String> { diff --git a/harmony/src/modules/brocade/brocade_snmp.rs b/harmony/src/modules/brocade/brocade_snmp.rs index 0e75c865..01a863cb 100644 --- a/harmony/src/modules/brocade/brocade_snmp.rs +++ b/harmony/src/modules/brocade/brocade_snmp.rs @@ -44,6 +44,12 @@ pub struct BrocadeSwitchAuth { pub password: String, } +impl BrocadeSwitchAuth { + pub fn user_pass(username: String, password: String) -> Self { + Self { username, password } + } +} + #[derive(Secret, Clone, Debug, JsonSchema, Serialize, Deserialize)] pub struct BrocadeSnmpAuth { pub username: String, diff --git a/harmony/src/modules/inventory/mod.rs b/harmony/src/modules/inventory/mod.rs index a27a9880..6e6f42cb 100644 --- a/harmony/src/modules/inventory/mod.rs +++ b/harmony/src/modules/inventory/mod.rs @@ -54,6 +54,12 @@ pub enum HarmonyDiscoveryStrategy { SUBNET { cidr: cidr::Ipv4Cidr, port: u16 }, } +impl Default for HarmonyDiscoveryStrategy { + fn default() -> Self { + HarmonyDiscoveryStrategy::MDNS + } +} + #[async_trait] impl Interpret for DiscoverInventoryAgentInterpret { async fn execute( diff --git a/harmony/src/modules/mod.rs b/harmony/src/modules/mod.rs index 074254db..70ecfdf6 100644 --- a/harmony/src/modules/mod.rs +++ b/harmony/src/modules/mod.rs @@ -17,9 +17,11 @@ pub mod nats; pub mod network; pub mod node_health; pub mod okd; +pub mod openbao; pub mod opnsense; pub mod postgresql; pub mod prometheus; pub mod storage; pub mod tenant; pub mod tftp; +pub mod zitadel; diff --git a/harmony/src/modules/openbao/mod.rs b/harmony/src/modules/openbao/mod.rs new file mode 100644 index 00000000..bbc2bea8 --- /dev/null +++ b/harmony/src/modules/openbao/mod.rs @@ -0,0 +1,88 @@ +use std::str::FromStr; + +use harmony_macros::hurl; +use non_blank_string_rs::NonBlankString; +use serde::Serialize; + +use crate::{ + interpret::Interpret, + modules::helm::chart::{HelmChartScore, HelmRepository}, + score::Score, + topology::{HelmCommand, K8sclient, Topology}, +}; + +#[derive(Debug, Serialize, Clone)] +pub struct OpenbaoScore { + /// Host used for external access (ingress) + pub host: String, +} + +impl Score for OpenbaoScore { + fn name(&self) -> String { + "OpenbaoScore".to_string() + } + + #[doc(hidden)] + fn create_interpret(&self) -> Box> { + // TODO exec pod commands to initialize secret store if not already done + let host = &self.host; + + let values_yaml = Some(format!( + r#"global: + openshift: true +server: + standalone: + enabled: true + config: | + ui = true + + listener "tcp" {{ + tls_disable = true + address = "[::]:8200" + cluster_address = "[::]:8201" + }} + + storage "file" {{ + path = "/openbao/data" + }} + + service: + enabled: true + + ingress: + enabled: true + hosts: + - host: {host} + dataStorage: + enabled: true + size: 10Gi + storageClass: null + accessMode: ReadWriteOnce + + auditStorage: + enabled: true + size: 10Gi + storageClass: null + accessMode: ReadWriteOnce +ui: + enabled: true"# + )); + + HelmChartScore { + namespace: Some(NonBlankString::from_str("openbao").unwrap()), + release_name: NonBlankString::from_str("openbao").unwrap(), + chart_name: NonBlankString::from_str("openbao/openbao").unwrap(), + chart_version: None, + values_overrides: None, + values_yaml, + create_namespace: true, + install_only: false, + repository: Some(HelmRepository::new( + "openbao".to_string(), + hurl!("https://openbao.github.io/openbao-helm"), + true, + )), + } + .create_interpret() + } +} diff --git a/harmony/src/modules/zitadel/mod.rs b/harmony/src/modules/zitadel/mod.rs new file mode 100644 index 00000000..1bd8a05e --- /dev/null +++ b/harmony/src/modules/zitadel/mod.rs @@ -0,0 +1,51 @@ +use std::str::FromStr; + +use harmony_macros::hurl; +use non_blank_string_rs::NonBlankString; +use serde::Serialize; + +use crate::{ + interpret::Interpret, + modules::helm::chart::{HelmChartScore, HelmRepository}, + score::Score, + topology::{HelmCommand, K8sclient, Topology}, +}; + +#[derive(Debug, Serialize, Clone)] +pub struct ZitadelScore { + /// Host used for external access (ingress) + pub host: String, +} + +impl Score for ZitadelScore { + fn name(&self) -> String { + "ZitadelScore".to_string() + } + + #[doc(hidden)] + fn create_interpret(&self) -> Box> { + // TODO exec pod commands to initialize secret store if not already done + let host = &self.host; + + let values_yaml = Some(format!(r#""#)); + + todo!("This is not complete yet"); + + HelmChartScore { + namespace: Some(NonBlankString::from_str("zitadel").unwrap()), + release_name: NonBlankString::from_str("zitadel").unwrap(), + chart_name: NonBlankString::from_str("zitadel/zitadel").unwrap(), + chart_version: None, + values_overrides: None, + values_yaml, + create_namespace: true, + install_only: false, + repository: Some(HelmRepository::new( + "zitadel".to_string(), + hurl!("https://charts.zitadel.com"), + true, + )), + } + .create_interpret() + } +} diff --git a/opnsense-config-xml/src/data/opnsense.rs b/opnsense-config-xml/src/data/opnsense.rs index 933487d4..b36c70b9 100644 --- a/opnsense-config-xml/src/data/opnsense.rs +++ b/opnsense-config-xml/src/data/opnsense.rs @@ -1408,6 +1408,7 @@ pub struct Account { pub hostnames: String, pub wildcard: i32, pub zone: MaybeString, + pub dynipv6host: Option, pub checkip: String, #[yaserde(rename = "checkip_timeout")] pub checkip_timeout: i32, -- 2.39.5