Skip to main content

confium_signerd/
config.rs

1//! Signer daemon configuration.
2//!
3//! Loaded from a TOML file specified via `--config`.
4
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8/// Top-level daemon configuration.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DaemonConfig {
11    /// Coordinator TCP address (e.g., "127.0.0.1:18432"). Ignored
12    /// when `coordinator_url` is set.
13    pub coordinator_addr: String,
14    /// Coordinator transport URL — plain (`tcp://host:port`) or
15    /// encrypted (`noise://host:port?key=<hex>&pinned=<hex>`). Takes
16    /// precedence over `coordinator_addr`.
17    #[serde(default)]
18    pub coordinator_url: Option<String>,
19    /// This signer's identity.
20    pub signer_id: String,
21    /// Quorum this signer belongs to.
22    pub quorum_id: String,
23    /// Path to the local share file (JSON).
24    pub share_file: String,
25    /// Signing scheme (e.g., "CMP20", "FROST-P256").
26    pub scheme: String,
27    /// Reconnect backoff in seconds (default: 5).
28    #[serde(default = "default_backoff")]
29    pub reconnect_backoff_secs: u64,
30    /// Maximum reconnect attempts before giving up (0 = infinite).
31    #[serde(default = "default_max_retries")]
32    pub max_reconnect_attempts: u32,
33}
34
35fn default_backoff() -> u64 {
36    5
37}
38
39fn default_max_retries() -> u32 {
40    0
41}
42
43impl DaemonConfig {
44    /// Load configuration from a TOML file.
45    pub fn load(path: &Path) -> Result<Self, ConfigError> {
46        let contents = std::fs::read_to_string(path)
47            .map_err(|e| ConfigError::Read(path.display().to_string(), e.to_string()))?;
48        let config: Self = toml::from_str(&contents)
49            .map_err(|e| ConfigError::Parse(path.display().to_string(), e.to_string()))?;
50        config.validate()?;
51        Ok(config)
52    }
53
54    fn validate(&self) -> Result<(), ConfigError> {
55        if self.signer_id.is_empty() {
56            return Err(ConfigError::Invalid("signer_id must not be empty".into()));
57        }
58        if self.quorum_id.is_empty() {
59            return Err(ConfigError::Invalid("quorum_id must not be empty".into()));
60        }
61        if self.coordinator_url.is_none() && self.coordinator_addr.is_empty() {
62            return Err(ConfigError::Invalid(
63                "coordinator_addr must not be empty".into(),
64            ));
65        }
66        Ok(())
67    }
68}
69
70/// Configuration errors.
71#[derive(Debug, thiserror::Error)]
72pub enum ConfigError {
73    #[error("failed to read {0}: {1}")]
74    Read(String, String),
75    #[error("failed to parse {0}: {1}")]
76    Parse(String, String),
77    #[error("invalid configuration: {0}")]
78    Invalid(String),
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use std::io::Write;
85
86    #[test]
87    fn valid_config_parses() {
88        let toml_str = r#"
89coordinator_addr = "127.0.0.1:18432"
90signer_id = "director-1"
91quorum_id = "biml-root"
92share_file = "/etc/confium/director-1.json"
93scheme = "CMP20"
94"#;
95        let mut tmp = tempfile::NamedTempFile::new().unwrap();
96        tmp.write_all(toml_str.as_bytes()).unwrap();
97        let config = DaemonConfig::load(tmp.path()).unwrap();
98        assert_eq!(config.signer_id, "director-1");
99        assert_eq!(config.quorum_id, "biml-root");
100        assert_eq!(config.scheme, "CMP20");
101        assert_eq!(config.reconnect_backoff_secs, 5);
102    }
103
104    #[test]
105    fn empty_signer_id_rejected() {
106        let toml_str = r#"
107coordinator_addr = "127.0.0.1:18432"
108signer_id = ""
109quorum_id = "q"
110share_file = "/tmp/share.json"
111scheme = "CMP20"
112"#;
113        let mut tmp = tempfile::NamedTempFile::new().unwrap();
114        tmp.write_all(toml_str.as_bytes()).unwrap();
115        assert!(DaemonConfig::load(tmp.path()).is_err());
116    }
117
118    #[test]
119    fn custom_backoff_parses() {
120        let toml_str = r#"
121coordinator_addr = "127.0.0.1:18432"
122signer_id = "s1"
123quorum_id = "q"
124share_file = "/tmp/share.json"
125scheme = "CMP20"
126reconnect_backoff_secs = 30
127max_reconnect_attempts = 10
128"#;
129        let mut tmp = tempfile::NamedTempFile::new().unwrap();
130        tmp.write_all(toml_str.as_bytes()).unwrap();
131        let config = DaemonConfig::load(tmp.path()).unwrap();
132        assert_eq!(config.reconnect_backoff_secs, 30);
133        assert_eq!(config.max_reconnect_attempts, 10);
134    }
135}