Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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"));
    }
}