#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::time::Duration;
use base64::Engine;
use dynamic_config::{Error, Fetched, Format, RemoteSource, Watching};
pub mod auth;
pub use auth::{Auth, Bearer};
use auth::{Session, Token};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_WAIT: Duration = Duration::from_secs(60);
const RETRY_AFTER: Duration = Duration::from_secs(5);
pub struct Consul {
address: String,
key: String,
format: Option<Format>,
auth: Auth,
session: Session,
datacenter: Option<String>,
timeout: Duration,
wait: Duration,
agent: Option<ureq::Agent>,
}
impl Consul {
pub fn new(address: impl Into<String>, key: impl Into<String>) -> Self {
let key = key.into();
let format = Format::from_key(&key);
Self {
address: address.into().trim_end_matches('/').to_owned(),
key,
format,
auth: Auth::Anonymous,
session: Session::new(),
datacenter: None,
timeout: DEFAULT_TIMEOUT,
wait: DEFAULT_WAIT,
agent: None,
}
}
#[must_use]
pub fn with_format(mut self, format: Format) -> Self {
self.format = Some(format);
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_datacenter(mut self, datacenter: impl Into<String>) -> Self {
self.datacenter = Some(datacenter.into());
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_wait(mut self, wait: Duration) -> Self {
const CEILING: Duration = Duration::from_secs(600);
self.wait = wait.min(CEILING);
self
}
pub fn watch<F>(&self, watching: &Watching, mut on_change: F) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
let format = self.format.ok_or_else(|| {
Error::remote(format!(
"{}: the key names no format; call `with_format`",
self.describe()
))
})?;
let agent = self.agent(
self.wait
.saturating_add(self.wait / 8)
.saturating_add(self.timeout),
);
let mut index = 0;
let mut last: Option<String> = None;
let mut priming = true;
while watching.keep_going() {
let answered = match self.blocking_read(&agent, index) {
Ok(answered) => answered,
Err(_) => {
watching.sleep_for(RETRY_AFTER);
continue;
}
};
index = if answered.index < index {
0
} else {
answered.index
};
let Some(text) = answered.text else {
watching.sleep_for(RETRY_AFTER);
continue;
};
let unchanged = last.as_ref() == Some(&text);
last = Some(text.clone());
if std::mem::take(&mut priming) || unchanged {
continue;
}
guarded(&mut on_change, Fetched::new(text, format), &self.describe())?;
}
Ok(())
}
fn agent(&self, timeout: Duration) -> ureq::Agent {
self.agent.clone().unwrap_or_else(|| {
ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.build()
.new_agent()
})
}
fn token(&self) -> Result<Option<String>, Error> {
match &self.auth {
Auth::Anonymous => Ok(None),
Auth::Token(supplied) => Ok(Some(supplied.clone())),
Auth::Login { .. } => self.session.token(|| self.login()).map(Some),
}
}
fn login(&self) -> Result<Token, Error> {
let Some(body) = self.auth.login_body()? else {
return Err(Error::remote(format!(
"{}: {} needs no login",
self.describe(),
self.auth.describe()
)));
};
let url = format!("{}/v1/acl/login", self.address);
let response: serde_json::Value = self
.agent(self.timeout)
.post(&url)
.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()
))
})?;
let secret = response
.get("SecretID")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::remote(format!(
"{}: the login response has no `SecretID`",
self.describe()
))
})?
.to_owned();
let ttl = response
.get("ExpirationTTL")
.and_then(serde_json::Value::as_u64)
.filter(|nanos| *nanos > 0)
.map(Duration::from_nanos);
Ok(Token::new(secret, ttl))
}
fn authenticated(
&self,
request: ureq::RequestBuilder<ureq::typestate::WithoutBody>,
) -> Result<ureq::RequestBuilder<ureq::typestate::WithoutBody>, Error> {
match self.token()? {
Some(token) => Ok(request.header("X-Consul-Token", &token)),
None => Ok(request),
}
}
fn blocking_read(&self, agent: &ureq::Agent, index: u64) -> Result<Answered, Error> {
let mut url = self.url();
url.push(if url.contains('?') { '&' } else { '?' });
url.push_str(&format!(
"index={index}&wait={}s",
self.wait.as_secs().max(1)
));
let mut response = match self.call(agent.get(&url)) {
Err(CallError::Forbidden(_)) if self.can_relogin() => {
self.session.invalidate();
self.call(agent.get(&url)).map_err(CallError::into_error)?
}
outcome => outcome.map_err(CallError::into_error)?,
};
let index = response
.headers()
.get("X-Consul-Index")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.unwrap_or(index);
let entries: Vec<serde_json::Value> = response.body_mut().read_json().map_err(|error| {
Error::remote(format!(
"{}: the response was not JSON: {error}",
self.describe()
))
})?;
Ok(Answered {
index,
text: self.decode(&entries).ok(),
})
}
fn call(
&self,
request: ureq::RequestBuilder<ureq::typestate::WithoutBody>,
) -> Result<ureq::http::Response<ureq::Body>, CallError> {
self.authenticated(request)
.map_err(CallError::Other)?
.call()
.map_err(|error| {
let rendered = Error::remote(format!("{}: {error}", self.describe()));
match error {
ureq::Error::StatusCode(403) => CallError::Forbidden(rendered),
_ => CallError::Other(rendered),
}
})
}
fn can_relogin(&self) -> bool {
matches!(self.auth, Auth::Login { .. })
}
fn decode(&self, entries: &[serde_json::Value]) -> Result<String, Error> {
let encoded = entries
.first()
.and_then(|entry| entry.get("Value"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::remote(format!("{}: the key holds no value", self.describe())))?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|error| {
Error::remote(format!(
"{}: the value is not valid base64: {error}",
self.describe()
))
})?;
String::from_utf8(decoded).map_err(|error| {
Error::remote(format!(
"{}: the value is not UTF-8: {error}",
self.describe()
))
})
}
fn url(&self) -> String {
let mut url = format!(
"{}/v1/kv/{}",
self.address,
self.key.trim_start_matches('/')
);
if let Some(datacenter) = &self.datacenter {
url.push_str("?dc=");
url.push_str(datacenter);
}
url
}
}
enum CallError {
Forbidden(Error),
Other(Error),
}
impl CallError {
fn into_error(self) -> Error {
match self {
Self::Forbidden(error) | Self::Other(error) => error,
}
}
}
struct Answered {
index: u64,
text: Option<String>,
}
impl std::fmt::Debug for Consul {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Consul")
.field("address", &self.address)
.field("key", &self.key)
.field("format", &self.format)
.field("datacenter", &self.datacenter)
.field("auth", &self.auth)
.finish_non_exhaustive()
}
}
impl RemoteSource for Consul {
fn fetch(&self) -> Result<Fetched, Error> {
let format = self.format.ok_or_else(|| {
Error::remote(format!(
"{}: the key names no format; call `with_format`",
self.describe()
))
})?;
let agent = self.agent(self.timeout);
let mut response = match self.call(agent.get(&self.url())) {
Err(CallError::Forbidden(_)) if self.can_relogin() => {
self.session.invalidate();
self.call(agent.get(&self.url()))
.map_err(CallError::into_error)?
}
outcome => outcome.map_err(CallError::into_error)?,
};
let entries: Vec<serde_json::Value> = response.body_mut().read_json().map_err(|error| {
Error::remote(format!(
"{}: the response was not JSON: {error}",
self.describe()
))
})?;
Ok(Fetched::new(self.decode(&entries)?, format))
}
fn describe(&self) -> String {
format!("consul {} kv/{}", self.address, self.key)
}
}
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 = Consul::new("http://consul:8500", "myapp/db.json")
.with_auth(Auth::token("hunter2-consul-token"));
let printed = format!("{source:?} {:?}", Auth::token("hunter2-consul-token"));
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("Token(***)"), "{printed}");
}
}