vaultrs 0.8.0

An asynchronous Rust client library for the Hashicorp Vault API.
Documentation
use crate::api::AuthInfo;
use crate::api::{token::responses::LookupTokenResponse, EndpointMiddleware};
use crate::error::ClientError;
use async_trait::async_trait;
pub use reqwest::Identity;
use reqwest::Proxy;
use rustify::clients::reqwest::Client as HTTPClient;
use std::time::Duration;
use std::{env, fs};
use url::Url;

/// Valid URL schemes that can be used for a Vault server address
const VALID_SCHEMES: [&str; 2] = ["http", "https"];

/// The client interface capabale of interacting with API functions
#[async_trait]
pub trait Client: Send + Sync + Sized {
    /// Returns the underlying HTTP client being used for API calls
    fn http(&self) -> &HTTPClient;

    /// Returns the middleware to be used when executing API calls
    fn middle(&self) -> &EndpointMiddleware;

    /// Returns the settings used to configure this client
    fn settings(&self) -> &VaultClientSettings;

    /// Sets the underlying token for this client
    fn set_token(&mut self, token: &str);

    /// Looks up the current token being used by this client
    async fn lookup(&self) -> Result<LookupTokenResponse, ClientError> {
        crate::token::lookup_self(self).await
    }

    /// Renews the current token being used by this client
    async fn renew(&self, increment: Option<&str>) -> Result<AuthInfo, ClientError> {
        crate::token::renew_self(self, increment).await
    }

    /// Revokes the current token being used by this client
    async fn revoke(&self) -> Result<(), ClientError> {
        crate::token::revoke_self(self).await
    }

    /// Returns the status of the configured Vault server
    async fn status(&self) -> Result<crate::sys::ServerStatus, ClientError> {
        crate::sys::status(self).await
    }
}

/// A client which can be used to execute calls against a Vault server.
///
/// A vault client is configured using [VaultClientSettings] and will
/// automatically configure a backing instance of a [HTTPClient] which is
/// used for executing [Endpoints][rustify::endpoint::Endpoint].
pub struct VaultClient {
    pub http: HTTPClient,
    pub middle: EndpointMiddleware,
    pub settings: VaultClientSettings,
}

#[async_trait]
impl Client for VaultClient {
    fn http(&self) -> &HTTPClient {
        &self.http
    }

    fn middle(&self) -> &EndpointMiddleware {
        &self.middle
    }

    fn settings(&self) -> &VaultClientSettings {
        &self.settings
    }

    fn set_token(&mut self, token: &str) {
        self.settings.token = token.to_string();
        self.middle.token = token.to_string();
    }
}

impl VaultClient {
    /// Creates a new [VaultClient] using the given [VaultClientSettings].
    #[instrument(skip(settings), err)]
    pub fn new(settings: VaultClientSettings) -> Result<VaultClient, ClientError> {
        #[expect(unused_variables, reason = "false positive")]
        let http_client = reqwest::ClientBuilder::new();

        #[cfg(not(any(feature = "rustls", feature = "rustls-no-provider")))]
        let mut http_client = http_client.tls_backend_native();

        #[cfg(any(feature = "rustls", feature = "rustls-no-provider"))]
        let mut http_client = reqwest::ClientBuilder::new().tls_backend_rustls();

        // Optionally set timeout on client
        http_client = if let Some(timeout) = settings.timeout {
            http_client.timeout(timeout)
        } else {
            http_client
        };

        // Disable TLS checks if specified
        if !settings.verify {
            event!(tracing::Level::WARN, "Disabling TLS verification");
        }
        http_client = http_client.tls_danger_accept_invalid_certs(!settings.verify);

        // Workaround https://github.com/seanmonstar/reqwest/issues/2988
        if settings.address.as_str().starts_with("http://") {
            http_client = http_client.tls_certs_only(Vec::new());
        } else {
            // Adds CA certificates
            for path in &settings.ca_certs {
                let content = std::fs::read(path).map_err(|e| ClientError::FileReadError {
                    source: e,
                    path: path.clone(),
                })?;
                let certs = reqwest::Certificate::from_pem_bundle(&content).map_err(|e| {
                    ClientError::ParseCertificateError {
                        source: e,
                        path: path.clone(),
                    }
                })?;

                debug!("Importing CA certificate from {}", path);

                http_client = http_client.tls_certs_merge(certs);
            }
        }

        // Adds client certificates
        if let Some(identity) = &settings.identity {
            http_client = http_client.identity(identity.clone());
        }

        // Configures middleware for endpoints to append API version and token
        debug!("Using API version {}", settings.version);
        let version_str = format!("v{}", settings.version);
        let middle = EndpointMiddleware {
            token: settings.token.clone(),
            version: version_str,
            wrap: None,
            namespace: settings.namespace.clone(),
        };

        if let Some(proxy_url) = &settings.proxy {
            http_client = http_client.proxy(
                Proxy::all(proxy_url.as_str())
                    .map_err(|e| ClientError::RestClientBuildError { source: e })?,
            );
        }

        let http_client = http_client
            .build()
            .map_err(|e| ClientError::RestClientBuildError { source: e })?;
        let http = HTTPClient::new(settings.address.as_str(), http_client);
        Ok(VaultClient {
            settings,
            middle,
            http,
        })
    }
}

