//! Lightweight OpenBao/Vault HTTP API client. //! //! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the //! Python version with direct HTTP API calls via port-forward to openbao:8200. use crate::error::{Result, ResultExt}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// OpenBao HTTP client wrapping a base URL and optional root token. #[derive(Clone)] pub struct BaoClient { pub base_url: String, pub token: Option, http: reqwest::Client, } // ── API response types ────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub struct InitResponse { pub unseal_keys_b64: Vec, pub root_token: String, } #[derive(Debug, Deserialize)] pub struct SealStatusResponse { #[serde(default)] pub initialized: bool, #[serde(default)] pub sealed: bool, #[serde(default)] pub progress: u32, #[serde(default)] pub t: u32, #[serde(default)] pub n: u32, } #[derive(Debug, Deserialize)] pub struct UnsealResponse { #[serde(default)] pub sealed: bool, #[serde(default)] pub progress: u32, } /// KV v2 read response wrapper. #[derive(Debug, Deserialize)] struct KvReadResponse { data: Option, } #[derive(Debug, Deserialize)] struct KvReadData { data: Option>, } // ── Client implementation ─────────────────────────────────────────────────── impl BaoClient { /// Create a new client pointing at `base_url` (e.g. `http://localhost:8200`). pub fn new(base_url: &str) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), token: None, http: reqwest::Client::new(), } } /// Create a client with an authentication token. pub fn with_token(base_url: &str, token: &str) -> Self { let mut client = Self::new(base_url); client.token = Some(token.to_string()); client } fn url(&self, path: &str) -> String { format!("{}/v1/{}", self.base_url, path.trim_start_matches('/')) } fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { let mut req = self.http.request(method, self.url(path)); if let Some(ref token) = self.token { req = req.header("X-Vault-Token", token); } req } // ── System operations ─────────────────────────────────────────────── /// Get the seal status of the OpenBao instance. pub async fn seal_status(&self) -> Result { let resp = self .http .get(format!("{}/v1/sys/seal-status", self.base_url)) .send() .await .ctx("Failed to connect to OpenBao")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("OpenBao seal-status returned {status}: {body}"); } resp.json().await.ctx("Failed to parse seal status") } /// Initialize OpenBao with the given number of key shares and threshold. pub async fn init(&self, key_shares: u32, key_threshold: u32) -> Result { #[derive(Serialize)] struct InitRequest { secret_shares: u32, secret_threshold: u32, } let resp = self .http .put(format!("{}/v1/sys/init", self.base_url)) .json(&InitRequest { secret_shares: key_shares, secret_threshold: key_threshold, }) .send() .await .ctx("Failed to initialize OpenBao")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("OpenBao init returned {status}: {body}"); } resp.json().await.ctx("Failed to parse init response") } /// Unseal OpenBao with one key share. pub async fn unseal(&self, key: &str) -> Result { #[derive(Serialize)] struct UnsealRequest<'a> { key: &'a str, } let resp = self .http .put(format!("{}/v1/sys/unseal", self.base_url)) .json(&UnsealRequest { key }) .send() .await .ctx("Failed to unseal OpenBao")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("OpenBao unseal returned {status}: {body}"); } resp.json().await.ctx("Failed to parse unseal response") } // ── Secrets engine management ─────────────────────────────────────── /// Enable a secrets engine at the given path. /// Returns Ok(()) even if already enabled (400 is tolerated). pub async fn enable_secrets_engine(&self, path: &str, engine_type: &str) -> Result<()> { #[derive(Serialize)] struct EnableRequest<'a> { r#type: &'a str, } let resp = self .request(reqwest::Method::POST, &format!("sys/mounts/{path}")) .json(&EnableRequest { r#type: engine_type, }) .send() .await .ctx("Failed to enable secrets engine")?; let status = resp.status(); if status.is_success() || status.as_u16() == 400 { // 400 = "path is already in use" — idempotent Ok(()) } else { let body = resp.text().await.unwrap_or_default(); bail!("Enable secrets engine {path} returned {status}: {body}"); } } // ── KV v2 operations ──────────────────────────────────────────────── /// Read all fields from a KV v2 secret path. /// Returns None if the path doesn't exist (404). pub async fn kv_get(&self, mount: &str, path: &str) -> Result>> { let resp = self .request(reqwest::Method::GET, &format!("{mount}/data/{path}")) .send() .await .ctx("Failed to read KV secret")?; if resp.status().as_u16() == 404 { return Ok(None); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("KV get {mount}/{path} returned {status}: {body}"); } let kv_resp: KvReadResponse = resp.json().await.ctx("Failed to parse KV response")?; let data = kv_resp .data .and_then(|d| d.data) .unwrap_or_default(); // Convert all values to strings let result: HashMap = data .into_iter() .map(|(k, v)| { let s = match v { serde_json::Value::String(s) => s, other => other.to_string(), }; (k, s) }) .collect(); Ok(Some(result)) } /// Read a single field from a KV v2 secret path. /// Returns empty string if path or field doesn't exist. pub async fn kv_get_field(&self, mount: &str, path: &str, field: &str) -> Result { match self.kv_get(mount, path).await? { Some(data) => Ok(data.get(field).cloned().unwrap_or_default()), None => Ok(String::new()), } } /// Write (create or overwrite) all fields in a KV v2 secret path. pub async fn kv_put( &self, mount: &str, path: &str, data: &HashMap, ) -> Result<()> { #[derive(Serialize)] struct KvWriteRequest<'a> { data: &'a HashMap, } let resp = self .request(reqwest::Method::POST, &format!("{mount}/data/{path}")) .json(&KvWriteRequest { data }) .send() .await .ctx("Failed to write KV secret")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("KV put {mount}/{path} returned {status}: {body}"); } Ok(()) } /// Patch (merge) fields into an existing KV v2 secret path. pub async fn kv_patch( &self, mount: &str, path: &str, data: &HashMap, ) -> Result<()> { #[derive(Serialize)] struct KvWriteRequest<'a> { data: &'a HashMap, } let resp = self .request(reqwest::Method::PATCH, &format!("{mount}/data/{path}")) .header("Content-Type", "application/merge-patch+json") .json(&KvWriteRequest { data }) .send() .await .ctx("Failed to patch KV secret")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("KV patch {mount}/{path} returned {status}: {body}"); } Ok(()) } /// Delete a KV v2 secret path (soft delete — deletes latest version). pub async fn kv_delete(&self, mount: &str, path: &str) -> Result<()> { let resp = self .request(reqwest::Method::DELETE, &format!("{mount}/data/{path}")) .send() .await .ctx("Failed to delete KV secret")?; // 404 is fine (already deleted) if !resp.status().is_success() && resp.status().as_u16() != 404 { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("KV delete {mount}/{path} returned {status}: {body}"); } Ok(()) } // ── Auth operations ───────────────────────────────────────────────── /// Enable an auth method at the given path. /// Tolerates "already enabled" (400/409). pub async fn auth_enable(&self, path: &str, method_type: &str) -> Result<()> { #[derive(Serialize)] struct AuthEnableRequest<'a> { r#type: &'a str, } let resp = self .request(reqwest::Method::POST, &format!("sys/auth/{path}")) .json(&AuthEnableRequest { r#type: method_type, }) .send() .await .ctx("Failed to enable auth method")?; let status = resp.status(); if status.is_success() || status.as_u16() == 400 { Ok(()) } else { let body = resp.text().await.unwrap_or_default(); bail!("Enable auth {path} returned {status}: {body}"); } } /// Write a policy. pub async fn write_policy(&self, name: &str, policy_hcl: &str) -> Result<()> { #[derive(Serialize)] struct PolicyRequest<'a> { policy: &'a str, } let resp = self .request( reqwest::Method::PUT, &format!("sys/policies/acl/{name}"), ) .json(&PolicyRequest { policy: policy_hcl }) .send() .await .ctx("Failed to write policy")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("Write policy {name} returned {status}: {body}"); } Ok(()) } /// Write to an arbitrary API path (for auth config, roles, database config, etc.). pub async fn write( &self, path: &str, data: &serde_json::Value, ) -> Result { let resp = self .request(reqwest::Method::POST, path) .json(data) .send() .await .with_ctx(|| format!("Failed to write to {path}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("Write {path} returned {status}: {body}"); } let body = resp.text().await.unwrap_or_default(); if body.is_empty() { Ok(serde_json::Value::Null) } else { serde_json::from_str(&body).ctx("Failed to parse write response") } } /// Read from an arbitrary API path. pub async fn read(&self, path: &str) -> Result> { let resp = self .request(reqwest::Method::GET, path) .send() .await .with_ctx(|| format!("Failed to read {path}"))?; if resp.status().as_u16() == 404 { return Ok(None); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); bail!("Read {path} returned {status}: {body}"); } let body = resp.text().await.unwrap_or_default(); if body.is_empty() { Ok(Some(serde_json::Value::Null)) } else { Ok(Some(serde_json::from_str(&body)?)) } } // ── Database secrets engine ───────────────────────────────────────── /// Configure the database secrets engine connection. pub async fn write_db_config( &self, name: &str, plugin: &str, connection_url: &str, username: &str, password: &str, allowed_roles: &str, ) -> Result<()> { let data = serde_json::json!({ "plugin_name": plugin, "connection_url": connection_url, "username": username, "password": password, "allowed_roles": allowed_roles, }); self.write(&format!("database/config/{name}"), &data).await?; Ok(()) } /// Create a database static role. pub async fn write_db_static_role( &self, name: &str, db_name: &str, username: &str, rotation_period: u64, rotation_statements: &[&str], ) -> Result<()> { let data = serde_json::json!({ "db_name": db_name, "username": username, "rotation_period": rotation_period, "rotation_statements": rotation_statements, }); self.write(&format!("database/static-roles/{name}"), &data) .await?; Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_client_url_construction() { let client = BaoClient::new("http://localhost:8200"); assert_eq!(client.url("sys/seal-status"), "http://localhost:8200/v1/sys/seal-status"); assert_eq!(client.url("/sys/seal-status"), "http://localhost:8200/v1/sys/seal-status"); } #[test] fn test_client_url_strips_trailing_slash() { let client = BaoClient::new("http://localhost:8200/"); assert_eq!(client.base_url, "http://localhost:8200"); } #[test] fn test_with_token() { let client = BaoClient::with_token("http://localhost:8200", "mytoken"); assert_eq!(client.token, Some("mytoken".to_string())); } #[test] fn test_new_has_no_token() { let client = BaoClient::new("http://localhost:8200"); assert!(client.token.is_none()); } }