use std::collections::HashMap;
use std::path::Path;
pub use zerodds_config::ConfigError;
use zerodds_config::read::Section;
pub const CONFIG_STEM: &str = "webdds-bridged";
pub const KEYS: &[&str] = &[
"listen",
"domain",
"log_level",
"max_body_bytes",
"tls.enabled",
"tls.cert_file",
"tls.key_file",
"tls.client_ca_file",
"auth.mode",
"auth.bearer_token",
"auth.bearer_subject",
"auth.jwt.public_key",
"auth.jwt.issuer",
"auth.jwt.audience",
"auth.jwt.required_claim",
"metrics.enabled",
"metrics.listen",
"types",
"interfaces",
];
#[must_use]
pub fn is_key(path: &str) -> bool {
KEYS.contains(&path)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TypeEntry {
pub type_name: String,
pub idl_file: Option<String>,
pub xml_file: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct DaemonConfig {
pub listen: String,
pub domain: i32,
pub log_level: String,
pub max_body_bytes: usize,
pub tls_enabled: bool,
pub tls_cert_file: String,
pub tls_key_file: String,
pub tls_client_ca_file: String,
pub auth_mode: String,
pub auth_bearer_token: Option<String>,
pub auth_bearer_subject: Option<String>,
pub auth_jwt: zerodds_bridge_security::JwtSettings,
pub app_acl: HashMap<String, (Vec<String>, Vec<String>)>,
pub metrics_enabled: bool,
pub metrics_addr: String,
pub types: Vec<TypeEntry>,
pub interfaces: Vec<String>,
}
impl DaemonConfig {
#[must_use]
pub fn default_for_dev() -> Self {
Self {
listen: "127.0.0.1:8080".to_string(),
domain: 0,
log_level: "info".to_string(),
max_body_bytes: crate::http1::DEFAULT_MAX_BODY,
tls_enabled: false,
tls_cert_file: String::new(),
tls_key_file: String::new(),
tls_client_ca_file: String::new(),
auth_mode: "none".to_string(),
auth_bearer_token: None,
auth_bearer_subject: None,
auth_jwt: zerodds_bridge_security::JwtSettings::default(),
app_acl: HashMap::new(),
metrics_enabled: false,
metrics_addr: String::new(),
types: Vec::new(),
interfaces: Vec::new(),
}
}
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
let config = zerodds_config::Loader::new()
.base(path)
.require_base()
.drop_in_dir(path.with_extension("d"))
.load()?;
Self::from_config(Some(&config))
}
pub fn load_from_str(raw: &str) -> Result<Self, ConfigError> {
let mut config = zerodds_config::Config::default();
config.push(
zerodds_config::parse_str(raw, "<string>")?,
"<string>".to_string(),
)?;
Self::from_config(Some(&config))
}
pub fn from_config(config: Option<&zerodds_config::Config>) -> Result<Self, ConfigError> {
let root = Section::root(config.and_then(zerodds_config::Config::root))?;
let mut out = Self::default_for_dev();
if let Some(v) = root.string("listen")? {
out.listen = v;
}
if let Some(v) = root.parse::<i32>("domain", "a DDS domain id")? {
out.domain = v;
}
if let Some(v) = root.choice("log_level", &["trace", "debug", "info", "warn", "error"])? {
out.log_level = v;
}
if let Some(v) = root.parse::<i64>("max_body_bytes", "a byte count >= 1")? {
if v < 1 {
let origin = root
.node("max_body_bytes")
.map(zerodds_config::read::first_origin)
.unwrap_or(zerodds_config::Origin::Default);
return Err(ConfigError::Invalid {
setting: "max_body_bytes".to_string(),
origin,
message: format!("got `{v}`, expected an integer >= 1"),
});
}
out.max_body_bytes = v as usize;
}
let tls = root.section("tls")?;
tls.only(&["enabled", "cert_file", "key_file", "client_ca_file"])?;
if let Some(v) = tls.boolean("enabled")? {
out.tls_enabled = v;
}
if let Some(v) = tls.string("cert_file")? {
out.tls_cert_file = v;
}
if let Some(v) = tls.string("key_file")? {
out.tls_key_file = v;
}
if let Some(v) = tls.string("client_ca_file")? {
out.tls_client_ca_file = v;
}
let auth = root.section("auth")?;
auth.only(&["mode", "bearer_token", "bearer_subject", "jwt"])?;
if let Some(v) = auth.choice("mode", &["none", "bearer", "jwt", "mtls"])? {
out.auth_mode = v;
}
out.auth_bearer_token = auth.string("bearer_token")?;
out.auth_bearer_subject = auth.string("bearer_subject")?;
let jwt = auth.section("jwt")?;
jwt.only(&["public_key", "issuer", "audience", "required_claim"])?;
out.auth_jwt = zerodds_bridge_security::JwtSettings {
public_key: jwt.string("public_key")?.unwrap_or_default(),
issuer: jwt.string("issuer")?,
audience: jwt.string("audience")?,
required_claim: jwt.string("required_claim")?,
};
let metrics = root.section("metrics")?;
metrics.only(&["enabled", "listen"])?;
if let Some(v) = metrics.boolean("enabled")? {
out.metrics_enabled = v;
}
if let Some(v) = metrics.string("listen")? {
out.metrics_addr = v;
}
if let Some(zerodds_config::Node::Map(apps)) = root.node("acl") {
for (app_name, node) in apps {
let entry = Section::of(Some(node), format!("acl.{app_name}"))?;
entry.only(&["read", "write"])?;
let read = entry.strings("read")?.unwrap_or_default();
let write = entry.strings("write")?.unwrap_or_default();
out.app_acl.insert(app_name.clone(), (read, write));
}
}
for (path, item) in root.items("types")? {
let entry = Section::of(Some(item), &path)?;
entry.only(&["name", "idl_file", "xml_file"])?;
let name = entry.string("name")?.unwrap_or_default();
let idl_file = entry.string("idl_file")?.filter(|s| !s.is_empty());
let xml_file = entry.string("xml_file")?.filter(|s| !s.is_empty());
if name.is_empty() || (idl_file.is_none() && xml_file.is_none()) {
return Err(ConfigError::Invalid {
setting: path.clone(),
origin: zerodds_config::read::first_origin(item),
message: "a types entry needs name and exactly one of idl_file/xml_file"
.to_string(),
});
}
if idl_file.is_some() && xml_file.is_some() {
return Err(ConfigError::Invalid {
setting: path.clone(),
origin: zerodds_config::read::first_origin(item),
message: "a types entry names both idl_file and xml_file — exactly one"
.to_string(),
});
}
out.types.push(TypeEntry {
type_name: name,
idl_file,
xml_file,
});
}
out.interfaces = root.strings("interfaces")?.unwrap_or_default();
Ok(out)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn default_config_has_no_types_or_acl() {
let c = DaemonConfig::default_for_dev();
assert!(c.types.is_empty());
assert!(c.app_acl.is_empty());
assert_eq!(c.listen, "127.0.0.1:8080");
}
#[test]
fn loads_listen_domain_and_types() {
let c = DaemonConfig::load_from_str(
"listen: \"0.0.0.0:9000\"\ndomain: 7\ntypes:\n - name: M::Sensor\n idl_file: /etc/zerodds/sensor.idl\n",
)
.expect("valid");
assert_eq!(c.listen, "0.0.0.0:9000");
assert_eq!(c.domain, 7);
assert_eq!(c.types.len(), 1);
assert_eq!(c.types[0].type_name, "M::Sensor");
assert_eq!(
c.types[0].idl_file,
Some("/etc/zerodds/sensor.idl".to_string())
);
assert_eq!(c.types[0].xml_file, None);
}
#[test]
fn loads_a_type_from_an_xml_file() {
let c = DaemonConfig::load_from_str(
"types:\n - name: M::Sensor\n xml_file: /etc/zerodds/sensor.xml\n",
)
.expect("valid");
assert_eq!(
c.types[0].xml_file,
Some("/etc/zerodds/sensor.xml".to_string())
);
assert_eq!(c.types[0].idl_file, None);
}
#[test]
fn a_types_entry_naming_both_idl_and_xml_is_refused() {
let err = DaemonConfig::load_from_str(
"types:\n - name: M::Sensor\n idl_file: /a.idl\n xml_file: /a.xml\n",
)
.unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn a_types_entry_naming_neither_idl_nor_xml_is_refused() {
let err = DaemonConfig::load_from_str("types:\n - name: M::Sensor\n").unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn loads_app_acl() {
let c = DaemonConfig::load_from_str(
"acl:\n app1:\n read: [\"alice\"]\n write: [\"alice\"]\n",
)
.expect("valid");
assert_eq!(
c.app_acl.get("app1"),
Some(&(vec!["alice".to_string()], vec!["alice".to_string()]))
);
}
#[test]
fn interfaces_list_loads() {
let c =
DaemonConfig::load_from_str("interfaces:\n - /etc/zerodds/calc.idl\n").expect("valid");
assert_eq!(c.interfaces, vec!["/etc/zerodds/calc.idl".to_string()]);
}
}