Skip to main content

zerodds_webdds_bridge/daemon/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Configuration of `zerodds-webdds-bridged`.
5//!
6//! Read by the shared layered loader (`zerodds-config`): base file,
7//! `webdds-bridged.d/` drop-ins, includes, `${VAR}` substitution, the
8//! precedence defaults < environment < files < `--set`, the same
9//! scheme every `zerodds-*-bridged` daemon uses (`crates/coap-bridge`,
10//! `crates/websocket-bridge`).
11//!
12//! **Authorization granularity.** The bridge-security substrate
13//! (`zerodds_bridge_security::Acl`) is topic-shaped (one ACL entry per
14//! DDS topic name) — the right unit for a bridge that proxies exactly
15//! one topic per wire resource. DDS-WEB's object model is not
16//! topic-shaped: an `Application`/`DomainParticipant`/`Publisher`
17//! operation names no topic at all, and a `DataWriter`/`DataReader`
18//! reaches its topic only two levels down a path the client chose the
19//! names of. This daemon's ACL is keyed by **Application name**
20//! instead — Spec §7.3's own access-control model is scoped through
21//! the object tree rooted at `Application` ("access control per
22//! Principal"), so the Application is the natural access-controlled
23//! unit here, not the topic underneath it. `acl.<app>.read`/`.write`
24//! name the subjects allowed a `GET`/everything-else on that
25//! application's subtree.
26
27use std::collections::HashMap;
28use std::path::Path;
29
30pub use zerodds_config::ConfigError;
31use zerodds_config::read::Section;
32
33/// The base name of the daemon's file in the system configuration
34/// directory: `webdds-bridged.yaml`, `webdds-bridged.d/`.
35pub const CONFIG_STEM: &str = "webdds-bridged";
36
37/// Every key this daemon reads, as a dotted path; list-valued keys stop
38/// at the list (`types`, `interfaces`).
39pub 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/// Whether `path` is one of [`KEYS`].
62#[must_use]
63pub fn is_key(path: &str) -> bool {
64    KEYS.contains(&path)
65}
66
67/// A config-named type, from either source
68/// (`crate::types_registry::TypeRegistry::load_idl_file` /
69/// `load_xml_file`) — exactly one of `idl_file`/`xml_file` is set; the
70/// loader refuses an entry that names both or neither.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct TypeEntry {
73    /// The IDL scoped name (`Mod::Type`), also the DDS wire type name.
74    pub type_name: String,
75    /// Path to the `.idl` file it is declared in.
76    pub idl_file: Option<String>,
77    /// Path to the DDS-XML 1.0 `<types>`/`<dds>` document it is
78    /// declared in.
79    pub xml_file: Option<String>,
80}
81
82/// Parsed daemon config.
83#[derive(Debug, Clone, Default)]
84pub struct DaemonConfig {
85    /// `listen: <addr>` — HTTP bind address, serving REST + SOAP + WSDL
86    /// on the one port (dispatched by path prefix).
87    pub listen: String,
88    /// `domain: <id>` — DDS domain id.
89    pub domain: i32,
90    /// `log_level: <level>`.
91    pub log_level: String,
92    /// `max_body_bytes` — request body cap (`crate::http1::read_request`).
93    pub max_body_bytes: usize,
94    /// `tls.enabled` — if true, `tls_cert_file`+`tls_key_file` must be
95    /// set.
96    pub tls_enabled: bool,
97    /// `tls.cert_file` — PEM cert path.
98    pub tls_cert_file: String,
99    /// `tls.key_file` — PEM key path.
100    pub tls_key_file: String,
101    /// `tls.client_ca_file` — PEM CA bundle for mTLS client auth.
102    pub tls_client_ca_file: String,
103    /// `auth.mode` — `none|bearer|jwt|mtls`.
104    pub auth_mode: String,
105    /// `auth.bearer_token` — single-token form.
106    pub auth_bearer_token: Option<String>,
107    /// `auth.bearer_subject` — who is behind the bearer.
108    pub auth_bearer_subject: Option<String>,
109    /// `auth.jwt.*`.
110    pub auth_jwt: zerodds_bridge_security::JwtSettings,
111    /// `acl.<app>.{read,write}` — see the module doc.
112    pub app_acl: HashMap<String, (Vec<String>, Vec<String>)>,
113    /// `metrics.enabled` — toggles the Prometheus/catalog/healthz
114    /// endpoint.
115    pub metrics_enabled: bool,
116    /// Bind address for the admin endpoint. Empty with
117    /// `metrics_enabled=true` defaults to
118    /// [`crate::daemon::server::DEFAULT_METRICS_LISTEN`].
119    pub metrics_addr: String,
120    /// `types:` — every type this gateway can resolve from a
121    /// config-named IDL file.
122    pub types: Vec<TypeEntry>,
123    /// `interfaces:` — IDL files whose `interface`s are described in
124    /// the SOAP WSDL this gateway serves (`crate::wsdl_bridge`).
125    pub interfaces: Vec<String>,
126}
127
128impl DaemonConfig {
129    /// Default config for development / tests without a file.
130    #[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    /// Loads one file (and its sibling `<stem>.d/`), for tests and
154    /// tools. The daemon itself loads through
155    /// `zerodds_dcps::cli::Configured`.
156    ///
157    /// # Errors
158    /// Unreadable file, text outside the subset, or a refused value.
159    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    /// Parses a config from a YAML string, for tests.
169    ///
170    /// # Errors
171    /// Text outside the subset, or a refused value.
172    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    /// Reads this daemon's keys out of a merged tree, over
182    /// [`Self::default_for_dev`]. `None` means nothing was loaded.
183    ///
184    /// # Errors
185    /// [`ConfigError::Invalid`] naming the setting, its origin and what
186    /// it accepts.
187    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}