1mod admin;
37mod audit;
38mod ca;
39mod cmp;
40mod cms_est;
41mod coap;
42mod db;
43mod est;
44mod hsm;
45mod otp;
46mod server;
47mod star;
48mod tls;
49
50pub use self::admin::*;
51pub use self::audit::*;
52pub use self::ca::*;
53pub use self::cmp::*;
54pub use self::cms_est::*;
55pub use self::coap::*;
56pub use self::db::*;
57pub use self::est::*;
58pub use self::hsm::*;
59pub use self::otp::*;
60pub use self::server::*;
61pub use self::star::*;
62pub use self::tls::*;
63
64pub use crate::ocsp::OcspConfig;
66
67use serde::Deserialize;
68
69#[derive(Debug, Clone, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct Config {
75 #[serde(default)]
77 pub server: ServerConfig,
78
79 #[serde(default)]
81 pub tls: TlsConfig,
82
83 #[serde(default)]
85 pub database: DbConfig,
86
87 #[serde(rename = "ca", deserialize_with = "deserialize_ca_array")]
91 pub cas: Vec<CaConfig>,
92
93 #[serde(default)]
95 pub est: EstConfig,
96
97 #[serde(default)]
99 pub hsm: Option<HsmConfig>,
100
101 #[serde(default)]
103 pub otp: OtpConfig,
104
105 #[serde(default)]
107 pub admin: Option<AdminConfig>,
108
109 #[serde(default)]
111 pub audit: AuditConfig,
112
113 #[serde(default)]
115 pub coap: Option<CoapConfig>,
116
117 #[serde(default)]
119 pub cms_est: Option<CmsEstConfig>,
120
121 #[serde(default)]
123 pub cmp: Option<CmpConfig>,
124
125 #[serde(default)]
127 pub star: Option<StarConfig>,
128
129 #[serde(default)]
132 pub ocsp: OcspConfig,
133}
134
135impl Config {
136 pub fn from_file(path: &str) -> Result<Self, String> {
141 let content = std::fs::read_to_string(path)
142 .map_err(|e| format!("cannot read config file '{path}': {e}"))?;
143 let config: Self =
144 toml::from_str(&content).map_err(|e| format!("config parse error: {e}"))?;
145 config.validate()?;
146 Ok(config)
147 }
148
149 pub fn validate(&self) -> Result<(), String> {
153 if self.cas.is_empty() {
155 return Err("at least one [ca] or [[ca]] entry is required".into());
156 }
157
158 for ca in &self.cas {
160 if ca.id.is_empty() {
161 return Err("each [[ca]] entry must have a non-empty `id` field".into());
162 }
163 if !is_valid_ca_id(&ca.id) {
164 return Err(format!(
165 "CA id {:?} must match ^[a-z0-9][a-z0-9_-]*$ (max 64 chars)",
166 ca.id
167 ));
168 }
169 }
170
171 let mut seen_ids = std::collections::HashSet::new();
173 for ca in &self.cas {
174 if !seen_ids.insert(ca.id.as_str()) {
175 return Err(format!("duplicate CA id {:?}", ca.id));
176 }
177 }
178
179 if self.cas.len() > 1 {
181 let default_count = self.cas.iter().filter(|c| c.is_default).count();
182 if default_count == 0 {
183 return Err(
184 "with multiple [[ca]] entries, exactly one must have `is_default = true`"
185 .into(),
186 );
187 }
188 if default_count > 1 {
189 return Err("at most one [[ca]] entry may have `is_default = true`".into());
190 }
191 }
192
193 self.tls.validate()?;
195
196 if self.tls.enabled && self.server.is_unix_socket() {
198 return Err("TLS cannot be used with a Unix domain socket listener".into());
199 }
200
201 let known_ca_ids: std::collections::HashSet<&str> =
203 self.cas.iter().map(|c| c.id.as_str()).collect();
204
205 for label in &self.est.labels {
206 if let Some(ref ca_id) = label.ca_id
207 && !known_ca_ids.contains(ca_id.as_str())
208 {
209 return Err(format!(
210 "EST label {:?} references unknown CA id {ca_id:?}",
211 label.name
212 ));
213 }
214 }
215
216 self.otp.validate()?;
218
219 if let Some(ref admin) = self.admin {
221 admin.validate()?;
222 }
223
224 self.audit.validate()?;
226
227 if let Some(ref coap) = self.coap {
229 coap.validate()?;
230 }
231
232 if let Some(ref cms_est) = self.cms_est {
234 cms_est.validate()?;
235 }
236
237 if let Some(ref cmp) = self.cmp {
239 let _ = cmp; }
241
242 if let Some(ref star) = self.star {
244 star.validate()?;
245 }
246
247 if self.tls.enabled {
252 check_file_exists("[tls].cert_file", &self.tls.cert_file)?;
253 check_file_exists("[tls].key_file", &self.tls.key_file)?;
254 if !self.tls.ca_file.is_empty() {
255 check_file_exists("[tls].ca_file", &self.tls.ca_file)?;
256 }
257 }
258
259 Ok(())
260 }
261
262 pub fn default_ca(&self) -> &CaConfig {
270 if self.cas.len() == 1 {
271 return &self.cas[0];
272 }
273 self.cas
274 .iter()
275 .find(|c| c.is_default)
276 .expect("validate() ensures exactly one default CA")
277 }
278}
279
280fn is_valid_ca_id(id: &str) -> bool {
287 if id.is_empty() || id.len() > 64 {
288 return false;
289 }
290 let mut chars = id.chars();
291 match chars.next() {
292 None => return false,
293 Some(c) if !c.is_ascii_lowercase() && !c.is_ascii_digit() => return false,
294 _ => {}
295 }
296 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
297}
298
299fn check_file_exists(field: &str, path: &str) -> Result<(), String> {
301 if !path.is_empty() && !std::path::Path::new(path).exists() {
302 return Err(format!("{field} path {path:?} does not exist"));
303 }
304 Ok(())
305}
306
307fn deserialize_ca_array<'de, D>(deserializer: D) -> Result<Vec<CaConfig>, D::Error>
313where
314 D: serde::Deserializer<'de>,
315{
316 use serde::de::{MapAccess, SeqAccess, Visitor};
317 use std::fmt;
318
319 struct CaArrayVisitor;
320
321 impl<'de> Visitor<'de> for CaArrayVisitor {
322 type Value = Vec<CaConfig>;
323
324 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325 f.write_str("a [ca] table or [[ca]] array of tables")
326 }
327
328 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<CaConfig>, A::Error> {
329 let mut cas = Vec::new();
330 while let Some(ca) = seq.next_element::<CaConfig>()? {
331 cas.push(ca);
332 }
333 Ok(cas)
334 }
335
336 fn visit_map<M: MapAccess<'de>>(self, map: M) -> Result<Vec<CaConfig>, M::Error> {
337 let mut ca = CaConfig::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
338 if ca.id.is_empty() {
339 ca.id = "default".to_owned();
340 }
341 ca.is_default = true;
342 Ok(vec![ca])
343 }
344 }
345
346 deserializer.deserialize_any(CaArrayVisitor)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 fn minimal_toml() -> &'static str {
354 r#"
355[database]
356url = "sqlite::memory:"
357
358[ca]
359key_file = "/tmp/ca.key"
360cert_file = "/tmp/ca.crt"
361"#
362 }
363
364 #[test]
365 fn parse_minimal_config() {
366 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
367 assert_eq!(cfg.database.url, "sqlite::memory:");
368 assert_eq!(cfg.cas.len(), 1);
369 assert_eq!(cfg.cas[0].id, "default");
370 assert!(cfg.cas[0].is_default);
371 }
372
373 #[test]
374 fn default_ca_returns_single_entry() {
375 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
376 assert_eq!(cfg.default_ca().id, "default");
377 }
378
379 #[test]
380 fn multi_ca_requires_default() {
381 let toml = r#"
382[database]
383url = "sqlite::memory:"
384[[ca]]
385id = "a"
386key_file = "/tmp/a.key"
387cert_file = "/tmp/a.crt"
388[[ca]]
389id = "b"
390key_file = "/tmp/b.key"
391cert_file = "/tmp/b.crt"
392"#;
393 let cfg: Config = toml::from_str(toml).unwrap();
394 let err = cfg.validate().unwrap_err();
395 assert!(err.contains("is_default"), "err: {err}");
396 }
397
398 #[test]
399 fn multi_ca_rejects_duplicate_id() {
400 let toml = r#"
401[database]
402url = "sqlite::memory:"
403[[ca]]
404id = "a"
405is_default = true
406key_file = "/tmp/a.key"
407cert_file = "/tmp/a.crt"
408[[ca]]
409id = "a"
410key_file = "/tmp/a2.key"
411cert_file = "/tmp/a2.crt"
412"#;
413 let cfg: Config = toml::from_str(toml).unwrap();
414 let err = cfg.validate().unwrap_err();
415 assert!(err.contains("duplicate"), "err: {err}");
416 }
417
418 #[test]
419 fn est_label_rejects_unknown_ca_id() {
420 let toml = r#"
421[database]
422url = "sqlite::memory:"
423[ca]
424key_file = "/tmp/ca.key"
425cert_file = "/tmp/ca.crt"
426[[est.label]]
427name = "devices"
428ca_id = "nonexistent"
429"#;
430 let cfg: Config = toml::from_str(toml).unwrap();
431 let err = cfg.validate().unwrap_err();
432 assert!(err.contains("nonexistent"), "err: {err}");
433 }
434
435 #[test]
436 fn invalid_ca_id_rejected() {
437 assert!(!is_valid_ca_id(""));
438 assert!(!is_valid_ca_id("Bad Id!"));
439 assert!(!is_valid_ca_id("_starts_with_underscore"));
440 assert!(is_valid_ca_id("good-id"));
441 assert!(is_valid_ca_id("a123_test-ca"));
442 }
443
444 #[test]
445 fn server_defaults_applied() {
446 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
447 assert_eq!(cfg.server.listen_addr, "0.0.0.0:8443");
448 assert_eq!(cfg.server.max_body_size, 65536);
449 }
450
451 #[test]
452 fn est_defaults_applied() {
453 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
454 assert!(cfg.est.simpleenroll);
455 assert!(cfg.est.simplereenroll);
456 assert!(!cfg.est.fullcmc);
457 assert!(!cfg.est.serverkeygen);
458 assert!(cfg.est.csrattrs);
459 }
460}