use crate::client::VaultClient;
use crate::renew::Renewer;
use crate::resolve::{self, Resolver};
use rustlavel_core::{Config, Result};
use rustlavel_http::plugin::{Plugin, Setup};
use std::sync::Arc;
const SKIP_VERIFY: &[&str] = &["VAULT_SKIP_VERIFY", "BAO_SKIP_VERIFY"];
pub struct Vault {
client: VaultClient,
roots: Vec<String>,
renewer: Arc<Renewer>,
plain_http_is_fine: bool,
}
impl Vault {
pub fn new(client: VaultClient) -> Vault {
Vault {
client,
roots: Vec::new(),
renewer: Arc::new(Renewer::new()),
plain_http_is_fine: false,
}
}
pub fn from_env() -> Result<Vault> {
Ok(Vault::new(VaultClient::from_env()?))
}
pub fn resolving(mut self, root: impl Into<String>) -> Vault {
self.roots.push(root.into());
self
}
pub fn even_over_http(mut self) -> Vault {
self.plain_http_is_fine = true;
self
}
pub fn client(&self) -> &VaultClient {
&self.client
}
pub fn renewer(&self) -> &Arc<Renewer> {
&self.renewer
}
pub async fn resolve(&self, config: &Config) -> Result<usize> {
let roots = if self.roots.is_empty() { configured_roots(config) } else { self.roots.clone() };
if roots.is_empty() {
return Ok(0);
}
let borrowed: Vec<&str> = roots.iter().map(String::as_str).collect();
let resolved = Resolver::new(&self.client).config(config, &borrowed).await?;
if resolved > 0 {
rustlavel_core::info!(
"resolved {resolved} configuration {} from the secret store",
plural(resolved)
);
}
Ok(resolved)
}
pub async fn resolve_env(&self) -> Result<usize> {
let resolved = resolve::resolve_env(&self.client).await?;
if resolved > 0 {
rustlavel_core::info!(
"resolved {resolved} environment {} from the secret store",
plural(resolved)
);
}
Ok(resolved)
}
fn warn_about_unsafe_settings(&self, config: &Config) {
if !self.plain_http_is_fine && sends_the_token_in_the_clear(self.client.address()) {
rustlavel_core::warn!(
"the secret store address is `{}`, which is plain HTTP over a network. The token \
travels in clear text and a token is the whole store — anyone who can read the \
traffic can read every secret it guards. Use https, or `.even_over_http()` if \
the address really is behind a TLS proxy on this host.",
self.client.address()
);
}
for name in SKIP_VERIFY {
if std::env::var(name).is_ok_and(|value| truthy(&value)) {
rustlavel_core::warn!(
"{name} is set, so the certificate the secret store presents is not checked. \
That makes the connection encrypted but not authenticated: anything that \
can intercept it can present its own certificate, take the token and answer \
with secrets of its own choosing."
);
}
}
if config.is_production() && !self.client.has_token() {
rustlavel_core::warn!(
"no token for the secret store. Every read will be refused until something \
authenticates — set VAULT_TOKEN, or log in before the first read."
);
}
}
}
impl Plugin for Vault {
fn name(&self) -> &'static str {
"vault"
}
fn register(self: Box<Self>, setup: &mut Setup<'_>) {
self.warn_about_unsafe_settings(setup.config);
let renewer = self.renewer.clone();
setup.state(self.client.clone());
setup.state(renewer);
}
}
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("client", &self.client)
.field("roots", &self.roots)
.field("renewer", &self.renewer)
.finish()
}
}
fn configured_roots(config: &Config) -> Vec<String> {
config.list("vault.resolve")
}
fn sends_the_token_in_the_clear(address: &str) -> bool {
let Some(rest) = address.strip_prefix("http://") else { return false };
let host = rest.split(['/', '?']).next().unwrap_or(rest);
let host = host.rsplit_once(':').map_or(host, |(head, _)| head);
!matches!(host.trim_matches(['[', ']']), "localhost" | "::1")
&& !host.starts_with("127.")
}
fn plural(count: usize) -> &'static str {
if count == 1 { "value" } else { "values" }
}
fn truthy(value: &str) -> bool {
matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fake::Fake;
use rustlavel_core::{Context, ContextBuilder, Json};
use rustlavel_http::Router;
fn register(vault: Vault, config: &Config) -> Context {
let mut router = Router::new();
let mut context: Option<ContextBuilder> = Some(Context::builder().config(config.clone()));
let mut setup = Setup { router: &mut router, config, context: &mut context };
Box::new(vault).register(&mut setup);
context.expect("context builder").build()
}
#[test]
fn registers_the_client_and_the_renewer_so_handlers_can_reach_them() {
let store = Fake::new().secret("secret/data/x", [("a", "1")]).store();
let context = register(Vault::new(store.client().clone()), &Config::new());
assert!(context.state::<VaultClient>().is_some(), "no client for a handler to use");
assert!(context.state::<Arc<Renewer>>().is_some(), "nothing to hand a lease to");
}
#[test]
fn a_plain_http_address_on_a_network_is_unsafe_and_loopback_is_not() {
assert!(sends_the_token_in_the_clear("http://vault.internal:8200"));
assert!(sends_the_token_in_the_clear("http://10.0.0.5:8200/"));
assert!(!sends_the_token_in_the_clear("https://vault.internal:8200"));
assert!(!sends_the_token_in_the_clear("http://127.0.0.1:8200"));
assert!(!sends_the_token_in_the_clear("http://localhost:8200"));
assert!(!sends_the_token_in_the_clear("http://[::1]:8200"));
}
#[test]
fn a_skip_verify_setting_is_recognised_however_it_is_spelled() {
assert!(truthy("1"));
assert!(truthy("TRUE"));
assert!(truthy(" yes "));
assert!(!truthy("0"));
assert!(!truthy(""));
}
#[tokio::test]
async fn resolves_the_roots_it_was_told_about_and_leaves_the_rest() {
let store = Fake::new()
.secret("secret/data/myapp", [("password", "s3cr3t")])
.secret("secret/data/other", [("password", "unused")])
.store();
let config = Config::new();
config.set("database.password", "vault:secret/data/myapp#password");
config.set("mail.password", "vault:secret/data/other#password");
let vault = Vault::new(store.client().clone()).resolving("database");
assert_eq!(vault.resolve(&config).await.unwrap(), 1);
assert_eq!(config.string("database.password", ""), "s3cr3t");
assert_eq!(config.string("mail.password", ""), "vault:secret/data/other#password");
store.assert_not_read("secret/data/other");
}
#[tokio::test]
async fn configuration_supplies_the_roots_when_the_builder_named_none() {
let store = Fake::new().secret("secret/data/myapp", [("password", "s3cr3t")]).store();
let config = Config::new();
config.set("vault.resolve", Json::Array(vec![Json::from("database")]));
config.set("database.password", "vault:secret/data/myapp#password");
assert_eq!(Vault::new(store.client().clone()).resolve(&config).await.unwrap(), 1);
assert_eq!(config.string("database.password", ""), "s3cr3t");
}
#[tokio::test]
async fn resolving_nothing_reads_nothing() {
let store = Fake::new().store();
assert_eq!(Vault::new(store.client().clone()).resolve(&Config::new()).await.unwrap(), 0);
store.assert_reads(0);
}
#[tokio::test]
async fn an_unresolvable_reference_refuses_to_boot() {
let store = Fake::new().denies("secret/data/myapp").store();
let config = Config::new();
config.set("database.password", "vault:secret/data/myapp#password");
let error = Vault::new(store.client().clone())
.resolving("database")
.resolve(&config)
.await
.expect_err("a refused secret must stop the boot")
.to_string();
assert!(error.contains("database.password"), "got {error}");
assert!(error.contains("secret/data/myapp"), "got {error}");
}
#[test]
fn the_plugin_never_prints_the_token() {
let store = Fake::new().store();
let printed = format!("{:?}", Vault::new(store.client().clone()));
assert!(!printed.contains("fake-token"), "got {printed}");
assert!(printed.contains("redacted"));
}
}