use std::{
path::{Path, PathBuf},
time::Duration,
};
use mercurio_core::qos::QoS;
use mercurio_server::{
queue::QueueLimits,
server::{Limits, Transport},
tls::{CertificateName, TlsConfig, TlsVersion},
};
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[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>,
pub include_dir: Option<String>,
}
#[derive(Debug, Deserialize)]
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>,
pub password: Option<String>,
#[serde(default)]
pub tls: BridgeTlsConfig,
pub topics: Vec<BridgeTopic>,
}
#[derive(Debug, Default, Deserialize)]
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)]
#[serde(rename_all = "lowercase")]
pub enum BridgeProtocol {
#[default]
Mqttv5,
Mqttv311,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize)]
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)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
#[default]
Out,
In,
Both,
}
#[derive(Debug, Deserialize)]
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,
pub mount_point: Option<String>,
pub max_connections: Option<usize>,
pub http_dir: Option<String>,
pub bind_interface: Option<String>,
pub auth: Option<AuthConfig>,
}
#[derive(Debug, Default, Clone, Copy, Deserialize)]
#[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)]
#[serde(tag = "backend", rename_all = "lowercase")]
pub enum StorageConfig {
#[default]
Memory,
Sqlite { path: String },
Postgres {
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)?);
}
}
Ok(Config::deserialize(table)?)
}
}
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)]
#[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,
pub max_queued_bytes: Option<usize>,
#[serde(default)]
pub upgrade_outgoing_qos: bool,
#[serde(default)]
pub check_retain_source: bool,
#[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>,
#[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: None,
upgrade_outgoing_qos: false,
check_retain_source: false,
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,
tls: TlsServerConfig::default(),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[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)]
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)]
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_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));
}
Ok(Limits {
connect_timeout: Duration::from_secs(self.connect_timeout),
max_packet_size: self.max_packet_size,
queue: QueueLimits {
max_qos0: self.max_queued_qos0_messages,
max_messages: self.max_queued_messages,
max_bytes: self.max_queued_bytes,
upgrade_qos: self.upgrade_outgoing_qos,
},
max_connections: Some(self.max_connections),
topic_alias_maximum: self.max_topic_alias,
receive_maximum: self.receive_maximum,
max_keepalive: self.max_keepalive,
send_topic_aliases: self.send_topic_aliases,
maximum_qos,
retain_available: self.retain_available,
persistent_client_expiration: self
.persistent_client_expiration
.map(Duration::from_secs),
check_retain_source: self.check_retain_source,
})
}
}
#[derive(Debug, Deserialize)]
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, Deserialize)]
#[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)]
pub struct HttpAuthConfig {
pub user_url: Option<String>,
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 {
true
}
#[cfg(test)]
mod tests {
use super::{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_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());
}
}