use std::{
borrow::Cow,
collections::{BTreeMap, HashSet},
path::{Path, PathBuf},
time::Duration,
};
use mercurio_core::qos::QoS;
use mercurio_server::{
server::{Limits, QueueLimits, Transport},
tls::{CertificateName, TlsConfig, TlsVersion},
};
use serde::{Deserialize, Serialize, Serializer};
#[derive(Debug, Default, Deserialize, Serialize)]
#[allow(dead_code)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub listeners: Vec<ListenerConfig>,
#[serde(default)]
pub bridges: Vec<BridgeConfig>,
#[serde(default)]
pub plugins: Vec<PluginConfig>,
pub admin: Option<AdminConfig>,
pub include_dir: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BridgeConfig {
pub name: String,
pub host: Option<String>,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub addresses: Vec<String>,
#[serde(default)]
pub protocol_version: BridgeProtocol,
#[serde(default = "default_true")]
pub notifications: bool,
pub client_id: Option<String>,
pub username: Option<String>,
#[serde(serialize_with = "redact_some")]
pub password: Option<String>,
#[serde(default)]
pub tls: BridgeTlsConfig,
pub topics: Vec<BridgeTopic>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct BridgeTlsConfig {
#[serde(default)]
pub enabled: bool,
pub ca_path: Option<String>,
pub cert_path: Option<String>,
pub key_path: Option<String>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BridgeProtocol {
#[default]
Mqttv5,
Mqttv311,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BridgeTopic {
pub pattern: String,
#[serde(default)]
pub direction: Direction,
#[serde(default)]
pub qos: u8,
#[serde(default)]
pub local_prefix: String,
#[serde(default)]
pub remote_prefix: String,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
#[default]
Out,
In,
Both,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ListenerConfig {
#[serde(default = "default_host")]
pub host: String,
pub port: Option<u16>,
pub path: Option<String>,
#[serde(default)]
pub protocol: Protocol,
#[serde(default)]
pub tls: TlsServerConfig,
#[serde(default)]
pub proxy_protocol: bool,
#[serde(default)]
pub proxy_from: Vec<String>,
pub mount_point: Option<String>,
pub max_connections: Option<usize>,
pub http_dir: Option<String>,
pub bind_interface: Option<String>,
pub auth: Option<AuthConfig>,
pub plugins: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
pub struct PluginConfig {
pub path: PathBuf,
pub name: Option<String>,
#[serde(default, serialize_with = "redact")]
pub config: BTreeMap<String, String>,
#[serde(default)]
pub allow_http: Vec<String>,
#[serde(default)]
pub allow_sockets: Vec<String>,
pub instances: Option<usize>,
pub timeout_ms: Option<u64>,
#[serde(default = "default_plugin_memory_mb")]
pub memory_mb: usize,
}
impl PluginConfig {
pub fn name(&self) -> Cow<'_, str> {
match &self.name {
Some(name) => name.into(),
None => self.path.file_stem().unwrap_or_default().to_string_lossy(),
}
}
}
const REDACTED: &str = "<redacted>";
fn redact<T, S: Serializer>(_: &T, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(REDACTED)
}
fn redact_some<S: Serializer>(value: &Option<String>, serializer: S) -> Result<S::Ok, S::Error> {
match value {
Some(_) => serializer.serialize_str(REDACTED),
None => serializer.serialize_none(),
}
}
fn redact_url<S: Serializer>(url: &str, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&redacted_url(url))
}
fn redact_http_url<S: Serializer>(url: &str, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&redacted_http_url(url))
}
fn redact_some_url<S: Serializer>(url: &Option<String>, serializer: S) -> Result<S::Ok, S::Error> {
match url {
Some(url) => redact_http_url(url, serializer),
None => serializer.serialize_none(),
}
}
pub fn redacted_url(url: &str) -> Cow<'_, str> {
const SECRET_PARAMS: [&str; 6] = ["password", "passwd", "pwd", "secret", "token", "apikey"];
let query = if url.contains("://") {
url.split_once('?').map_or("", |(_, query)| query)
} else {
url
};
let lower = query.to_ascii_lowercase().replace(['_', '-'], "");
if SECRET_PARAMS.iter().any(|p| lower.contains(p)) {
return REDACTED.into();
}
let Some((scheme, rest)) = url.split_once("://") else {
return url.into();
};
let (user_info, host_and_tail) = if matches!(scheme, "postgres" | "postgresql") {
let Some(split) = rest.split_once('@') else {
return url.into();
};
split
} else {
let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
let Some((user_info, host)) = authority.rsplit_once('@') else {
return url.into();
};
(user_info, &rest[authority.len() - host.len()..])
};
let Some((user, _)) = user_info.split_once(':') else {
return url.into();
};
format!("{scheme}://{user}:{REDACTED}@{host_and_tail}").into()
}
pub fn redacted_http_url(url: &str) -> Cow<'_, str> {
let Some((scheme, rest)) = url.split_once("://") else {
return REDACTED.into();
};
if rest.contains('@') {
return REDACTED.into();
}
match rest.find(['?', '#']) {
Some(at) => format!("{scheme}://{}?{REDACTED}", &rest[..at]).into(),
None => url.into(),
}
}
const BUILT_IN_PLUGINS: [&str; 6] = [
"dynamic-security",
"password-file",
"http-auth",
"acl-file",
"plain",
"credentials",
];
fn default_plugin_memory_mb() -> usize {
64
}
#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
#[default]
Mqtt,
Websocket,
}
impl From<Protocol> for Transport {
fn from(protocol: Protocol) -> Self {
match protocol {
Protocol::Mqtt => Transport::Mqtt,
Protocol::Websocket => Transport::WebSocket,
}
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(tag = "backend", rename_all = "lowercase")]
pub enum StorageConfig {
#[default]
Memory,
Sqlite { path: String },
Postgres {
#[serde(serialize_with = "redact_url")]
url: String,
ca_path: Option<PathBuf>,
#[serde(default)]
standby: bool,
},
}
impl Config {
pub fn from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let mut table = read_table(path)?;
if let Some(dir) = table.get("include_dir").and_then(|d| d.as_str()) {
let mut files: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| format!("Failed to read include_dir {dir}: {e}"))?
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
.collect();
files.sort();
for file in files {
merge(&mut table, read_table(&file)?);
}
}
let config = Config::deserialize(table)?;
config.check()?;
Ok(config)
}
}
impl Config {
fn check(&self) -> Result<(), String> {
for listener in &self.listeners {
if let Some(mount_point) = &listener.mount_point {
if !mount_point.ends_with('/') {
return Err(format!(
"mount_point `{mount_point}` must end with `/`, or its clients reach the topics of every mount point it is a prefix of"
));
}
}
}
let mut names = HashSet::new();
for plugin in &self.plugins {
let name = plugin.name();
if name.is_empty() || name.contains(['/', '+', '#']) {
return Err(format!(
"[[plugins]] name `{name}` must be a non-empty topic level, without `/`, `+` or `#`"
));
}
if BUILT_IN_PLUGINS.contains(&&*name) {
return Err(format!(
"[[plugins]] name `{name}` is taken by a built-in plugin; set a different `name`"
));
}
if !names.insert(name.clone()) {
return Err(format!(
"Two [[plugins]] are named `{name}`; give one a different `name`"
));
}
}
for name in self
.listeners
.iter()
.flat_map(|l| l.plugins.iter().flatten())
{
if !names.contains(name.as_str()) {
return Err(format!("No [[plugins]] entry is named `{name}`"));
}
}
Ok(())
}
}
fn read_table(path: &Path) -> Result<toml::Table, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
toml::from_str(&content).map_err(|e| format!("{}: {e}", path.display()).into())
}
fn merge(into: &mut toml::Table, from: toml::Table) {
for (key, value) in from {
match (into.get_mut(&key), value) {
(Some(toml::Value::Table(into)), toml::Value::Table(from)) => merge(into, from),
(Some(toml::Value::Array(into)), toml::Value::Array(from)) => into.extend(from),
(_, value) => {
into.insert(key, value);
}
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[allow(dead_code)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
pub bind_interface: Option<String>,
pub websocket_port: Option<u16>,
pub http_dir: Option<String>,
#[serde(default = "default_sys_interval")]
pub sys_interval: u64,
#[serde(default = "default_max_connections")]
pub max_connections: usize,
#[serde(default = "default_max_packet_size")]
pub max_packet_size: usize,
#[serde(default = "default_max_queued_messages")]
pub max_queued_messages: usize,
#[serde(default = "default_max_queued_qos0_messages")]
pub max_queued_qos0_messages: usize,
#[serde(default = "default_max_queued_bytes")]
pub max_queued_bytes: usize,
#[serde(default)]
pub upgrade_outgoing_qos: bool,
#[serde(default)]
pub check_retain_source: bool,
#[serde(default = "default_max_subscriptions")]
pub max_subscriptions: usize,
#[serde(default = "default_connect_timeout")]
pub connect_timeout: u64,
#[serde(default = "default_max_topic_alias")]
pub max_topic_alias: u16,
#[serde(default = "default_receive_maximum")]
pub receive_maximum: u16,
pub max_keepalive: Option<u16>,
pub response_information: Option<String>,
pub redirect: Option<RedirectConfig>,
#[serde(default)]
pub send_topic_aliases: bool,
#[serde(default = "default_max_qos")]
pub max_qos: u8,
#[serde(default = "default_retain_available")]
pub retain_available: bool,
#[serde(default = "default_allow_zero_length_clientid")]
pub allow_zero_length_clientid: bool,
#[serde(default)]
pub auto_id_prefix: String,
#[serde(default)]
pub clientid_prefixes: Vec<String>,
pub persistent_client_expiration: Option<u64>,
pub max_session_expiry: Option<u64>,
pub max_retained_messages: Option<usize>,
pub max_connections_per_ip: Option<usize>,
#[serde(default)]
pub tls: TlsServerConfig,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
bind_interface: None,
websocket_port: None,
http_dir: None,
sys_interval: default_sys_interval(),
max_connections: default_max_connections(),
max_packet_size: default_max_packet_size(),
max_queued_messages: default_max_queued_messages(),
max_queued_qos0_messages: default_max_queued_qos0_messages(),
max_queued_bytes: default_max_queued_bytes(),
upgrade_outgoing_qos: false,
check_retain_source: false,
max_subscriptions: default_max_subscriptions(),
connect_timeout: default_connect_timeout(),
max_topic_alias: default_max_topic_alias(),
receive_maximum: default_receive_maximum(),
max_keepalive: None,
response_information: None,
redirect: None,
send_topic_aliases: false,
max_qos: default_max_qos(),
retain_available: default_retain_available(),
allow_zero_length_clientid: default_allow_zero_length_clientid(),
auto_id_prefix: String::new(),
clientid_prefixes: Vec::new(),
persistent_client_expiration: None,
max_session_expiry: None,
max_retained_messages: None,
max_connections_per_ip: None,
tls: TlsServerConfig::default(),
}
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[allow(dead_code)]
pub struct TlsServerConfig {
#[serde(default)]
pub enabled: bool,
pub cert_path: Option<String>,
pub key_path: Option<String>,
pub ca_path: Option<String>,
pub crl_path: Option<String>,
#[serde(default)]
pub min_version: MinTlsVersion,
#[serde(default)]
pub identity_as_username: bool,
#[serde(default)]
pub subject_as_username: bool,
}
#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize)]
pub enum MinTlsVersion {
#[default]
#[serde(rename = "1.2")]
Tls12,
#[serde(rename = "1.3")]
Tls13,
}
impl TlsServerConfig {
pub fn build(&self) -> Result<Option<TlsConfig>, String> {
if !self.enabled {
return Ok(None);
}
let cert_path = self
.cert_path
.clone()
.ok_or("TLS enabled but cert_path not specified in configuration")?;
let key_path = self
.key_path
.clone()
.ok_or("TLS enabled but key_path not specified in configuration")?;
let mut tls = TlsConfig::new(cert_path, key_path);
if let Some(ca_path) = &self.ca_path {
tls = tls.with_client_auth(ca_path.clone());
}
tls.crl_path = self.crl_path.clone();
tls.min_version = match self.min_version {
MinTlsVersion::Tls12 => TlsVersion::Tls12,
MinTlsVersion::Tls13 => TlsVersion::Tls13,
};
tls.username_from_certificate = match (self.identity_as_username, self.subject_as_username)
{
(false, false) => None,
(true, false) => Some(CertificateName::CommonName),
(false, true) => Some(CertificateName::Subject),
(true, true) => {
return Err("Set identity_as_username or subject_as_username, not both".to_string())
}
};
Ok(Some(tls))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RedirectConfig {
pub server_reference: String,
#[serde(default)]
pub permanent: bool,
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
1883
}
fn default_max_qos() -> u8 {
2
}
fn default_retain_available() -> bool {
true
}
fn default_allow_zero_length_clientid() -> bool {
true
}
fn default_sys_interval() -> u64 {
10
}
fn default_max_connections() -> usize {
10000
}
fn default_max_packet_size() -> usize {
Limits::default().max_packet_size
}
fn default_max_queued_messages() -> usize {
Limits::default().queue.max_messages
}
fn default_max_queued_qos0_messages() -> usize {
Limits::default().queue.max_qos0
}
fn default_max_queued_bytes() -> usize {
QueueLimits::default().max_bytes.unwrap_or(usize::MAX)
}
fn default_max_subscriptions() -> usize {
mercurio_server::server::DEFAULT_MAX_SUBSCRIPTIONS
}
fn default_connect_timeout() -> u64 {
Limits::default().connect_timeout.as_secs()
}
fn default_max_topic_alias() -> u16 {
Limits::default().topic_alias_maximum
}
fn default_receive_maximum() -> u16 {
Limits::default().receive_maximum
}
impl ServerConfig {
pub fn limits(&self) -> Result<Limits, String> {
let maximum_qos = QoS::from(self.max_qos);
if maximum_qos == QoS::Invalid {
return Err(format!("max_qos must be 0, 1 or 2, not {}", self.max_qos));
}
let mut limits = Limits::default();
limits.connect_timeout = Duration::from_secs(self.connect_timeout);
limits.max_packet_size = self.max_packet_size;
limits.queue.max_qos0 = self.max_queued_qos0_messages;
limits.queue.max_messages = self.max_queued_messages;
limits.queue.max_bytes = Some(self.max_queued_bytes);
limits.queue.upgrade_qos = self.upgrade_outgoing_qos;
limits.max_connections = Some(self.max_connections);
limits.topic_alias_maximum = self.max_topic_alias;
limits.receive_maximum = self.receive_maximum;
limits.max_keepalive = self.max_keepalive;
limits.send_topic_aliases = self.send_topic_aliases;
limits.maximum_qos = maximum_qos;
limits.retain_available = self.retain_available;
limits.persistent_client_expiration =
self.persistent_client_expiration.map(Duration::from_secs);
limits.check_retain_source = self.check_retain_source;
limits.max_subscriptions = Some(self.max_subscriptions);
limits.max_session_expiry = self.max_session_expiry.map(Duration::from_secs);
limits.max_retained_messages = self.max_retained_messages;
limits.max_connections_per_ip = self.max_connections_per_ip;
Ok(limits)
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
pub file: Option<String>,
#[serde(default)]
pub syslog: bool,
#[serde(default = "default_true")]
pub connection_messages: bool,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
file: None,
syslog: false,
connection_messages: true,
}
}
}
fn default_log_level() -> String {
"info".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AdminConfig {
pub socket: String,
pub group: String,
}
impl Default for AdminConfig {
fn default() -> Self {
Self {
socket: "/run/mercurio/admin.sock".to_string(),
group: "mercurio".to_string(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[allow(dead_code)]
pub struct AuthConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_allow_anonymous")]
pub allow_anonymous: bool,
pub password_file: Option<String>,
pub acl_file: Option<String>,
pub dynamic_security: Option<String>,
pub http: Option<HttpAuthConfig>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct HttpAuthConfig {
#[serde(serialize_with = "redact_some_url")]
pub user_url: Option<String>,
#[serde(serialize_with = "redact_some_url")]
pub acl_url: Option<String>,
#[serde(default = "default_http_timeout")]
pub timeout_seconds: u64,
#[serde(default = "default_http_cache")]
pub cache_seconds: u64,
}
fn default_http_timeout() -> u64 {
5
}
fn default_http_cache() -> u64 {
30
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
allow_anonymous: default_allow_anonymous(),
password_file: None,
acl_file: None,
dynamic_security: None,
http: None,
}
}
}
fn default_allow_anonymous() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::{redacted_http_url, redacted_url, AdminConfig, Config, Protocol};
#[test]
fn test_tls_acceptor_builds_with_every_dependency_linked() {
let fixtures = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mercurio-server/tests/fixtures"
);
let config: Config = toml::from_str(&format!(
r#"
[server.tls]
enabled = true
cert_path = "{fixtures}/cert.pem"
key_path = "{fixtures}/key.pem"
"#
))
.unwrap();
let tls = config.server.tls.build().unwrap().unwrap();
assert!(tls.build_acceptor().is_ok());
}
#[test]
fn test_include_dir_merges_files_in_order() {
let dir = std::env::temp_dir().join(format!("mercuriod-include-{}", std::process::id()));
let conf_d = dir.join("conf.d");
std::fs::create_dir_all(&conf_d).unwrap();
let main = dir.join("main.toml");
std::fs::write(
&main,
format!(
"include_dir = \"{}\"\n[server]\nport = 1\nmax_qos = 1\n[[listeners]]\nport = 2\n",
conf_d.display()
),
)
.unwrap();
std::fs::write(
conf_d.join("a.toml"),
"[server]\nport = 3\n[[listeners]]\nport = 4\n",
)
.unwrap();
std::fs::write(conf_d.join("b.toml"), "[server]\nport = 5\n").unwrap();
std::fs::write(conf_d.join("ignored.txt"), "not toml").unwrap();
let config = Config::from_file(&main).unwrap();
assert_eq!(config.server.port, 5);
assert_eq!(config.server.max_qos, 1);
let ports: Vec<_> = config.listeners.iter().map(|l| l.port).collect();
assert_eq!(ports, [Some(2), Some(4)]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_mount_points_must_end_a_level() {
let config = |mount_point: &str| -> Config {
toml::from_str(&format!(
"[[listeners]]\nport = 1884\nmount_point = \"{mount_point}\"\n"
))
.unwrap()
};
assert!(config("tenantA").check().is_err());
assert!(config("tenantA/").check().is_ok());
}
#[test]
fn test_parse_plugins() {
let config: Config = toml::from_str(
r#"
[[plugins]]
path = "/etc/mercurio/plugins/audit.wasm"
config = { webhook = "https://audit.example.com/mqtt" }
allow_http = ["audit.example.com"]
allow_sockets = ["10.0.0.5:5432"]
instances = 4
timeout_ms = 500
memory_mb = 32
[[plugins]]
path = "deny.wasm"
name = "deny"
[[listeners]]
port = 1884
plugins = ["deny"]
"#,
)
.unwrap();
assert_eq!(config.plugins.len(), 2);
assert_eq!(config.plugins[0].name(), "audit");
assert_eq!(config.plugins[0].instances, Some(4));
assert_eq!(config.plugins[1].name(), "deny");
assert_eq!(
config.listeners[0].plugins.as_deref(),
Some(&["deny".to_string()][..])
);
assert!(config.check().is_ok());
}
#[test]
fn test_plugin_config_values_are_strings() {
let error =
toml::from_str::<Config>("[[plugins]]\npath = \"a.wasm\"\nconfig = { retries = 3 }\n")
.unwrap_err()
.to_string();
assert!(error.contains("retries"), "{error}");
}
#[test]
fn test_plugin_names_must_be_unique_and_known() {
let config = |toml: &str| -> Config { toml::from_str(toml).unwrap() };
let duplicate =
config("[[plugins]]\npath = \"a/deny.wasm\"\n[[plugins]]\npath = \"b/deny.wasm\"\n");
assert!(duplicate.check().unwrap_err().contains("`deny`"));
let unknown = config("[[plugins]]\npath = \"deny.wasm\"\n[[listeners]]\nport = 1884\nplugins = [\"audit\"]\n");
assert!(unknown.check().unwrap_err().contains("`audit`"));
}
#[test]
fn test_plugin_names_must_be_usable_in_topics_and_not_built_in() {
let named = |name: &str| -> Config {
toml::from_str(&format!(
"[[plugins]]\npath = \"a.wasm\"\nname = \"{name}\"\n"
))
.unwrap()
};
for bad in [
"",
"a/b",
"a+",
"#",
"dynamic-security",
"acl-file",
"plain",
] {
let error = named(bad).check().unwrap_err();
assert!(error.contains(&format!("`{bad}`")), "{bad}: {error}");
}
assert!(named("audit").check().is_ok());
let file: Config = toml::from_str("[[plugins]]\npath = \"http-auth.wasm\"\n").unwrap();
assert!(file.check().is_err());
}
#[test]
fn test_parse_listeners() {
let config: Config = toml::from_str(
r#"
[[listeners]]
port = 8883
[listeners.tls]
enabled = true
cert_path = "cert.pem"
key_path = "key.pem"
[[listeners]]
host = "0.0.0.0"
port = 8080
protocol = "websocket"
"#,
)
.unwrap();
let [tls, websocket] = &config.listeners[..] else {
panic!("expected two listeners");
};
assert_eq!((tls.host.as_str(), tls.port), ("127.0.0.1", Some(8883)));
assert!(matches!(tls.protocol, Protocol::Mqtt));
assert!(tls.tls.build().unwrap().is_some());
assert_eq!(
(websocket.host.as_str(), websocket.port),
("0.0.0.0", Some(8080))
);
assert!(matches!(websocket.protocol, Protocol::Websocket));
assert!(websocket.tls.build().unwrap().is_none());
}
#[test]
fn test_admin_socket_is_on_by_default_and_can_be_turned_off() {
let config: Config = toml::from_str("").unwrap();
assert!(config.admin.is_none());
let admin = AdminConfig::default();
assert_eq!(
(admin.socket.as_str(), admin.group.as_str()),
("/run/mercurio/admin.sock", "mercurio")
);
let config: Config = toml::from_str("[admin]\ngroup = \"wheel\"\n").unwrap();
let admin = config.admin.unwrap();
assert_eq!(
(admin.socket.as_str(), admin.group.as_str()),
("/run/mercurio/admin.sock", "wheel")
);
let config: Config = toml::from_str("[admin]\nsocket = \"\"\n").unwrap();
assert!(config.admin.unwrap().socket.is_empty());
assert!(toml::from_str::<Config>("[admin]\nsockets = \"/x\"\n").is_err());
}
#[test]
fn test_shipped_config_parses() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/contrib/config.toml");
let config = Config::from_file(std::path::Path::new(path)).unwrap();
let admin = config.admin.expect("the shipped config has [admin]");
assert_eq!(admin.socket, "/run/mercurio/admin.sock");
assert_eq!(admin.group, "mercurio");
}
#[test]
fn test_serialized_config_hides_secrets() {
let config: Config = toml::from_str(
r#"
[server.tls]
enabled = true
cert_path = "cert.pem"
key_path = "key.pem"
[storage]
backend = "postgres"
url = "postgres://mercurio:hunter2@db.example.com/mercurio"
[auth.http]
user_url = "https://svc:pa55@auth.example.com/user"
acl_url = "https://auth.example.com/acl"
[[bridges]]
name = "cloud"
host = "cloud.example.com"
username = "edge"
password = "s3cret"
[[bridges.topics]]
pattern = "a/#"
[[plugins]]
path = "audit.wasm"
config = { token = "tok3n" }
"#,
)
.unwrap();
let json = serde_json::to_value(&config).unwrap();
let text = json.to_string();
for secret in ["hunter2", "pa55", "s3cret", "tok3n"] {
assert!(!text.contains(secret), "{secret} in {text}");
}
assert_eq!(
json["storage"]["url"],
"postgres://mercurio:<redacted>@db.example.com/mercurio"
);
assert_eq!(json["storage"]["backend"], "postgres");
assert_eq!(json["auth"]["http"]["user_url"], "<redacted>");
assert_eq!(
json["auth"]["http"]["acl_url"],
"https://auth.example.com/acl"
);
assert_eq!(json["bridges"][0]["password"], "<redacted>");
assert_eq!(json["bridges"][0]["username"], "edge");
assert_eq!(json["plugins"][0]["config"], "<redacted>");
assert_eq!(json["server"]["tls"]["key_path"], "key.pem");
assert_eq!(json["server"]["port"], 1883);
assert_eq!(json["logging"]["level"], "info");
let plain: Config = toml::from_str(
"[[bridges]]\nname = \"b\"\nhost = \"h\"\n[[bridges.topics]]\npattern = \"#\"\n",
)
.unwrap();
let json = serde_json::to_value(&plain).unwrap();
assert!(json["bridges"][0]["password"].is_null());
assert_eq!(json["storage"]["backend"], "memory");
}
#[test]
fn test_no_secret_bearing_field_is_shown() {
let config: Config = toml::from_str(
r#"
[storage]
backend = "postgres"
url = "host=db user=u password=SECRET-pg sslmode=require"
[auth]
enabled = true
[auth.http]
user_url = "https://svc:SECRET-user@auth.example.com/user?token=SECRET-tok"
acl_url = "https://auth.example.com/acl?api_key=SECRET-key"
[[listeners]]
port = 1884
[listeners.auth.http]
user_url = "https://SECRET-l1@auth.example.com/user"
acl_url = "https://h/acl?key=SECRET-l2"
[[bridges]]
name = "cloud"
host = "cloud.example.com"
password = "SECRET-bridge"
[[bridges.topics]]
pattern = "a/#"
[[plugins]]
path = "audit.wasm"
config = { token = "SECRET-plugin", other = "SECRET-other" }
"#,
)
.unwrap();
let text = serde_json::to_string(&config).unwrap();
assert!(!text.contains("SECRET-"), "{text}");
let url: Config = toml::from_str(
"[storage]\nbackend = \"postgres\"\nurl = \"postgres://u:SECRET-a@h/db?sslpassword=SECRET-b\"\n",
)
.unwrap();
assert!(!serde_json::to_string(&url).unwrap().contains("SECRET-"));
}
#[test]
fn test_http_urls_hide_user_info_and_queries() {
for url in [
"https://TOKEN@auth.example.com/user",
"https://svc:SECRET-x@auth.example.com/user",
"https://h/user?key=SECRET-x",
"https://h/user?sig=SECRET-x#SECRET-y",
"https://svc:SECRET-x/y@h/user",
] {
let shown = redacted_http_url(url);
assert!(
!shown.contains("TOKEN") && !shown.contains("SECRET-"),
"{shown}"
);
}
assert_eq!(
redacted_http_url("https://h/user?a=1"),
"https://h/user?<redacted>"
);
assert_eq!(
redacted_http_url("https://h:8080/acl"),
"https://h:8080/acl"
);
assert_eq!(redacted_http_url("not a url"), "<redacted>");
}
#[test]
fn test_url_passwords_are_redacted() {
assert_eq!(
redacted_url("postgres://u:p@h:5432/db"),
"postgres://u:<redacted>@h:5432/db"
);
assert_eq!(redacted_url("postgres://u@h/db"), "postgres://u@h/db");
for url in [
"postgres://u:pa/ss@h/db",
"postgres://u:p?ss@h/db",
"postgres://u:p#ss@h/db",
"postgresql://u:pa/ss@h/db",
] {
let shown = redacted_url(url);
assert!(!shown.contains("ss@") && !shown.contains("pa/"), "{shown}");
assert!(!shown.contains("p?") && !shown.contains("p#"), "{shown}");
assert!(shown.ends_with("@h/db"), "{shown}");
}
assert_eq!(redacted_url("https://h/path@x"), "https://h/path@x");
assert_eq!(redacted_url("postgres://h/db?password=p"), "<redacted>");
assert_eq!(redacted_url("postgres://h/db?sslPassword=p"), "<redacted>");
assert_eq!(redacted_url("https://h/a?api_key=k"), "<redacted>");
assert_eq!(redacted_url("host=h password=p"), "<redacted>");
assert_eq!(redacted_url("host=h PASSWORD=p"), "<redacted>");
assert_eq!(
redacted_url("https://auth.example.com/token"),
"https://auth.example.com/token"
);
assert_eq!(
redacted_url("https://auth.example.com/acl"),
"https://auth.example.com/acl"
);
}
}