src/vault_http.rs
//! Shared **Vault-REST-with-token** HTTP transport — the ONE blocking `reqwest`
//! POST to `{addr}/v1/{path}` carrying an `X-Vault-Token` header + a JSON body,
//! returning the raw `(StatusCode, body_text)` for each caller to interpret.
//!
//! Extracted (reuse-law L5) from two hand-rolled twins that both built a
//! `reqwest::blocking::Client`, trimmed the addr's trailing `/`, set the
//! `X-Vault-Token` header and POSTed JSON to `/v1/<path>`:
//!
//! - [`crate::openbao::OpenBao::post`] — the structure provisioner (feature
//! `openbao`); its response handling **swallows** a 400 "already in use" so a
//! re-run is idempotent, else bails with status+body.
//! - [`crate::pki::vault::HttpVaultClient`] — the PKI leaf-issue client (feature
//! `vault`); its response handling **surfaces** a non-2xx as an error, else
//! parses the leaf out of the body.
//!
//! Only the RESPONSE semantics differ, so the transport lives here ONCE and each
//! caller keeps its own thin wrapper over [`VaultHttp::post_json`]. Gated on the
//! union of the two features that pull `reqwest` (`vault` | `openbao`); the lean
//! offline core compiles neither this nor `reqwest`. (A future Redfish BMC client
//! is NOT a Vault-token transport, so it does not share this seam.)
use anyhow::{Context, Result};
/// A minimal token-authenticated Vault REST transport: a blocking `reqwest` client
/// bound to a trimmed base `addr` + an `X-Vault-Token`. Build ONE, POST many paths.
pub struct VaultHttp {
addr: String,
token: String,
http: reqwest::blocking::Client,
}
impl VaultHttp {
/// Build the transport for `addr` (e.g. `https://vault:8200`) authenticated by
/// `token`, with request `timeout` (openbao uses 15s, pki 30s — their one real
/// difference besides response handling). The addr's trailing `/` is trimmed so
/// `{addr}/v1/{path}` never doubles the slash.
pub fn new(
addr: impl Into<String>,
token: impl Into<String>,
timeout: std::time::Duration,
) -> Result<Self> {
let http = crate::http_tls::tls_roots_blocking(reqwest::blocking::Client::builder())
.timeout(timeout)
.build()
.context("build Vault HTTP client")?;
Ok(Self {
addr: addr.into().trim_end_matches('/').to_string(),
token: token.into(),
http,
})
}
/// `{addr}/v1/{path}` with any leading `/` on `path` trimmed (single-slash join).
pub fn url(&self, path: &str) -> String {
format!("{}/v1/{}", self.addr, path.trim_start_matches('/'))
}
/// POST `body` as JSON to `/v1/{path}` with the `X-Vault-Token` header, returning
/// the raw `(status, body_text)` for the caller to interpret. This is the ONE
/// transport the openbao + pki clients share; response semantics (idempotency
/// swallow vs error-surface + parse) stay with the caller.
pub fn post_json(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<(reqwest::StatusCode, String)> {
let url = self.url(path);
let resp = self
.http
.post(&url)
.header("X-Vault-Token", &self.token)
.json(body)
.send()
.with_context(|| format!("POST {url}"))?;
let status = resp.status();
let text = resp.text().unwrap_or_default();
Ok((status, text))
}
}
// Reuse-law proof (RED-when-broken): BOTH Vault-token callers — the openbao structure
// provisioner AND the pki leaf-issue client — must route their POST through this ONE
// `VaultHttp` transport, so each emits a `POST /v1/<path>` carrying `X-Vault-Token`.
// If either caller regresses to a hand-rolled client with a different `/v1` join or
// header, this single test goes red. Needs BOTH features (each caller lives behind its
// own), so run under `--features vault,openbao`.
#[cfg(all(test, feature = "vault", feature = "openbao"))]
mod shared_transport_tests {
use crate::mockhttp::MockServer;
use crate::pki::VaultClient;
/// A minimally well-formed `POST /v1/pki/issue/<role>` response so the pki
/// caller's parse succeeds and the request is captured for assertion.
const ISSUE_RESP: &str = r#"{"data":{
"certificate":"-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----",
"issuing_ca":"-----BEGIN CERTIFICATE-----\ny\n-----END CERTIFICATE-----",
"private_key":"-----BEGIN RSA PRIVATE KEY-----\nz\n-----END RSA PRIVATE KEY-----",
"serial_number":"aa:bb"
}}"#;
#[test]
fn both_callers_route_through_the_shared_vault_http_transport() {
// openbao structure provisioner → shared VaultHttp: /v1 join + token header.
let ob_server = MockServer::start(1, 200, "{}");
let ob = crate::openbao::OpenBao::new(ob_server.base_url(), "ob.token").unwrap();
ob.enable_kubernetes_auth()
.expect("openbao POST via shared transport");
let ob_req = ob_server.next_request();
assert_eq!(ob_req.method, "POST");
assert_eq!(ob_req.path, "/v1/sys/auth/kubernetes");
assert_eq!(ob_req.header("X-Vault-Token"), Some("ob.token"));
// pki leaf-issue client → the SAME shared VaultHttp: /v1 join + token header.
let pki_server = MockServer::start(1, 200, ISSUE_RESP);
let client =
crate::pki::HttpVaultClient::new(pki_server.base_url(), "pki.token", "server").unwrap();
client
.issue("cn.x14.local", &["cn.x14.local".into()])
.expect("pki issue via shared transport");
let pki_req = pki_server.next_request();
assert_eq!(pki_req.method, "POST");
assert_eq!(pki_req.path, "/v1/pki/issue/server");
assert_eq!(pki_req.header("X-Vault-Token"), Some("pki.token"));
}
}