#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::time::Duration;
use dynamic_config::{Error, Fetched, Format, RemoteSource, Watching};
pub mod auth;
pub use auth::Auth;
use auth::{Session, Token};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
enum CallError {
Forbidden(Error),
Other(Error),
}
impl CallError {
fn into_error(self) -> Error {
match self {
Self::Forbidden(error) | Self::Other(error) => error,
}
}
}
enum CheckError {
NotKv2(Error),
#[allow(dead_code, reason = "carried for symmetry; only `NotKv2` is read")]
Transient(Error),
}
pub struct Vault {
address: String,
mount: String,
path: String,
key: String,
auth: Auth,
session: Session,
namespace: Option<String>,
timeout: Duration,
agent: Option<ureq::Agent>,
default_agent: std::sync::OnceLock<ureq::Agent>,
}
impl Vault {
pub fn new(
address: impl Into<String>,
mount: impl Into<String>,
path: impl Into<String>,
) -> Self {
Self {
address: address.into().trim_end_matches('/').to_owned(),
mount: mount.into(),
path: path.into(),
key: "db".to_owned(),
auth: Auth::Token(String::new()),
session: Session::new(),
namespace: None,
timeout: DEFAULT_TIMEOUT,
agent: None,
default_agent: std::sync::OnceLock::new(),
}
}
#[must_use]
pub fn with_key(mut self, key: impl Into<String>) -> Self {
self.key = key.into();
self
}
#[must_use]
pub fn with_token(self, token: impl Into<String>) -> Self {
self.with_auth(Auth::token(token))
}
#[must_use]
pub fn with_auth(mut self, auth: Auth) -> Self {
self.auth = auth;
self.session.invalidate();
self
}
#[must_use]
pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
self.agent = Some(agent);
self
}
#[must_use]
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.default_agent = std::sync::OnceLock::new();
self
}
pub fn watch<F>(
&self,
watching: &Watching,
interval: Duration,
mut on_change: F,
) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
let mut seen: Option<u64> = None;
while watching.keep_going() {
match self.current_version() {
Ok(version) if seen.is_none() => seen = Some(version),
Ok(version) if seen != Some(version) => {
if let Ok((document, version)) = self.read() {
seen = Some(version);
guarded(&mut on_change, document, &self.describe())?;
}
}
Err(CheckError::NotKv2(error)) => return Err(error),
_ => {}
}
watching.sleep_for(interval);
}
Ok(())
}
fn current_version(&self) -> Result<u64, CheckError> {
let body = self
.get(&self.metadata_url(), "metadata")
.map_err(CheckError::Transient)?;
body.get("data")
.and_then(|data| data.get("current_version"))
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
CheckError::NotKv2(Error::remote(format!(
"{}: the metadata has no `data.current_version`; is this a KV v2 mount?",
self.describe()
)))
})
}
fn get(&self, url: &str, what: &str) -> Result<serde_json::Value, Error> {
match self.get_once(url, what) {
Err(CallError::Forbidden(_)) if self.can_relogin() => {
self.session.invalidate();
self.get_once(url, what).map_err(CallError::into_error)
}
outcome => outcome.map_err(CallError::into_error),
}
}
fn can_relogin(&self) -> bool {
!matches!(self.auth, Auth::Token(_))
}
fn get_once(&self, url: &str, what: &str) -> Result<serde_json::Value, CallError> {
let token = self.token().map_err(CallError::Other)?;
let mut request = self.agent().get(url).header("X-Vault-Token", &token);
if let Some(namespace) = &self.namespace {
request = request.header("X-Vault-Namespace", namespace);
}
request
.call()
.map_err(|error| {
let rendered = Error::remote(format!("{}: {error}", self.describe()));
match error {
ureq::Error::StatusCode(403) => CallError::Forbidden(rendered),
_ => CallError::Other(rendered),
}
})?
.body_mut()
.read_json()
.map_err(|error| {
CallError::Other(Error::remote(format!(
"{}: the {what} response was not JSON: {error}",
self.describe()
)))
})
}
fn token(&self) -> Result<String, Error> {
if let Auth::Token(supplied) = &self.auth {
if supplied.is_empty() {
return Err(Error::remote(format!(
"{}: no credentials; call `with_token` or `with_auth`",
self.describe()
)));
}
return Ok(supplied.clone());
}
self.session
.token(|| self.login(), |token| self.renew(token))
}
fn login(&self) -> Result<Token, Error> {
let Some(path) = self.auth.path() else {
return Err(Error::remote(format!(
"{}: {} needs no login",
self.describe(),
self.auth.describe()
)));
};
let body = self.auth.body()?;
let url = format!("{}/v1/{path}", self.address);
let mut request = self.agent().post(&url);
if let Some(namespace) = &self.namespace {
request = request.header("X-Vault-Namespace", namespace);
}
let response: serde_json::Value = request
.send_json(&body)
.map_err(|error| {
Error::remote(format!(
"{}: logging in with {} failed: {error}",
self.describe(),
self.auth.describe()
))
})?
.body_mut()
.read_json()
.map_err(|error| {
Error::remote(format!(
"{}: the login response was not JSON: {error}",
self.describe()
))
})?;
self.token_from(&response, "auth")
}
fn renew(&self, token: &str) -> Result<Token, Error> {
let url = format!("{}/v1/auth/token/renew-self", self.address);
let mut request = self.agent().post(&url).header("X-Vault-Token", token);
if let Some(namespace) = &self.namespace {
request = request.header("X-Vault-Namespace", namespace);
}
let response: serde_json::Value = request
.send_json(serde_json::json!({}))
.map_err(|error| {
Error::remote(format!("{}: renewal failed: {error}", self.describe()))
})?
.body_mut()
.read_json()
.map_err(|error| {
Error::remote(format!(
"{}: the renewal response was not JSON: {error}",
self.describe()
))
})?;
let mut renewed = self.token_from(&response, "auth")?;
renewed.secret = token.to_owned();
Ok(renewed)
}
fn token_from(&self, response: &serde_json::Value, field: &str) -> Result<Token, Error> {
let auth = response.get(field).ok_or_else(|| {
Error::remote(format!(
"{}: the response has no `{field}` block",
self.describe()
))
})?;
let secret = auth
.get("client_token")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
let lease = auth
.get("lease_duration")
.and_then(serde_json::Value::as_u64)
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs);
let renewable = auth
.get("renewable")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
Ok(Token::new(secret, lease, renewable))
}
fn agent(&self) -> &ureq::Agent {
self.agent.as_ref().unwrap_or_else(|| {
self.default_agent.get_or_init(|| {
ureq::Agent::config_builder()
.timeout_global(Some(self.timeout))
.build()
.new_agent()
})
})
}
fn url(&self) -> String {
format!(
"{}/v1/{}/data/{}",
self.address,
self.mount,
self.path.trim_start_matches('/')
)
}
fn metadata_url(&self) -> String {
format!(
"{}/v1/{}/metadata/{}",
self.address,
self.mount,
self.path.trim_start_matches('/')
)
}
}
impl Vault {
fn read(&self) -> Result<(Fetched, u64), Error> {
let body = self.get(&self.url(), "secret")?;
let values = body
.get("data")
.and_then(|data| data.get("data"))
.ok_or_else(|| {
Error::remote(format!(
"{}: the response has no `data.data`; is this a KV v2 mount?",
self.describe()
))
})?;
let document = serde_json::json!({ &self.key: values });
let version = body
.get("data")
.and_then(|data| data.get("metadata"))
.and_then(|metadata| metadata.get("version"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
Ok((Fetched::new(document.to_string(), Format::Json), version))
}
}
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("address", &self.address)
.field("mount", &self.mount)
.field("path", &self.path)
.field("key", &self.key)
.field("namespace", &self.namespace)
.field("auth", &self.auth)
.finish_non_exhaustive()
}
}
impl RemoteSource for Vault {
fn fetch(&self) -> Result<Fetched, Error> {
self.read().map(|(document, _version)| document)
}
fn describe(&self) -> String {
format!("vault {} {}/{}", self.address, self.mount, self.path)
}
}
fn guarded<F>(on_change: &mut F, document: Fetched, described: &str) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| on_change(document))).unwrap_or_else(
|_| {
Err(Error::remote(format!(
"{described}: the watch callback panicked; the watch is stopped"
)))
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_never_prints_a_credential() {
let source = Vault::new("http://vault:8200", "secret", "myapp/db")
.with_auth(Auth::app_role("hunter2-role-id", "hunter2-secret-id"));
let printed = format!(
"{source:?} {:?} {:?}",
Auth::token("hunter2-token"),
Auth::userpass("admin", "hunter2-password"),
);
assert!(!printed.contains("hunter2-secret-id"), "{printed}");
assert!(!printed.contains("hunter2-token"), "{printed}");
assert!(!printed.contains("hunter2-password"), "{printed}");
assert!(printed.contains("hunter2-role-id"), "{printed}");
assert!(printed.contains("admin"), "{printed}");
}
}