/// Contains settings for configuring a [VaultClient].
///
/// Most settings that are not directly configured will have their default value
/// pulled from their respective environment variables. Specifically:
///
/// * `address`: VAULT_ADDR
/// * `ca_certs: VAULT_CACERT / VAULT_CAPATH
/// * `token`: VAULT_TOKEN
/// * verify`: VAULT_SKIP_VERIFY
///
/// The `address` is validated when the settings are built and will throw an
/// error if the format is invalid.
#[derive(Builder, Clone, Debug)]
#[builder(build_fn(validate = "Self::validate"))]
pub struct VaultClientSettings {
    #[builder(setter(custom), default = "self.default_address()?")]
    pub address: Url,
    #[builder(default = "self.default_ca_certs()")]
    pub ca_certs: Vec<String>,
    #[builder(default = "self.default_identity()")]
    pub identity: Option<Identity>,
    #[builder(default)]
    pub timeout: Option<Duration>,
    #[builder(setter(into), default = "self.default_token()")]
    pub token: String,
    #[builder(default = "self.default_verify()")]
    pub verify: bool,
    #[builder(setter(into, strip_option), default = "1")]
    pub version: u8,
    #[builder(default = "false")]
    pub wrapping: bool,
    #[builder(default)]
    pub namespace: Option<String>,
    #[builder(setter(custom), default)]
    pub proxy: Option<Url>,
}

impl VaultClientSettingsBuilder {
    /// Set an address for vault. Note that if not set, it will default
    /// to the `VAULT_ADDR` environment variable and if that is not set either,
    /// it will default to `http://127.0.0.1:8200`.
    ///
    /// # Panics
    ///
    /// The setter will panic if the address given contains an invalid URL format.
    pub fn address<T>(&mut self, address: T) -> &mut Self
    where
        T: AsRef<str>,
    {
        let url = Url::parse(address.as_ref())
            .map_err(|_| format!("Invalid URL format: {}", address.as_ref()))
            .unwrap();
        self.address = Some(url);
        self
    }

    /// Set an proxy address for vault.
    ///
    /// # Panics
    ///
    /// The setter will panic if the address given contains an invalid URL format.
    pub fn proxy<T>(&mut self, proxy: T) -> &mut Self
    where
        T: AsRef<str>,
    {
        let url = Url::parse(proxy.as_ref())
            .map_err(|_| format!("Invalid URL format: {}", proxy.as_ref()))
            .unwrap();
        self.proxy = Some(Some(url));
        self
    }

    pub fn set_namespace(&mut self, str: String) -> &mut Self {
        self.namespace = Some(Some(str));
        self
    }

    fn default_address(&self) -> Result<Url, String> {
        let address = if let Ok(address) = env::var("VAULT_ADDR") {
            debug!("Using vault address from $VAULT_ADDR: {address}");
            address
        } else {
            debug!("Using default vault address http://127.0.0.1:8200");
            String::from("http://127.0.0.1:8200")
        };
        let url = Url::parse(&address);
        let url = url.map_err(|_| format!("Invalid URL format: {}", &address))?;
        // validation in derive_builder does not happen for defaults,
        // so we need to do it ourselves, here:
        self.validate_url(&url)?;
        Ok(url)
    }

    fn default_token(&self) -> String {
        match env::var("VAULT_TOKEN") {
            Ok(s) => {
                debug!("Using vault token from $VAULT_TOKEN");
                s
            }
            Err(_) => {
                debug!("Using default empty vault token");
                String::from("")
            }
        }
    }

    fn default_verify(&self) -> bool {
        debug!("Checking TLS verification using $VAULT_SKIP_VERIFY");
        match env::var("VAULT_SKIP_VERIFY") {
            Ok(value) => matches!(value.to_lowercase().as_str(), "0" | "f" | "false"),
            Err(_) => true,
        }
    }

    fn default_ca_certs(&self) -> Vec<String> {
        let mut paths: Vec<String> = Vec::new();

        if let Ok(s) = env::var("VAULT_CACERT") {
            debug!("Found CA certificate in $VAULT_CACERT");
            paths.push(s);
        }

        if let Ok(s) = env::var("VAULT_CAPATH") {
            debug!("Found CA certificate path in $VAULT_CAPATH");
            if let Ok(p) = fs::read_dir(s) {
                for path in p {
                    paths.push(path.unwrap().path().to_str().unwrap().to_string())
                }
            }
        }

        paths
    }

    fn default_identity(&self) -> Option<reqwest::Identity> {
        // Default value can be set from environment
        let env_client_cert = env::var("VAULT_CLIENT_CERT").unwrap_or_default();
        let env_client_key = env::var("VAULT_CLIENT_KEY").unwrap_or_default();

        if env_client_cert.is_empty() || env_client_key.is_empty() {
            debug!("No client certificate (env VAULT_CLIENT_CERT & VAULT_CLIENT_KEY are not set)");
            return None;
        }

        #[cfg(feature = "rustls")]
        {
            let mut client_cert = match fs::read(&env_client_cert) {
                Ok(content) => content,
                Err(err) => {
                    error!("error reading client cert '{}': {}", env_client_cert, err);
                    return None;
                }
            };

            let mut client_key = match fs::read(&env_client_key) {
                Ok(content) => content,
                Err(err) => {
                    error!("error reading client key '{}': {}", env_client_key, err);
                    return None;
                }
            };

            // concat certificate and key
            client_cert.append(&mut client_key);

            match reqwest::Identity::from_pem(&client_cert) {
                Ok(pkcs8) => return Some(pkcs8),
                Err(err) => error!("error creating identity: {}", err),
            };
        }

        #[cfg(feature = "native-tls")]
        {
            error!("Client certificates not implemented for native-tls");
        }

        None
    }

    fn validate(&self) -> Result<(), String> {
        // Verify URL is valid
        if let Some(url) = &self.address {
            self.validate_url(url)
        } else {
            Ok(())
        }
    }

    fn validate_url(&self, url: &Url) -> Result<(), String> {
        // Verify scheme is valid HTTP endpoint
        if !VALID_SCHEMES.contains(&url.scheme()) {
            Err(format!("Invalid scheme for HTTP URL: {}", url.scheme()))
        } else {
            Ok(())
        }
    }
}