diff --git a/Cargo.lock b/Cargo.lock index 551c619f..31f96f6f 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", @@ -3700,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" @@ -3865,7 +3912,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 +5427,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 +5939,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 +6414,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 +7233,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/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/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 54494bf8..1094b2e7 100644 --- a/harmony_secret/src/config.rs +++ b/harmony_secret/src/config.rs @@ -1,4 +1,5 @@ use lazy_static::lazy_static; +use std::env; lazy_static! { pub static ref SECRET_NAMESPACE: String = @@ -16,3 +17,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..0cbc7bf3 --- /dev/null +++ b/harmony_secret/src/store/openbao.rs @@ -0,0 +1,317 @@ +use crate::{SecretStore, SecretStoreError}; +use async_trait::async_trait; +use log::{debug, info, warn}; +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)] +struct TokenResponse { + auth: AuthInfo, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AuthInfo { + client_token: String, + #[serde(default)] + lease_duration: Option, + token_type: String, +} + +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), + } + } +} + +pub struct OpenbaoSecretStore { + client: VaultClient, + kv_mount: 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 { + /// 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}"); + + // 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 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); + if let Ok(cached_token) = Self::load_cached_token(&cache_path) { + debug!("OPENBAO_STORE: Found cached token, validating..."); + if Self::validate_token(&base_url, skip_tls, &cached_token.client_token).await { + info!("OPENBAO_STORE: Cached token is valid"); + return Self::with_token( + &base_url, + skip_tls, + &cached_token.client_token, + &kv_mount, + ); + } + 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(&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}"); + } + + 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 + 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 { + 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: &AuthInfo) -> 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(serde_json::to_string(token)?.as_bytes())?; + } + #[cfg(not(unix))] + { + fs::write(path, token)?; + } + Ok(()) + } + + /// 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( + base_url: &str, + kv_mount: &str, + skip_tls: bool, + username: &str, + password: &str, + ) -> Result { + info!("OPENBAO_STORE: Authenticating with username/password"); + + // Create a client without a token for authentication + let mut settings = VaultClientSettingsBuilder::default(); + settings.address(base_url); + if skip_tls { + 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)))?; + + // 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.into()) + } +} + +#[async_trait] +impl SecretStore for OpenbaoSecretStore { + async fn get_raw(&self, namespace: &str, key: &str) -> Result, SecretStoreError> { + let path = format!("{}/{}", namespace, key); + info!("OPENBAO_STORE: Getting key '{key}' from namespace '{namespace}'"); + debug!("OPENBAO_STORE: Request path: {path}"); + + let data: serde_json::Value = kv2::read(&self.client, &self.kv_mount, &path) + .await + .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 = 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 path = format!("{}/{}", namespace, key); + info!("OPENBAO_STORE: Setting key '{key}' in namespace '{namespace}'"); + debug!("OPENBAO_STORE: Request path: {path}"); + + let value_str = + String::from_utf8(val.to_vec()).map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + // Create the data structure expected by our format + let data = serde_json::json!({ + "value": value_str + }); + + kv2::set(&self.client, &self.kv_mount, &path, &data) + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + 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); + } +} 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,