1use serde::Deserialize;
2use std::path::PathBuf;
3
4#[derive(Debug, Deserialize, Clone)]
5#[serde(deny_unknown_fields)]
6pub struct Config {
7 pub server: ServerConfig,
8 pub storage: StorageConfig,
9 #[serde(default)]
10 pub ca: Vec<CaConfig>,
11 pub gossip: Option<GossipConfigSection>,
12}
13
14#[derive(Debug, Deserialize, Clone)]
15#[serde(deny_unknown_fields)]
16pub struct GossipConfigSection {
17 #[serde(default)]
18 pub enabled: bool,
19 #[serde(default = "default_gossip_bind")]
20 pub bind: String,
21 #[serde(default)]
22 pub seeds: Vec<String>,
23 #[serde(default = "default_gossip_node_name")]
24 pub node_name: String,
25 pub identity_key: Option<PathBuf>,
31 #[serde(default)]
36 pub peer_keys: Vec<PathBuf>,
37 #[serde(default)]
38 pub peer_identities: std::collections::BTreeMap<String, PathBuf>,
39}
40
41fn default_gossip_bind() -> String {
42 "0.0.0.0:7946".into()
43}
44fn default_gossip_node_name() -> String {
45 std::env::var("HOSTNAME").unwrap_or_else(|_| "hoike-node".into())
46}
47
48#[derive(Debug, Deserialize, Clone)]
49#[serde(deny_unknown_fields)]
50pub struct ServerConfig {
51 #[serde(default = "default_mode")]
52 pub mode: String,
53 #[serde(default = "default_listen")]
54 pub listen: String,
55 #[serde(default = "default_max_request")]
56 pub max_request: usize,
57 pub admin: Option<AdminConfig>,
58 pub webui: Option<WebUiConfig>,
59 pub metrics_listen: Option<String>,
63 pub admin_listen: Option<String>,
70 pub admin_tls: Option<TlsConfig>,
73 pub metrics_tls: Option<TlsConfig>,
76}
77
78#[derive(Debug, Deserialize, Clone)]
85#[serde(deny_unknown_fields)]
86pub struct TlsConfig {
87 pub cert: PathBuf,
89 pub key: PathBuf,
91 pub client_ca: Option<PathBuf>,
94}
95
96#[derive(Debug, Deserialize, Clone)]
97#[serde(deny_unknown_fields)]
98pub struct WebUiConfig {
99 pub static_dir: Option<PathBuf>,
100}
101
102#[derive(Debug, Deserialize, Clone)]
103#[serde(deny_unknown_fields)]
104pub struct AdminConfig {
105 #[serde(default = "default_session_ttl")]
106 pub session_ttl_secs: u64,
107 #[serde(default)]
108 pub operators: Vec<OperatorConfig>,
109}
110
111#[derive(Debug, Deserialize, Clone)]
112#[serde(deny_unknown_fields)]
113pub struct OperatorConfig {
114 pub name: String,
115 pub password_hash: String,
116 #[serde(default = "default_operator_role")]
117 pub role: String,
118}
119
120fn default_session_ttl() -> u64 {
121 3600
122}
123fn default_operator_role() -> String {
124 "viewer".into()
125}
126
127#[derive(Debug, Deserialize, Clone)]
128#[serde(deny_unknown_fields)]
129pub struct StorageConfig {
130 pub bundle_dir: PathBuf,
131 #[serde(default = "default_state_db")]
132 pub state_db: PathBuf,
133 #[serde(default = "default_max_chain")]
134 pub max_chain: u32,
135 #[serde(default)]
138 pub seal_trust_anchors: Option<Vec<PathBuf>>,
139 #[serde(default)]
140 pub seal_authorizations: Vec<SealAuthorization>,
141 #[serde(default)]
143 pub seal_signer_pins: Vec<PathBuf>,
144}
145
146#[derive(Debug, Deserialize, Clone)]
148#[serde(deny_unknown_fields)]
149pub struct SealAuthorization {
150 pub producer_id: String,
151 pub issuer_key_hash: String,
152 pub signer_sha256: String,
154}
155
156#[derive(Debug, Deserialize, Clone)]
157#[serde(deny_unknown_fields)]
158pub struct CaConfig {
159 pub label: String,
160 pub bundle_file: Option<PathBuf>,
161 #[serde(default = "default_nonce_policy")]
162 pub nonce_policy: String,
163 #[serde(default = "default_completeness")]
164 pub completeness: String,
165 pub issuer_name_hash: Option<String>,
168 pub issuer_key_hash: Option<String>,
171 pub forward_to: Option<String>,
175 #[serde(default)]
178 pub forward_insecure: bool,
179 pub forward_ca: Option<PathBuf>,
187 pub source: Option<SourceConfig>,
189 #[serde(default = "default_batch_interval")]
191 pub batch_interval: u64,
192 #[serde(default = "default_validity_secs")]
194 pub validity_secs: u64,
195 #[serde(default = "default_jitter_secs")]
199 pub jitter_secs: u64,
200 #[serde(default = "default_certid_compat")]
203 pub certid_compat: String,
204 #[serde(default = "default_max_age_fraction")]
207 pub max_age_fraction: f64,
208 #[serde(default)]
213 pub archive_cutoff_secs: u64,
214 #[serde(default = "default_urgent_revocation")]
218 pub urgent_revocation: bool,
219 pub issuer_name_der_b64: Option<String>,
222 pub issuer_key_bytes_b64: Option<String>,
225 #[serde(default = "default_sig_alg")]
227 pub sig_alg: String,
228 pub signing_key: Option<SigningKeyConfig>,
230 pub responder_cert: Option<PathBuf>,
234 pub key_rotation: Option<KeyRotationConfigToml>,
236 pub seal_key: Option<PathBuf>,
240 pub seal_cert: Option<PathBuf>,
243}
244
245impl CaConfig {
246 pub fn is_ml_dsa(&self) -> bool {
248 matches!(
249 self.sig_alg.as_str(),
250 "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87"
251 )
252 }
253}
254
255#[derive(Debug, Deserialize, Clone)]
257#[serde(deny_unknown_fields)]
258pub struct KeyRotationConfigToml {
259 #[serde(default = "default_renew_before_days")]
261 pub renew_before_days: u64,
262 #[serde(default = "default_check_interval_hours")]
264 pub check_interval_hours: u64,
265 pub rotation_command: Option<String>,
268}
269
270fn default_renew_before_days() -> u64 {
271 7
272}
273fn default_check_interval_hours() -> u64 {
274 1
275}
276
277#[derive(Debug, Deserialize, Clone)]
279#[serde(tag = "type")]
280pub enum SigningKeyConfig {
281 #[serde(rename = "file")]
283 File { path: PathBuf },
284
285 #[serde(rename = "pkcs11")]
290 Pkcs11 {
291 module: String,
293 token_label: Option<String>,
295 slot_id: Option<u64>,
297 pin: Option<String>,
299 pin_env: Option<String>,
301 key_label: Option<String>,
303 key_id: Option<String>,
305 },
306
307 #[serde(rename = "demo")]
309 Demo,
310}
311
312#[derive(Debug, Deserialize, Clone)]
313#[serde(tag = "type")]
314pub enum SourceConfig {
315 #[serde(rename = "crl")]
316 Crl {
317 path: PathBuf,
318 issuer_cert: Option<PathBuf>,
319 },
320
321 #[serde(rename = "dogtag-sync")]
328 DogtagSync {
329 ldap_url: String,
331 base_dn: String,
333 #[serde(default = "default_bind_dn")]
335 bind_dn: String,
336 bind_password: Option<String>,
338 bind_password_env: Option<String>,
340 cookie_path: Option<PathBuf>,
342 #[serde(default = "default_sync_filter")]
344 filter: Option<String>,
345 #[serde(default = "default_ldap_tls")]
351 tls: String,
352 ca_cert: Option<PathBuf>,
355 },
356}
357
358fn default_ldap_tls() -> String {
359 "none".into()
360}
361
362fn default_bind_dn() -> String {
363 "cn=Directory Manager".into()
364}
365
366fn default_sync_filter() -> Option<String> {
367 Some("(objectClass=certificateRecord)".into())
368}
369
370fn default_mode() -> String {
371 "edge".into()
372}
373fn default_listen() -> String {
374 "0.0.0.0:2560".into()
375}
376fn default_max_request() -> usize {
377 8192
378}
379fn default_state_db() -> PathBuf {
380 PathBuf::from("/var/lib/hoike/state")
381}
382fn default_max_chain() -> u32 {
383 24
384}
385fn default_sig_alg() -> String {
386 "ecdsa-p256".into()
387}
388fn default_nonce_policy() -> String {
389 "ignore".into()
390}
391fn default_completeness() -> String {
392 "partial".into()
393}
394fn default_batch_interval() -> u64 {
395 3600
396}
397fn default_validity_secs() -> u64 {
398 86400
399}
400fn default_jitter_secs() -> u64 {
401 7200
402}
403fn default_certid_compat() -> String {
404 "dual".into()
405}
406fn default_max_age_fraction() -> f64 {
407 0.5
408}
409fn default_urgent_revocation() -> bool {
410 true
411}
412
413impl Config {
414 pub fn from_file(path: &std::path::Path) -> crate::error::Result<Self> {
415 let contents = std::fs::read_to_string(path)?;
416 toml::from_str(&contents).map_err(|e| crate::error::CoreError::Config(e.to_string()))
417 }
418
419 pub fn is_combined(&self) -> bool {
420 self.server.mode == "combined"
421 }
422
423 pub fn is_signer(&self) -> bool {
424 self.server.mode == "signer"
425 }
426
427 pub fn needs_signing(&self) -> bool {
428 self.is_combined() || self.is_signer()
429 }
430
431 pub fn validate_transport(&self, tls_supported: bool) -> crate::error::Result<()> {
433 let fail = |s: &str| crate::error::CoreError::Config(s.into());
434 if (self.server.admin_tls.is_some() || self.server.metrics_tls.is_some()) && !tls_supported
435 {
436 return Err(fail(
437 "TLS configured but this binary was built without the tls feature",
438 ));
439 }
440 if self.server.admin_tls.is_some() && self.server.admin_listen.is_none() {
441 return Err(fail(
442 "admin_tls requires admin_listen; refusing plaintext management fallback",
443 ));
444 }
445 if self.server.metrics_tls.is_some() && self.server.metrics_listen.is_none() {
446 return Err(fail("metrics_tls requires metrics_listen"));
447 }
448 self.validate_for_mode()
449 }
450
451 pub fn validate_for_mode(&self) -> crate::error::Result<()> {
452 if self
453 .gossip
454 .as_ref()
455 .is_some_and(|g| g.enabled && !g.peer_keys.is_empty())
456 {
457 return Err(crate::error::CoreError::Config("replace gossip.peer_keys with peer_identities mapping node names to public-key files".into()));
458 }
459 let mut labels = std::collections::HashSet::new();
460 for ca in &self.ca {
461 if ca.label.is_empty()
462 || ca.label == "."
463 || ca.label == ".."
464 || ca.label.contains(['/', '\\'])
465 || !labels.insert(&ca.label)
466 {
467 return Err(crate::error::CoreError::Config(
468 "CA labels must be unique nonempty file names".into(),
469 ));
470 }
471 if let Some(url) = &ca.forward_to {
472 if !url.starts_with("https://")
473 && !(ca.forward_insecure && url.starts_with("http://"))
474 {
475 return Err(crate::error::CoreError::Config(format!(
476 "CA '{}': forward_to requires https:// (or explicit forward_insecure for http://)",
477 ca.label
478 )));
479 }
480 }
481 }
482 let valid_sig_algs = ["ecdsa-p256", "ml-dsa-44", "ml-dsa-65", "ml-dsa-87"];
483 for ca in &self.ca {
484 if !valid_sig_algs.contains(&ca.sig_alg.as_str()) {
485 return Err(crate::error::CoreError::Config(format!(
486 "CA '{}' has invalid sig_alg '{}' — expected one of: {}",
487 ca.label,
488 ca.sig_alg,
489 valid_sig_algs.join(", ")
490 )));
491 }
492 if ca.is_ml_dsa() && ca.nonce_policy == "live" {
493 return Err(crate::error::CoreError::Config(format!(
494 "CA '{}': nonce_policy=live is not yet supported with {} signing",
495 ca.label, ca.sig_alg
496 )));
497 }
498 let valid_certid_compat = ["dual", "sha256", "sha1"];
499 if !valid_certid_compat.contains(&ca.certid_compat.as_str()) {
500 return Err(crate::error::CoreError::Config(format!(
501 "CA '{}' has invalid certid_compat '{}' — expected one of: {}",
502 ca.label,
503 ca.certid_compat,
504 valid_certid_compat.join(", ")
505 )));
506 }
507 if !(ca.max_age_fraction > 0.0 && ca.max_age_fraction <= 1.0) {
508 return Err(crate::error::CoreError::Config(format!(
509 "CA '{}' has invalid max_age_fraction {} — must be in the range (0, 1]",
510 ca.label, ca.max_age_fraction
511 )));
512 }
513 }
514 if self.needs_signing() {
515 for ca in &self.ca {
516 if ca.source.is_none() {
517 return Err(crate::error::CoreError::Config(format!(
518 "CA '{}' has no source configured, required for {} mode",
519 ca.label, self.server.mode
520 )));
521 }
522 if ca.signing_key.is_none() {
523 return Err(crate::error::CoreError::Config(format!(
524 "CA '{}' has no signing_key configured, required for {} mode. \
525 Use type='file' with a PKCS#8 key, type='pkcs11' for HSM, \
526 or type='demo' for testing only.",
527 ca.label, self.server.mode
528 )));
529 }
530 }
531 if self.ca.is_empty() {
532 return Err(crate::error::CoreError::Config(format!(
533 "{} mode requires at least one [[ca]] with a source",
534 self.server.mode
535 )));
536 }
537 }
538 Ok(())
539 }
540}