mod http;
use std::sync::Arc;
use std::time::Duration;
use dynamic_config::{Error, Fetched, Format, RemoteSource};
use dynamic_config_store_core::tls::TlsConfig;
use dynamic_config_store_core::{redacted, LoneAuthority};
use http::{Budget, Connection, Endpoint};
const MOST_BYTES: usize = 1024 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
pub struct ConfigServer {
url: String,
application: String,
profile: String,
token: Option<String>,
tls: TlsConfig,
timeout: Duration,
client: std::sync::OnceLock<Arc<rustls::ClientConfig>>,
described: String,
}
impl ConfigServer {
#[must_use]
pub fn new(
url: impl Into<String>,
application: impl Into<String>,
profile: impl Into<String>,
) -> Self {
let (url, application, profile) = (url.into(), application.into(), profile.into());
let described = format!(
"config server {} {application}/{profile}",
redacted(&url, LoneAuthority::Username)
);
Self {
url,
application,
profile,
token: None,
tls: TlsConfig::new(),
timeout: DEFAULT_TIMEOUT,
client: std::sync::OnceLock::new(),
described,
}
}
#[must_use]
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
#[must_use]
pub fn with_tls(mut self, tls: TlsConfig) -> Self {
self.tls = tls;
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
fn tls_client(&self, secure: bool) -> Result<Option<&Arc<rustls::ClientConfig>>, Error> {
if !secure {
return Ok(None);
}
if let Some(built) = self.client.get() {
return Ok(Some(built));
}
let built = self.build_tls_client()?;
Ok(Some(self.client.get_or_init(|| built)))
}
fn build_tls_client(&self) -> Result<Arc<rustls::ClientConfig>, Error> {
use rustls::pki_types::pem::PemObject as _;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
let mut roots = rustls::RootCertStore::empty();
for certificate in rustls_native_certs::load_native_certs().certs {
let _ = roots.add(certificate);
}
if let Some(pem) = self.tls.ca_certificate_pem(&self.described)? {
let mut added = 0;
for certificate in CertificateDer::pem_slice_iter(&pem) {
let certificate = certificate.map_err(|_| {
Error::remote(format!(
"{}: the certificate authority is not readable as PEM",
self.described
))
})?;
roots
.add(certificate)
.map_err(|error| Error::remote(format!("{}: {error}", self.described)))?;
added += 1;
}
if added == 0 {
return Err(Error::remote(format!(
"{}: the certificate authority holds no certificate",
self.described
)));
}
}
let builder = rustls::ClientConfig::builder().with_root_certificates(roots);
let Some((certificate, key)) = self.tls.client_certificate_pem(&self.described)? else {
return Ok(Arc::new(builder.with_no_client_auth()));
};
let chain = CertificateDer::pem_slice_iter(&certificate)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
Error::remote(format!(
"{}: the client certificate is not readable as PEM",
self.described
))
})?;
let key = PrivateKeyDer::from_pem_slice(&key).map_err(|_| {
Error::remote(format!(
"{}: the client private key is not readable as PEM",
self.described
))
})?;
builder
.with_client_auth_cert(chain, key)
.map(Arc::new)
.map_err(|error| Error::remote(format!("{}: {error}", self.described)))
}
async fn read(&self) -> Result<Fetched, Error> {
let endpoint = Endpoint::parse(&self.url, &self.described)?;
let path = endpoint.path(&format!("/{}/{}", self.application, self.profile));
let budget = Budget::starting(self.timeout);
let secure = endpoint.secure;
let mut connection =
Connection::open(&endpoint, self.tls_client(secure)?, budget, &self.described).await?;
let response = connection
.get(
&endpoint,
&path,
self.token.as_deref(),
"application/json",
budget,
&self.described,
)
.await?;
if !response.status().is_success() {
return Err(http::refused(response.status(), &self.described));
}
let body = http::body(response, MOST_BYTES, budget, &self.described).await?;
let text = String::from_utf8(body)
.map_err(|_| Error::remote(format!("{}: the document is not UTF-8", self.described)))?;
let document = extract(&text, &self.described)?;
Ok(Fetched::new(document, Format::Json))
}
}
fn extract(text: &str, described: &str) -> Result<String, Error> {
let envelope: serde_json::Value = serde_json::from_str(text)
.map_err(|_| Error::remote(format!("{described}: the answer is not JSON")))?;
let document = envelope.get("config").ok_or_else(|| {
Error::remote(format!(
"{described}: the answer carries no `config` member; is this a \
config server?"
))
})?;
serde_json::to_string(document)
.map_err(|_| Error::remote(format!("{described}: the document will not re-render")))
}
impl RemoteSource for ConfigServer {
fn fetch(&self) -> Result<Fetched, Error> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| {
Error::remote(format!(
"{}: no runtime for the fetch: {error}",
self.described
))
})?;
runtime.block_on(self.read())
}
fn describe(&self) -> String {
self.described.clone()
}
}
impl std::fmt::Debug for ConfigServer {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ConfigServer")
.field("url", &redacted(&self.url, LoneAuthority::Username))
.field("application", &self.application)
.field("profile", &self.profile)
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.field("tls", &self.tls)
.field("timeout", &self.timeout)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_document_is_lifted_out_of_the_servers_envelope() {
let text = r#"{"application":"billing","profile":"prod","generation":7,
"config":{"port":8080}}"#;
assert_eq!(extract(text, "a server").unwrap(), r#"{"port":8080}"#);
}
#[test]
fn an_answer_from_something_that_is_not_a_config_server_says_so() {
let error = extract(r#"{"hello":"world"}"#, "a server").unwrap_err();
assert!(error.to_string().contains("no `config` member"), "{error}");
}
#[test]
fn a_password_in_the_url_reaches_neither_debug_nor_a_message() {
let source = ConfigServer::new(
"https://user:hunter2-do-not-print@config.internal",
"billing",
"prod",
);
let rendered = format!("{source:?}");
assert!(!rendered.contains("hunter2"), "{rendered}");
let described = source.describe();
assert!(!described.contains("hunter2"), "{described}");
assert!(described.contains("user:***@"), "{described}");
let error = Endpoint::parse(&source.url, &source.described)
.expect_err("a `user:password@` authority is refused");
assert!(!error.to_string().contains("hunter2"), "{error}");
}
#[test]
fn a_token_never_reaches_debug() {
let source = ConfigServer::new("https://config.internal", "billing", "prod")
.with_token("hunter2-do-not-print");
let rendered = format!("{source:?}");
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(rendered.contains("<redacted>"), "{rendered}");
}
}