#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use async_nats::jetstream::kv::{Operation, Store};
use dynamic_config::{AsyncRemoteSource, Error, Fetched, Format, RemoteSink};
use dynamic_config_store_core::attempts::Attempts;
use dynamic_config_store_core::documents::{self, Overlap};
use dynamic_config_store_core::{guarded, LoneAuthority};
pub use async_nats::{Client, ConnectOptions};
use futures_util::StreamExt;
use dynamic_config_store_core::tls as tls_core;
pub use dynamic_config_store_core::tls::TlsConfig;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Keys {
One(String),
Several(Vec<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())
}
fn named(&self) -> &[String] {
match self {
Self::One(key) => std::slice::from_ref(key),
Self::Several(keys) => keys,
}
}
fn describe(&self) -> String {
match self {
Self::One(key) => format!("key {key}"),
Self::Several(keys) => format!("keys {}", keys.join(", ")),
}
}
}
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 Nats {
store: Store,
keys: Keys,
format: Option<Format>,
disagreement: Option<String>,
server: String,
bucket: String,
timeout: Duration,
attempts: Attempts,
}
impl Nats {
pub async fn new(
server: impl Into<String>,
bucket: impl Into<String>,
key: impl Into<Keys>,
) -> Result<Self, Error> {
Self::with_options(server, bucket, key, ConnectOptions::new()).await
}
pub async fn with_options(
server: impl Into<String>,
bucket: impl Into<String>,
key: impl Into<Keys>,
options: ConnectOptions,
) -> Result<Self, Error> {
let server = server.into();
let bucket = bucket.into();
let keys = key.into();
let described = redacted(&server);
let client = options.connect(&server).await.map_err(|error| {
let described = format!("nats {described}: {error}");
match error.kind() {
async_nats::ConnectErrorKind::Authentication
| async_nats::ConnectErrorKind::AuthorizationViolation => Error::auth(described),
_ => Error::remote(described),
}
})?;
let store = async_nats::jetstream::new(client)
.get_key_value(&bucket)
.await
.map_err(|error| Error::remote(format!("nats {described} bucket {bucket}: {error}")))?;
let (format, disagreement) = agreed(&keys);
Ok(Self {
store,
keys,
format,
disagreement,
server: described,
bucket,
timeout: DEFAULT_TIMEOUT,
attempts: Attempts::default(),
})
}
pub async fn with_tls(
server: impl Into<String>,
bucket: impl Into<String>,
key: impl Into<Keys>,
options: ConnectOptions,
tls: &TlsConfig,
) -> Result<Self, Error> {
let server = server.into();
let described = format!("nats {}", redacted(&server));
let options = with_tls_options(options, tls, &described)?;
Self::with_options(server, bucket, key, options).await
}
pub async fn from_client(
client: Client,
bucket: impl Into<String>,
key: impl Into<Keys>,
) -> Result<Self, Error> {
let bucket = bucket.into();
let store = async_nats::jetstream::new(client)
.get_key_value(&bucket)
.await
.map_err(|error| Error::remote(format!("nats bucket {bucket}: {error}")))?;
Ok(Self::from_store(store, key))
}
#[must_use]
pub fn from_store(store: Store, key: impl Into<Keys>) -> Self {
let keys = key.into();
let bucket = store.name.clone();
let (format, disagreement) = agreed(&keys);
Self {
store,
keys,
format,
disagreement,
server: "<an existing connection>".to_owned(),
bucket,
timeout: DEFAULT_TIMEOUT,
attempts: Attempts::default(),
}
}
#[must_use]
pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
self.attempts = Attempts::to(sink);
self
}
fn failing(&self, error: Error) -> Error {
self.attempts.failed(&error);
error
}
#[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 single_key(&self) -> Result<&str, Error> {
match &self.keys {
Keys::One(key) => Ok(key),
Keys::Several(_) => Err(Error::remote(format!(
"{}: a source that reads several keys cannot be watched; \
poll `refresh_remote_async()` on a timer instead",
self.describe()
))),
}
}
fn overlap(&self) -> Overlap {
Overlap::LaterWins
}
async fn documents(&self) -> Result<Vec<(String, String)>, Error> {
let keys = self.keys.named();
let mut documents = Vec::with_capacity(keys.len());
for key in keys {
documents.push((key.clone(), self.read(key).await?));
}
Ok(documents)
}
async fn read(&self, key: &str) -> Result<String, Error> {
let read = tokio::time::timeout(self.timeout, self.store.get(key))
.await
.map_err(|_| {
Error::remote(format!(
"{}: `{key}` timed out after {:?}",
self.describe(),
self.timeout
))
})?;
let value = read
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?
.ok_or_else(|| Error::remote(format!("{}: `{key}` holds no value", self.describe())))?;
String::from_utf8(value.to_vec()).map_err(|error| {
Error::remote(format!(
"{}: `{key}` is not UTF-8: {error}",
self.describe()
))
})
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub async fn watch<F>(&self, mut on_change: F) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error> + Send,
{
let format = self.format()?;
let key = self.single_key()?;
let mut entries = self.store.watch(key).await.map_err(|error| {
self.failing(Error::remote(format!(
"{}: cannot watch: {error}",
self.describe()
)))
})?;
while let Some(entry) = entries.next().await {
let entry = entry.map_err(|error| {
self.failing(Error::remote(format!(
"{}: the watch failed: {error}",
self.describe()
)))
})?;
if entry.operation != Operation::Put {
continue;
}
let text = String::from_utf8(entry.value.to_vec()).map_err(|error| {
self.failing(Error::remote(format!(
"{}: the value is not UTF-8: {error}",
self.describe()
)))
})?;
guarded(&mut on_change, Fetched::new(text, format), &self.describe())?;
}
Err(self.failing(Error::remote(format!(
"{}: the watch ended; the stream was closed",
self.describe()
))))
}
}
impl AsyncRemoteSource for Nats {
fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
Box::pin(async move {
let format = self.format()?;
let documents = self.documents().await?;
documents::merged(&documents, format, self.overlap(), &self.describe())
})
}
fn describe(&self) -> String {
format!(
"nats {} bucket {} {}",
self.server,
self.bucket,
self.keys.describe()
)
}
}
fn with_tls_options(
mut options: ConnectOptions,
tls: &TlsConfig,
described: &str,
) -> Result<ConnectOptions, Error> {
if let Some(ca) = tls.ca_certificate() {
let path = ca.path().ok_or_else(|| {
tls_core::unsupported(
described,
"a certificate authority from PEM bytes",
"`async-nats` opens the file itself; name it with \
`with_ca_certificate_file`",
)
})?;
options = options.add_root_certificates(path.to_path_buf());
}
if let Some(client) = tls.client_certificate() {
let (certificate, key) = match (client.certificate().path(), client.key().path()) {
(Some(certificate), Some(key)) => (certificate, key),
_ => {
return Err(tls_core::unsupported(
described,
"a client certificate from PEM bytes",
"`async-nats` opens the files itself; name them with \
`with_client_certificate_files`",
))
}
};
options = options.add_client_certificate(certificate.to_path_buf(), key.to_path_buf());
}
if !tls.is_empty() {
options = options.require_tls(true);
}
Ok(options)
}
impl std::fmt::Debug for Nats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Nats")
.field("server", &self.server)
.field("bucket", &self.bucket)
.field("keys", &self.keys)
.field("format", &self.format)
.finish_non_exhaustive()
}
}
fn agreed(keys: &Keys) -> (Option<Format>, Option<String>) {
match documents::agreed_format(keys.named()) {
Ok(format) => (format, None),
Err(complaint) => (None, Some(complaint)),
}
}
fn redacted(servers: &str) -> String {
dynamic_config_store_core::redacted_list(servers, LoneAuthority::Secret)
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use super::*;
fn scripted(reply: &'static str) -> (String, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = format!("nats://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let info = r#"{"server_id":"scripted","server_name":"scripted","version":"2.10.0","proto":1,"go":"","host":"127.0.0.1","port":4222,"headers":true,"max_payload":1048576}"#;
let _ = stream.write_all(format!("INFO {info}\r\n").as_bytes());
let mut seen = Vec::new();
let mut byte = [0u8; 1];
while !seen.ends_with(b"PING\r\n") && stream.read(&mut byte).is_ok_and(|n| n == 1) {
seen.push(byte[0]);
}
let _ = stream.write_all(reply.as_bytes());
});
(address, server)
}
#[tokio::test]
async fn a_refused_credential_is_an_auth_failure() {
let (address, server) = scripted("-ERR 'Authorization Violation'\r\n");
let error = Nats::with_options(
&address,
"config",
"db.json",
ConnectOptions::new().token("hunter2-nats-token".to_owned()),
)
.await
.expect_err("the server refused the token");
let _ = server.join();
assert_eq!(error.kind(), dynamic_config::ErrorKind::Auth);
assert!(
!error.to_string().contains("hunter2"),
"a refused credential must not be echoed back: {error}"
);
}
#[test]
fn a_credential_in_the_url_never_reaches_an_error_message() {
assert_eq!(
redacted("nats://hunter2-token@nats.internal:4222"),
"nats://***@nats.internal:4222"
);
assert_eq!(
redacted("nats://app:hunter2@nats.internal:4222"),
"nats://app:***@nats.internal:4222"
);
assert_eq!(
redacted("nats://app:p@ss@w@rd@nats.internal:4222"),
"nats://app:***@nats.internal:4222"
);
assert_eq!(
redacted("nats://hunter2@a:4222,nats://hunter2@b:4222"),
"nats://***@a:4222,nats://***@b:4222"
);
assert_eq!(
redacted("nats://nats.internal:4222"),
"nats://nats.internal:4222"
);
assert_eq!(redacted("not a url"), "not a url");
}
#[tokio::test]
async fn a_credential_in_the_url_never_reaches_a_failed_connection() {
let error = Nats::new("nats://hunter2-token@127.0.0.1:9", "config", "db.json")
.await
.expect_err("nothing is listening");
let printed = format!("{error} {error:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("127.0.0.1:9"), "{printed}");
}
#[test]
fn keys_naming_two_formats_are_reported_rather_than_guessed() {
let (format, disagreement) = agreed(&Keys::several(["base.json", "local.toml"]));
assert_eq!(format, None);
let complaint = disagreement.expect("json and toml cannot both be it");
assert!(complaint.contains("base.json"), "{complaint}");
assert!(complaint.contains("local.toml"), "{complaint}");
assert!(complaint.contains("with_format"), "{complaint}");
assert_eq!(
agreed(&Keys::several(["base.json", "local.json"])),
(Some(Format::Json), None)
);
}
#[test]
fn a_diagnostic_names_the_whole_set_and_one_key_reads_as_it_always_did() {
assert_eq!(Keys::one("db.json").describe(), "key db.json");
assert_eq!(
Keys::several(["base.json", "local.json"]).describe(),
"keys base.json, local.json"
);
}
#[tokio::test]
async fn an_unreachable_server_is_remote_rather_than_auth() {
let error = Nats::with_options(
"nats://127.0.0.1:9",
"config",
"db.json",
ConnectOptions::new()
.token("hunter2-nats-token".to_owned())
.retry_on_initial_connect()
.max_reconnects(Some(0)),
)
.await
.expect_err("nothing is listening");
assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote);
assert!(!error.to_string().contains("hunter2"), "{error}");
}
#[test]
fn a_certificate_authority_from_bytes_is_refused_and_says_what_to_use() {
let error = with_tls_options(
ConnectOptions::new(),
&TlsConfig::new().with_ca_certificate_pem("-----BEGIN CERTIFICATE-----\n"),
"nats nats://nats.internal:4222 key db.json",
)
.expect_err("async-nats takes paths");
assert!(error.to_string().contains("PEM bytes"), "{error}");
assert!(
error.to_string().contains("with_ca_certificate_file"),
"{error}"
);
assert!(
error.to_string().contains("refused rather than ignored"),
"{error}"
);
}
#[test]
fn a_client_certificate_from_bytes_is_refused_and_never_quotes_the_key() {
const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
let error = with_tls_options(
ConnectOptions::new(),
&TlsConfig::new().with_client_certificate_pem("cert", PLANTED),
"nats nats://nats.internal:4222 key db.json",
)
.expect_err("async-nats takes paths");
assert!(!error.to_string().contains(PLANTED), "{error}");
assert!(
error.to_string().contains("with_client_certificate_files"),
"{error}"
);
}
#[test]
fn the_file_spellings_are_accepted_and_turn_tls_on() {
with_tls_options(
ConnectOptions::new(),
&TlsConfig::new()
.with_ca_certificate_file("/etc/nats/ca.pem")
.with_client_certificate_files("/etc/nats/client.crt", "/etc/nats/client.key"),
"nats nats://nats.internal:4222 key db.json",
)
.expect("paths are what this client takes");
}
#[test]
fn an_empty_configuration_changes_nothing() {
with_tls_options(
ConnectOptions::new(),
&TlsConfig::new(),
"nats nats://nats.internal:4222 key db.json",
)
.expect("nothing was asked for");
}
#[test]
fn a_refusal_carries_the_redacted_server_and_not_the_token() {
let described = format!(
"nats {}",
redacted("nats://hunter2-token@nats.internal:4222")
);
let error = with_tls_options(
ConnectOptions::new(),
&TlsConfig::new().with_ca_certificate_pem("x"),
&described,
)
.expect_err("async-nats takes paths");
assert!(!error.to_string().contains("hunter2"), "{error}");
assert!(error.to_string().contains("nats.internal:4222"), "{error}");
}
}