#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::time::Duration;
use base64::Engine;
use dynamic_config::{Error, Fetched, Format, RemoteSink, RemoteSource, Watching};
use dynamic_config_store_core::attempts::Attempts;
use dynamic_config_store_core::credential::{Cached, Issued};
use dynamic_config_store_core::documents::{self, Overlap};
use dynamic_config_store_core::guarded;
pub mod auth;
mod tls;
pub use auth::{Auth, Bearer};
pub use dynamic_config_store_core::tls::TlsConfig;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_WAIT: Duration = Duration::from_secs(60);
const RETRY_AFTER: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Keys {
One(String),
Several(Vec<String>),
Prefix(String),
}
impl Keys {
#[must_use]
pub fn one(key: impl Into<String>) -> Self {
Self::One(key.into())
}
#[must_use]
pub fn several<I, S>(keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::Several(keys.into_iter().map(Into::into).collect())
}
#[must_use]
pub fn prefix(prefix: impl Into<String>) -> Self {
Self::Prefix(prefix.into())
}
fn named(&self) -> &[String] {
match self {
Self::One(key) => std::slice::from_ref(key),
Self::Several(keys) => keys,
Self::Prefix(_) => &[],
}
}
fn describe(&self) -> String {
match self {
Self::One(key) => format!("kv/{key}"),
Self::Several(keys) => format!("keys {}", keys.join(", ")),
Self::Prefix(prefix) => format!("prefix {prefix}"),
}
}
}
impl From<&str> for Keys {
fn from(key: &str) -> Self {
Self::one(key)
}
}
impl From<String> for Keys {
fn from(key: String) -> Self {
Self::One(key)
}
}
impl From<&String> for Keys {
fn from(key: &String) -> Self {
Self::one(key)
}
}
pub struct Consul {
address: String,
keys: Keys,
format: Option<Format>,
disagreement: Option<String>,
auth: Auth,
session: Cached<String>,
datacenter: Option<String>,
timeout: Duration,
wait: Duration,
agent: Option<ureq::Agent>,
tls: Option<TlsConfig>,
attempts: Attempts,
}
impl Consul {
pub fn new(address: impl Into<String>, keys: impl Into<Keys>) -> Self {
let keys = keys.into();
let (format, disagreement) = match documents::agreed_format(keys.named()) {
Ok(format) => (format, None),
Err(complaint) => (None, Some(complaint)),
};
Self {
address: address.into().trim_end_matches('/').to_owned(),
keys,
format,
disagreement,
auth: Auth::Anonymous,
session: Cached::new(),
datacenter: None,
timeout: DEFAULT_TIMEOUT,
wait: DEFAULT_WAIT,
agent: None,
tls: None,
attempts: Attempts::default(),
}
}
#[must_use]
pub fn with_format(mut self, format: Format) -> Self {
self.format = Some(format);
self.disagreement = None;
self
}
fn format(&self) -> Result<Format, Error> {
if let Some(complaint) = &self.disagreement {
return Err(Error::remote(format!("{}: {complaint}", self.describe())));
}
self.format.ok_or_else(|| {
Error::remote(format!(
"{}: the key names no format; call `with_format`",
self.describe()
))
})
}
fn watched(&self) -> Result<(&str, bool), Error> {
match &self.keys {
Keys::One(key) => Ok((key, false)),
Keys::Prefix(prefix) => Ok((prefix, true)),
Keys::Several(_) => Err(Error::remote(format!(
"{}: a source that reads a named list of keys cannot be \
watched; Consul has no batch read, so the set would be \
blocked on one key and then read key by key, which can \
deliver a document that never existed — watch a prefix, or \
poll `refresh_remote()` on a timer instead",
self.describe()
))),
}
}
#[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_tls(mut self, tls: TlsConfig) -> Self {
self.tls = Some(tls);
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
}
#[must_use]
pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
self.attempts = Attempts::to(sink);
self
}
pub fn watch<F>(&self, watching: &Watching, mut on_change: F) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
let format = self.format()?;
let (watched, recurse) = self.watched()?;
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, watched, recurse, index) {
Ok(answered) => answered,
Err(error) => {
self.attempts.failed(&error);
watching.sleep_for(RETRY_AFTER);
continue;
}
};
index = if answered.index < index {
0
} else {
answered.index
};
let folded = self
.watched_document(&answered.entries, recurse, format)
.inspect_err(|error| self.attempts.failed(error))?;
let Some(text) = folded else {
if self.attempts.is_reporting() {
self.attempts.failed(&Error::remote(format!(
"{}: the watched key holds no value",
self.describe()
)));
}
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) -> Result<ureq::Agent, Error> {
if let Some(agent) = &self.agent {
if self.tls.is_some() {
return Err(Error::remote(format!(
"{}: `with_agent` and `with_tls` were both called; \
an agent already carries its own TLS configuration, so \
this is refused rather than resolved — put the certificate \
authority on the agent, or drop the agent",
self.describe()
)));
}
return Ok(agent.clone());
}
match &self.tls {
Some(tls) => tls::agent(tls, timeout, &self.describe()),
None => Ok(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.get(|_previous| self.login()).map(Some),
}
}
fn login(&self) -> Result<Issued<String>, 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| {
let described = format!(
"{}: logging in with {} failed: {error}",
self.describe(),
self.auth.describe()
);
match error {
ureq::Error::StatusCode(403) => Error::auth(described),
_ => Error::remote(described),
}
})?
.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(Issued { value: 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,
key: &str,
recurse: bool,
index: u64,
) -> Result<Answered, Error> {
let mut url = self.url(key, recurse);
url.push(if url.contains('?') { '&' } else { '?' });
url.push_str(&format!(
"index={index}&wait={}s",
self.wait.as_secs().max(1)
));
let mut response = self.get(agent, &url)?;
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, entries })
}
fn watched_document(
&self,
entries: &[serde_json::Value],
recurse: bool,
format: Format,
) -> Result<Option<String>, Error> {
if !recurse {
return Ok(self
.pairs_of(entries, self.keys.named().first().map(String::as_str))
.ok()
.and_then(|mut pairs| pairs.pop())
.map(|(_, text)| text));
}
documents::within_key_budget(entries.len(), &self.describe())?;
let pairs = self.pairs_of(entries, None)?;
if pairs.is_empty() {
return Ok(None);
}
Ok(Some(
documents::merged(&pairs, format, Overlap::Refused, &self.describe())?.text,
))
}
fn get(
&self,
agent: &ureq::Agent,
url: &str,
) -> Result<ureq::http::Response<ureq::Body>, Error> {
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),
}
}
fn documents(&self, agent: &ureq::Agent) -> Result<Vec<(String, String)>, Error> {
match &self.keys {
Keys::One(key) => self.read(agent, key, false),
Keys::Several(keys) => {
let mut documents = Vec::with_capacity(keys.len());
for key in keys {
documents.extend(self.read(agent, key, false)?);
}
Ok(documents)
}
Keys::Prefix(prefix) => self.read(agent, prefix, true),
}
}
fn read(
&self,
agent: &ureq::Agent,
key: &str,
recurse: bool,
) -> Result<Vec<(String, String)>, Error> {
let mut response = self.get(agent, &self.url(key, recurse))?;
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()
))
})?;
if recurse {
documents::within_key_budget(entries.len(), &self.describe())?;
}
self.pairs_of(&entries, (!recurse).then_some(key))
}
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 described = format!("{}: {error}", self.describe());
match error {
ureq::Error::StatusCode(403) => CallError::Forbidden(Error::auth(described)),
_ => CallError::Other(Error::remote(described)),
}
})
}
fn can_relogin(&self) -> bool {
matches!(self.auth, Auth::Login { .. })
}
fn pairs_of(
&self,
entries: &[serde_json::Value],
expected: Option<&str>,
) -> Result<Vec<(String, String)>, Error> {
if let Some(key) = expected {
if entries.is_empty() {
return Err(Error::remote(format!(
"{}: `{key}` holds no value",
self.describe()
)));
}
}
let mut pairs = Vec::with_capacity(entries.len());
for entry in entries {
let key = entry
.get("Key")
.and_then(serde_json::Value::as_str)
.or(expected)
.unwrap_or_default()
.to_owned();
if let Keys::Prefix(prefix) = &self.keys {
documents::under_prefix(&key, prefix.trim_start_matches('/'), &self.describe())?;
if key.ends_with('/') {
continue;
}
}
let encoded = entry
.get("Value")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::remote(format!("{}: `{key}` holds no value", self.describe()))
})?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|error| {
Error::remote(format!(
"{}: `{key}` is not valid base64: {error}",
self.describe()
))
})?;
let text = String::from_utf8(decoded).map_err(|error| {
Error::remote(format!(
"{}: `{key}` is not UTF-8: {error}",
self.describe()
))
})?;
pairs.push((key, text));
}
Ok(pairs)
}
fn overlap(&self) -> Overlap {
match self.keys {
Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
Keys::Prefix(_) => Overlap::Refused,
}
}
fn url(&self, key: &str, recurse: bool) -> String {
let mut url = format!("{}/v1/kv/{}", self.address, key.trim_start_matches('/'));
if recurse {
url.push_str("?recurse=true");
}
if let Some(datacenter) = &self.datacenter {
url.push(if url.contains('?') { '&' } else { '?' });
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,
entries: Vec<serde_json::Value>,
}
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("keys", &self.keys)
.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()?;
let documents = self.documents(&self.agent(self.timeout)?)?;
documents::merged(&documents, format, self.overlap(), &self.describe())
}
fn describe(&self) -> String {
format!("consul {} {}", self.address, self.keys.describe())
}
}
#[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}");
}
}