zerodds_webdds_bridge/daemon/
config.rs1use std::collections::HashMap;
28use std::path::Path;
29
30pub use zerodds_config::ConfigError;
31use zerodds_config::read::Section;
32
33pub const CONFIG_STEM: &str = "webdds-bridged";
36
37pub const KEYS: &[&str] = &[
40 "listen",
41 "domain",
42 "log_level",
43 "max_body_bytes",
44 "tls.enabled",
45 "tls.cert_file",
46 "tls.key_file",
47 "tls.client_ca_file",
48 "auth.mode",
49 "auth.bearer_token",
50 "auth.bearer_subject",
51 "auth.jwt.public_key",
52 "auth.jwt.issuer",
53 "auth.jwt.audience",
54 "auth.jwt.required_claim",
55 "metrics.enabled",
56 "metrics.listen",
57 "types",
58 "interfaces",
59];
60
61#[must_use]
63pub fn is_key(path: &str) -> bool {
64 KEYS.contains(&path)
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct TypeEntry {
73 pub type_name: String,
75 pub idl_file: Option<String>,
77 pub xml_file: Option<String>,
80}
81
82#[derive(Debug, Clone, Default)]
84pub struct DaemonConfig {
85 pub listen: String,
88 pub domain: i32,
90 pub log_level: String,
92 pub max_body_bytes: usize,
94 pub tls_enabled: bool,
97 pub tls_cert_file: String,
99 pub tls_key_file: String,
101 pub tls_client_ca_file: String,
103 pub auth_mode: String,
105 pub auth_bearer_token: Option<String>,
107 pub auth_bearer_subject: Option<String>,
109 pub auth_jwt: zerodds_bridge_security::JwtSettings,
111 pub app_acl: HashMap<String, (Vec<String>, Vec<String>)>,
113 pub metrics_enabled: bool,
116 pub metrics_addr: String,
120 pub types: Vec<TypeEntry>,
123 pub interfaces: Vec<String>,
126}
127
128impl DaemonConfig {
129 #[must_use]
131 pub fn default_for_dev() -> Self {
132 Self {
133 listen: "127.0.0.1:8080".to_string(),
134 domain: 0,
135 log_level: "info".to_string(),
136 max_body_bytes: crate::http1::DEFAULT_MAX_BODY,
137 tls_enabled: false,
138 tls_cert_file: String::new(),
139 tls_key_file: String::new(),
140 tls_client_ca_file: String::new(),
141 auth_mode: "none".to_string(),
142 auth_bearer_token: None,
143 auth_bearer_subject: None,
144 auth_jwt: zerodds_bridge_security::JwtSettings::default(),
145 app_acl: HashMap::new(),
146 metrics_enabled: false,
147 metrics_addr: String::new(),
148 types: Vec::new(),
149 interfaces: Vec::new(),
150 }
151 }
152
153 pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
160 let config = zerodds_config::Loader::new()
161 .base(path)
162 .require_base()
163 .drop_in_dir(path.with_extension("d"))
164 .load()?;
165 Self::from_config(Some(&config))
166 }
167
168 pub fn load_from_str(raw: &str) -> Result<Self, ConfigError> {
173 let mut config = zerodds_config::Config::default();
174 config.push(
175 zerodds_config::parse_str(raw, "<string>")?,
176 "<string>".to_string(),
177 )?;
178 Self::from_config(Some(&config))
179 }
180
181 pub fn from_config(config: Option<&zerodds_config::Config>) -> Result<Self, ConfigError> {
188 let root = Section::root(config.and_then(zerodds_config::Config::root))?;
189 let mut out = Self::default_for_dev();
190 if let Some(v) = root.string("listen")? {
191 out.listen = v;
192 }
193 if let Some(v) = root.parse::<i32>("domain", "a DDS domain id")? {
194 out.domain = v;
195 }
196 if let Some(v) = root.choice("log_level", &["trace", "debug", "info", "warn", "error"])? {
197 out.log_level = v;
198 }
199 if let Some(v) = root.parse::<i64>("max_body_bytes", "a byte count >= 1")? {
200 if v < 1 {
201 let origin = root
202 .node("max_body_bytes")
203 .map(zerodds_config::read::first_origin)
204 .unwrap_or(zerodds_config::Origin::Default);
205 return Err(ConfigError::Invalid {
206 setting: "max_body_bytes".to_string(),
207 origin,
208 message: format!("got `{v}`, expected an integer >= 1"),
209 });
210 }
211 out.max_body_bytes = v as usize;
212 }
213
214 let tls = root.section("tls")?;
215 tls.only(&["enabled", "cert_file", "key_file", "client_ca_file"])?;
216 if let Some(v) = tls.boolean("enabled")? {
217 out.tls_enabled = v;
218 }
219 if let Some(v) = tls.string("cert_file")? {
220 out.tls_cert_file = v;
221 }
222 if let Some(v) = tls.string("key_file")? {
223 out.tls_key_file = v;
224 }
225 if let Some(v) = tls.string("client_ca_file")? {
226 out.tls_client_ca_file = v;
227 }
228
229 let auth = root.section("auth")?;
230 auth.only(&["mode", "bearer_token", "bearer_subject", "jwt"])?;
231 if let Some(v) = auth.choice("mode", &["none", "bearer", "jwt", "mtls"])? {
232 out.auth_mode = v;
233 }
234 out.auth_bearer_token = auth.string("bearer_token")?;
235 out.auth_bearer_subject = auth.string("bearer_subject")?;
236 let jwt = auth.section("jwt")?;
237 jwt.only(&["public_key", "issuer", "audience", "required_claim"])?;
238 out.auth_jwt = zerodds_bridge_security::JwtSettings {
239 public_key: jwt.string("public_key")?.unwrap_or_default(),
240 issuer: jwt.string("issuer")?,
241 audience: jwt.string("audience")?,
242 required_claim: jwt.string("required_claim")?,
243 };
244
245 let metrics = root.section("metrics")?;
246 metrics.only(&["enabled", "listen"])?;
247 if let Some(v) = metrics.boolean("enabled")? {
248 out.metrics_enabled = v;
249 }
250 if let Some(v) = metrics.string("listen")? {
251 out.metrics_addr = v;
252 }
253
254 if let Some(zerodds_config::Node::Map(apps)) = root.node("acl") {
255 for (app_name, node) in apps {
256 let entry = Section::of(Some(node), format!("acl.{app_name}"))?;
257 entry.only(&["read", "write"])?;
258 let read = entry.strings("read")?.unwrap_or_default();
259 let write = entry.strings("write")?.unwrap_or_default();
260 out.app_acl.insert(app_name.clone(), (read, write));
261 }
262 }
263
264 for (path, item) in root.items("types")? {
265 let entry = Section::of(Some(item), &path)?;
266 entry.only(&["name", "idl_file", "xml_file"])?;
267 let name = entry.string("name")?.unwrap_or_default();
268 let idl_file = entry.string("idl_file")?.filter(|s| !s.is_empty());
269 let xml_file = entry.string("xml_file")?.filter(|s| !s.is_empty());
270 if name.is_empty() || (idl_file.is_none() && xml_file.is_none()) {
271 return Err(ConfigError::Invalid {
272 setting: path.clone(),
273 origin: zerodds_config::read::first_origin(item),
274 message: "a types entry needs name and exactly one of idl_file/xml_file"
275 .to_string(),
276 });
277 }
278 if idl_file.is_some() && xml_file.is_some() {
279 return Err(ConfigError::Invalid {
280 setting: path.clone(),
281 origin: zerodds_config::read::first_origin(item),
282 message: "a types entry names both idl_file and xml_file — exactly one"
283 .to_string(),
284 });
285 }
286 out.types.push(TypeEntry {
287 type_name: name,
288 idl_file,
289 xml_file,
290 });
291 }
292
293 out.interfaces = root.strings("interfaces")?.unwrap_or_default();
294
295 Ok(out)
296 }
297}
298
299#[cfg(test)]
300#[allow(clippy::unwrap_used, clippy::expect_used)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn default_config_has_no_types_or_acl() {
306 let c = DaemonConfig::default_for_dev();
307 assert!(c.types.is_empty());
308 assert!(c.app_acl.is_empty());
309 assert_eq!(c.listen, "127.0.0.1:8080");
310 }
311
312 #[test]
313 fn loads_listen_domain_and_types() {
314 let c = DaemonConfig::load_from_str(
315 "listen: \"0.0.0.0:9000\"\ndomain: 7\ntypes:\n - name: M::Sensor\n idl_file: /etc/zerodds/sensor.idl\n",
316 )
317 .expect("valid");
318 assert_eq!(c.listen, "0.0.0.0:9000");
319 assert_eq!(c.domain, 7);
320 assert_eq!(c.types.len(), 1);
321 assert_eq!(c.types[0].type_name, "M::Sensor");
322 assert_eq!(
323 c.types[0].idl_file,
324 Some("/etc/zerodds/sensor.idl".to_string())
325 );
326 assert_eq!(c.types[0].xml_file, None);
327 }
328
329 #[test]
330 fn loads_a_type_from_an_xml_file() {
331 let c = DaemonConfig::load_from_str(
332 "types:\n - name: M::Sensor\n xml_file: /etc/zerodds/sensor.xml\n",
333 )
334 .expect("valid");
335 assert_eq!(
336 c.types[0].xml_file,
337 Some("/etc/zerodds/sensor.xml".to_string())
338 );
339 assert_eq!(c.types[0].idl_file, None);
340 }
341
342 #[test]
343 fn a_types_entry_naming_both_idl_and_xml_is_refused() {
344 let err = DaemonConfig::load_from_str(
345 "types:\n - name: M::Sensor\n idl_file: /a.idl\n xml_file: /a.xml\n",
346 )
347 .unwrap_err();
348 assert!(matches!(err, ConfigError::Invalid { .. }));
349 }
350
351 #[test]
352 fn a_types_entry_naming_neither_idl_nor_xml_is_refused() {
353 let err = DaemonConfig::load_from_str("types:\n - name: M::Sensor\n").unwrap_err();
354 assert!(matches!(err, ConfigError::Invalid { .. }));
355 }
356
357 #[test]
358 fn loads_app_acl() {
359 let c = DaemonConfig::load_from_str(
360 "acl:\n app1:\n read: [\"alice\"]\n write: [\"alice\"]\n",
361 )
362 .expect("valid");
363 assert_eq!(
364 c.app_acl.get("app1"),
365 Some(&(vec!["alice".to_string()], vec!["alice".to_string()]))
366 );
367 }
368
369 #[test]
370 fn interfaces_list_loads() {
371 let c =
372 DaemonConfig::load_from_str("interfaces:\n - /etc/zerodds/calc.idl\n").expect("valid");
373 assert_eq!(c.interfaces, vec!["/etc/zerodds/calc.idl".to_string()]);
374 }
375}