use std::sync::{Arc, OnceLock, RwLock};
use mercurio_server::{
auth::{ScramCredentialStore, StoredCredentials},
plugin::{Access, BoxFuture, ClientInfo, Credentials, Decision, Plugin, PluginStatus},
tls::Tls,
};
use serde::Serialize;
use tracing::{error, info};
type Loader<T> = fn(&str) -> Result<T, String>;
pub struct Reloadable<T> {
path: String,
load: Loader<T>,
current: RwLock<Arc<T>>,
name: OnceLock<String>,
}
impl<T> Reloadable<T> {
pub fn load(path: &str, load: Loader<T>) -> Result<Self, String> {
Ok(Self {
path: path.to_string(),
load,
current: RwLock::new(Arc::new(load(path)?)),
name: OnceLock::new(),
})
}
pub fn reload(&self) -> Result<(), String> {
let value = (self.load)(&self.path)?;
*self.current.write().unwrap() = Arc::new(value);
Ok(())
}
pub fn path(&self) -> &str {
&self.path
}
fn get(&self) -> Arc<T> {
Arc::clone(&self.current.read().unwrap())
}
}
pub trait Reload: Send + Sync {
fn reload(&self) -> Result<(), String>;
fn path(&self) -> &str;
fn item(&self) -> &str;
}
impl<T: Plugin> Reload for Reloadable<T> {
fn reload(&self) -> Result<(), String> {
Reloadable::reload(self)
}
fn path(&self) -> &str {
Reloadable::path(self)
}
fn item(&self) -> &str {
Plugin::name(self)
}
}
impl Reload for Tls {
fn reload(&self) -> Result<(), String> {
Tls::reload(self).map_err(|e| e.to_string())
}
fn path(&self) -> &str {
self.files().map_or("", |files| &files.cert_path)
}
fn item(&self) -> &str {
"tls"
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Reloaded {
pub item: String,
pub path: String,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn reload_all(files: &[Arc<dyn Reload>]) -> Vec<Reloaded> {
static RELOADING: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _running = RELOADING.lock().unwrap_or_else(|e| e.into_inner());
files
.iter()
.map(|file| {
let failure = file.reload().err();
match &failure {
None => info!("Reloaded {}", file.path()),
Some(e) => error!("Keeping the previous {}: {}", file.path(), e),
}
Reloaded {
item: file.item().to_string(),
path: file.path().to_string(),
ok: failure.is_none(),
error: failure,
}
})
.collect()
}
impl<T: Plugin> Plugin for Reloadable<T> {
fn name(&self) -> &str {
self.name.get_or_init(|| self.get().name().to_string())
}
fn status(&self) -> PluginStatus {
self.get().status()
}
fn authenticate<'a>(
&'a self,
client: &'a ClientInfo,
credentials: &'a Credentials<'a>,
) -> BoxFuture<'a, Decision> {
let current = self.get();
Box::pin(async move { current.authenticate(client, credentials).await })
}
fn authorize<'a>(
&'a self,
client: &'a ClientInfo,
topic: &'a str,
access: Access,
) -> BoxFuture<'a, Decision> {
let current = self.get();
Box::pin(async move { current.authorize(client, topic, access).await })
}
fn authorize_control<'a>(
&'a self,
client: &'a ClientInfo,
topic: &'a str,
) -> BoxFuture<'a, Decision> {
let current = self.get();
Box::pin(async move { current.authorize_control(client, topic).await })
}
}
impl<T: ScramCredentialStore> ScramCredentialStore for Reloadable<T> {
fn scram_credentials<'a>(
&'a self,
client: &'a ClientInfo,
username: &'a str,
) -> BoxFuture<'a, Option<StoredCredentials>> {
let current = self.get();
Box::pin(async move { current.scram_credentials(client, username).await })
}
}
#[cfg(test)]
mod tests {
use mercurio_server::{acl::AclFile, plugin::Plugin};
use super::Reloadable;
fn load(path: &str) -> Result<String, String> {
let contents = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
if contents.contains("bad") {
return Err("bad contents".to_string());
}
Ok(contents)
}
#[test]
fn test_reload_swaps_value_and_keeps_it_on_error() {
let path = std::env::temp_dir().join(format!("mercuriod-reload-{}", std::process::id()));
let path_str = path.to_str().unwrap();
std::fs::write(&path, "one").unwrap();
let value = Reloadable::load(path_str, load).unwrap();
assert_eq!(*value.get(), "one");
std::fs::write(&path, "two").unwrap();
value.reload().unwrap();
assert_eq!(*value.get(), "two");
std::fs::write(&path, "bad").unwrap();
assert!(value.reload().is_err());
assert_eq!(*value.get(), "two");
std::fs::remove_file(&path).unwrap();
assert!(value.reload().is_err());
assert_eq!(*value.get(), "two");
}
#[test]
fn test_tls_reload_keeps_the_previous_files_on_error() {
use mercurio_server::tls::{Tls, TlsConfig};
let fixtures = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mercurio-server/tests/fixtures"
);
let dir = std::env::temp_dir().join(format!("mercuriod-reload-tls-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (cert, key) = (dir.join("cert.pem"), dir.join("key.pem"));
std::fs::copy(format!("{fixtures}/cert.pem"), &cert).unwrap();
std::fs::copy(format!("{fixtures}/key.pem"), &key).unwrap();
let tls = Tls::new(TlsConfig::new(
cert.to_str().unwrap(),
key.to_str().unwrap(),
))
.unwrap();
let files: Vec<std::sync::Arc<dyn super::Reload>> = vec![std::sync::Arc::new(tls)];
let [ok] = &super::reload_all(&files)[..] else {
panic!("one file to reload");
};
assert!(ok.ok);
assert_eq!(
(ok.item.as_str(), ok.path.as_str()),
("tls", cert.to_str().unwrap())
);
std::fs::copy(format!("{fixtures}/pki/server.key"), &key).unwrap();
let [mismatched] = &super::reload_all(&files)[..] else {
panic!("one file to reload");
};
assert!(!mismatched.ok);
assert!(mismatched.error.is_some());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn test_plugins_keep_their_own_name() {
let path =
std::env::temp_dir().join(format!("mercuriod-reload-acl-{}", std::process::id()));
std::fs::write(&path, "topic read public/#").unwrap();
let acl = Reloadable::load(path.to_str().unwrap(), |path| {
AclFile::parse(&std::fs::read_to_string(path).unwrap())
})
.unwrap();
assert_eq!(Plugin::name(&acl), "acl-file");
std::fs::remove_file(&path).unwrap();
}
#[test]
fn test_plugins_report_the_status_of_what_they_hold() {
use mercurio_server::plugin::{PluginKind, PluginStatus};
let path =
std::env::temp_dir().join(format!("mercuriod-reload-status-{}", std::process::id()));
std::fs::write(&path, "topic read public/#").unwrap();
let acl = Reloadable::load(path.to_str().unwrap(), |path| {
AclFile::parse(&std::fs::read_to_string(path).unwrap())
})
.unwrap();
assert_eq!(
Plugin::status(&acl),
PluginStatus::new("acl-file", PluginKind::Builtin)
.with_hooks(&["authorize", "authorize_control"])
);
std::fs::remove_file(&path).unwrap();
}
}