1mod admin;
37pub mod audit;
38mod ca;
39mod cmp;
40mod cms_est;
41mod coap;
42mod db;
43mod est;
44mod ha;
45mod hsm;
46mod lockout;
47mod otp;
48pub mod secret;
49mod server;
50mod star;
51mod tls;
52
53pub use self::admin::*;
54pub use self::audit::*;
55pub use self::ca::*;
56pub use self::cmp::*;
57pub use self::cms_est::*;
58pub use self::coap::*;
59pub use self::db::*;
60pub use self::est::*;
61pub use self::ha::*;
62pub use self::hsm::*;
63pub use self::lockout::*;
64pub use self::otp::*;
65pub use self::secret::{ResolvedSecrets, SecretRef, SecretResolver};
66pub use self::server::*;
67pub use self::star::*;
68pub use self::tls::*;
69
70pub use crate::ocsp::OcspConfig;
72
73use serde::Deserialize;
74
75#[derive(Debug, Clone, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct Config {
81 #[serde(default)]
83 pub server: ServerConfig,
84
85 #[serde(default)]
87 pub tls: TlsConfig,
88
89 #[serde(default)]
91 pub database: DbConfig,
92
93 #[serde(rename = "ca", deserialize_with = "deserialize_ca_array")]
97 pub cas: Vec<CaConfig>,
98
99 #[serde(default)]
101 pub est: EstConfig,
102
103 #[serde(default)]
105 pub hsm: Option<HsmConfig>,
106
107 #[serde(default)]
109 pub otp: OtpConfig,
110
111 #[serde(default)]
113 pub admin: Option<AdminConfig>,
114
115 #[serde(default)]
117 pub audit: AuditConfig,
118
119 #[serde(default)]
121 pub auth_lockout: LockoutConfig,
122
123 #[serde(default)]
125 pub coap: Option<CoapConfig>,
126
127 #[serde(default)]
129 pub cms_est: Option<CmsEstConfig>,
130
131 #[serde(default)]
133 pub cmp: Option<CmpConfig>,
134
135 #[serde(default)]
141 pub dogtag: Option<kipuka_dogtag::DogtagConfig>,
142
143 #[serde(default)]
145 pub star: Option<StarConfig>,
146
147 #[serde(default)]
148 pub ha: Option<HaConfig>,
149
150 #[serde(default)]
153 pub ocsp: OcspConfig,
154
155 #[serde(default = "default_crl_refresh_interval_secs")]
159 pub crl_refresh_interval_secs: u64,
160}
161
162fn default_crl_refresh_interval_secs() -> u64 {
163 3600
164}
165
166impl Config {
167 pub fn from_file(path: &str) -> Result<Self, String> {
172 let content = std::fs::read_to_string(path)
173 .map_err(|e| format!("cannot read config file '{path}': {e}"))?;
174 let config: Self =
175 toml::from_str(&content).map_err(|e| format!("config parse error: {e}"))?;
176 config.validate()?;
177 Ok(config)
178 }
179
180 pub fn validate(&self) -> Result<(), String> {
184 if self.cas.is_empty() {
186 return Err("at least one [ca] or [[ca]] entry is required".into());
187 }
188
189 for ca in &self.cas {
191 if ca.id.is_empty() {
192 return Err("each [[ca]] entry must have a non-empty `id` field".into());
193 }
194 if !is_valid_ca_id(&ca.id) {
195 return Err(format!(
196 "CA id {:?} must match ^[a-z0-9][a-z0-9_-]*$ (max 64 chars)",
197 ca.id
198 ));
199 }
200 }
201
202 let mut seen_ids = std::collections::HashSet::new();
204 for ca in &self.cas {
205 if !seen_ids.insert(ca.id.as_str()) {
206 return Err(format!("duplicate CA id {:?}", ca.id));
207 }
208 }
209
210 if self.cas.len() > 1 {
212 let default_count = self.cas.iter().filter(|c| c.is_default).count();
213 if default_count == 0 {
214 return Err(
215 "with multiple [[ca]] entries, exactly one must have `is_default = true`"
216 .into(),
217 );
218 }
219 if default_count > 1 {
220 return Err("at most one [[ca]] entry may have `is_default = true`".into());
221 }
222 }
223
224 if let Some(ha) = &self.ha {
225 ha.validate(&self.cas)?;
226 }
227 for label in &self.est.labels {
228 if !label.ca_pool.is_empty() {
229 if !self.ha.as_ref().is_some_and(|ha| ha.enabled) {
230 return Err("label ca_pool requires enabled HA".into());
231 }
232 if label.ca_id.is_some() {
233 return Err("label ca_id and ca_pool are mutually exclusive".into());
234 }
235 for id in &label.ca_pool {
236 if !self.cas.iter().any(|ca| &ca.id == id) {
237 return Err(format!("unknown CA {id} in label ca_pool"));
238 }
239 }
240 }
241 }
242
243 self.tls.validate()?;
245
246 if self.tls.enabled && self.server.is_unix_socket() {
248 return Err("TLS cannot be used with a Unix domain socket listener".into());
249 }
250
251 let known_ca_ids: std::collections::HashSet<&str> =
253 self.cas.iter().map(|c| c.id.as_str()).collect();
254
255 for label in &self.est.labels {
256 if let Some(ref ca_id) = label.ca_id
257 && !known_ca_ids.contains(ca_id.as_str())
258 {
259 return Err(format!(
260 "EST label {:?} references unknown CA id {ca_id:?}",
261 label.name
262 ));
263 }
264 }
265
266 self.otp.validate()?;
268
269 if let Some(ref admin) = self.admin {
271 admin.validate()?;
272 }
273
274 self.audit.validate()?;
276
277 self.auth_lockout.validate()?;
279
280 if let Some(ref coap) = self.coap {
282 coap.validate()?;
283 }
284
285 if let Some(ref cms_est) = self.cms_est {
287 cms_est.validate()?;
288 }
289
290 if let Some(ref cmp) = self.cmp {
292 let _ = cmp; }
294
295 if let Some(ref dogtag) = self.dogtag {
297 if dogtag.ca_url.scheme() != "https" && dogtag.ca_url.scheme() != "http" {
298 return Err("dogtag.ca_url must use HTTPS or HTTP".into());
299 }
300 if dogtag.ca_url.scheme() == "http" {
301 tracing::warn!(
302 url = %dogtag.ca_url,
303 "dogtag.ca_url uses HTTP — agent credentials sent in cleartext"
304 );
305 }
306 if dogtag.agent_cert_file.is_empty() {
307 return Err("dogtag.agent_cert_file must not be empty".into());
308 }
309 if dogtag.agent_key_file.is_empty() {
310 return Err("dogtag.agent_key_file must not be empty".into());
311 }
312 if dogtag.ca_cert_file.is_empty() {
313 return Err("dogtag.ca_cert_file must not be empty".into());
314 }
315 if dogtag.profile_id.is_empty() {
316 return Err("dogtag.profile_id must not be empty".into());
317 }
318 }
319
320 if let Some(ref star) = self.star {
322 star.validate()?;
323 }
324
325 if self.crl_refresh_interval_secs == 0 {
327 return Err("crl_refresh_interval_secs must be at least 1".into());
328 }
329
330 if self.tls.enabled {
335 check_file_exists("[tls].cert_file", &self.tls.cert_file)?;
336 check_file_exists("[tls].key_file", &self.tls.key_file)?;
337 if !self.tls.ca_file.is_empty() {
338 check_file_exists("[tls].ca_file", &self.tls.ca_file)?;
339 }
340 }
341
342 Ok(())
343 }
344
345 pub fn default_ca(&self) -> &CaConfig {
353 if self.cas.len() == 1 {
354 return &self.cas[0];
355 }
356 self.cas
357 .iter()
358 .find(|c| c.is_default)
359 .expect("validate() ensures exactly one default CA")
360 }
361}
362
363fn is_valid_ca_id(id: &str) -> bool {
370 if id.is_empty() || id.len() > 64 {
371 return false;
372 }
373 let mut chars = id.chars();
374 match chars.next() {
375 None => return false,
376 Some(c) if !c.is_ascii_lowercase() && !c.is_ascii_digit() => return false,
377 _ => {}
378 }
379 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
380}
381
382fn check_file_exists(field: &str, path: &str) -> Result<(), String> {
384 if path.is_empty() || path.starts_with("pkcs11:") {
385 return Ok(());
386 }
387 if !std::path::Path::new(path).exists() {
388 return Err(format!("{field} path {path:?} does not exist"));
389 }
390 Ok(())
391}
392
393fn deserialize_ca_array<'de, D>(deserializer: D) -> Result<Vec<CaConfig>, D::Error>
399where
400 D: serde::Deserializer<'de>,
401{
402 use serde::de::{MapAccess, SeqAccess, Visitor};
403 use std::fmt;
404
405 struct CaArrayVisitor;
406
407 impl<'de> Visitor<'de> for CaArrayVisitor {
408 type Value = Vec<CaConfig>;
409
410 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411 f.write_str("a [ca] table or [[ca]] array of tables")
412 }
413
414 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<CaConfig>, A::Error> {
415 let mut cas = Vec::new();
416 while let Some(ca) = seq.next_element::<CaConfig>()? {
417 cas.push(ca);
418 }
419 Ok(cas)
420 }
421
422 fn visit_map<M: MapAccess<'de>>(self, map: M) -> Result<Vec<CaConfig>, M::Error> {
423 let mut ca = CaConfig::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
424 if ca.id.is_empty() {
425 ca.id = "default".to_owned();
426 }
427 ca.is_default = true;
428 Ok(vec![ca])
429 }
430 }
431
432 deserializer.deserialize_any(CaArrayVisitor)
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 fn minimal_toml() -> &'static str {
440 r#"
441[database]
442url = "sqlite::memory:"
443
444[ca]
445key_file = "/tmp/ca.key"
446cert_file = "/tmp/ca.crt"
447"#
448 }
449
450 #[test]
451 fn parse_minimal_config() {
452 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
453 assert_eq!(cfg.database.url, "sqlite::memory:");
454 assert_eq!(cfg.cas.len(), 1);
455 assert_eq!(cfg.cas[0].id, "default");
456 assert!(cfg.cas[0].is_default);
457 }
458
459 #[test]
460 fn default_ca_returns_single_entry() {
461 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
462 assert_eq!(cfg.default_ca().id, "default");
463 }
464
465 #[test]
466 fn multi_ca_requires_default() {
467 let toml = r#"
468[database]
469url = "sqlite::memory:"
470[[ca]]
471id = "a"
472key_file = "/tmp/a.key"
473cert_file = "/tmp/a.crt"
474[[ca]]
475id = "b"
476key_file = "/tmp/b.key"
477cert_file = "/tmp/b.crt"
478"#;
479 let cfg: Config = toml::from_str(toml).unwrap();
480 let err = cfg.validate().unwrap_err();
481 assert!(err.contains("is_default"), "err: {err}");
482 }
483
484 #[test]
485 fn multi_ca_rejects_duplicate_id() {
486 let toml = r#"
487[database]
488url = "sqlite::memory:"
489[[ca]]
490id = "a"
491is_default = true
492key_file = "/tmp/a.key"
493cert_file = "/tmp/a.crt"
494[[ca]]
495id = "a"
496key_file = "/tmp/a2.key"
497cert_file = "/tmp/a2.crt"
498"#;
499 let cfg: Config = toml::from_str(toml).unwrap();
500 let err = cfg.validate().unwrap_err();
501 assert!(err.contains("duplicate"), "err: {err}");
502 }
503
504 #[test]
505 fn est_label_rejects_unknown_ca_id() {
506 let toml = r#"
507[database]
508url = "sqlite::memory:"
509[ca]
510key_file = "/tmp/ca.key"
511cert_file = "/tmp/ca.crt"
512[[est.label]]
513name = "devices"
514ca_id = "nonexistent"
515"#;
516 let cfg: Config = toml::from_str(toml).unwrap();
517 let err = cfg.validate().unwrap_err();
518 assert!(err.contains("nonexistent"), "err: {err}");
519 }
520
521 #[test]
522 fn invalid_ca_id_rejected() {
523 assert!(!is_valid_ca_id(""));
524 assert!(!is_valid_ca_id("Bad Id!"));
525 assert!(!is_valid_ca_id("_starts_with_underscore"));
526 assert!(is_valid_ca_id("good-id"));
527 assert!(is_valid_ca_id("a123_test-ca"));
528 }
529
530 #[test]
531 fn server_defaults_applied() {
532 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
533 assert_eq!(cfg.server.listen_addr, "0.0.0.0:8443");
534 assert_eq!(cfg.server.max_body_size, 65536);
535 }
536
537 #[test]
538 fn est_defaults_applied() {
539 let cfg: Config = toml::from_str(minimal_toml()).unwrap();
540 assert!(cfg.est.simpleenroll);
541 assert!(cfg.est.simplereenroll);
542 assert!(!cfg.est.fullcmc);
543 assert!(!cfg.est.serverkeygen);
544 assert!(cfg.est.csrattrs);
545 }
546
547 #[test]
555 fn ha_example_config_is_valid() {
556 let path = concat!(
557 env!("CARGO_MANIFEST_DIR"),
558 "/contrib/local-dev/kipuka-ha.toml"
559 );
560 let content = std::fs::read_to_string(path).expect("read kipuka-ha.toml");
561 let cfg: Config = toml::from_str(&content).expect("kipuka-ha.toml must parse");
562
563 assert_eq!(cfg.cas.len(), 2, "example should configure two CAs");
565 assert_eq!(
566 cfg.cas.iter().filter(|c| c.is_default).count(),
567 1,
568 "exactly one CA must be is_default"
569 );
570
571 let ha = cfg.ha.as_ref().expect("[ha] table present");
573 assert!(ha.enabled, "example must enable HA to be illustrative");
574 ha.validate(&cfg.cas)
575 .expect("[ha] example must pass semantic validation");
576
577 assert!(ha.endpoints.contains_key("ca-secondary"));
580 assert!(!ha.endpoints.contains_key("ca-primary"));
581
582 assert_eq!(
585 cfg.est.labels[0].ca_pool,
586 vec!["ca-primary".to_string(), "ca-secondary".to_string()],
587 );
588 }
589}