Skip to main content

ncro_config/
lib.rs

1use std::{env, fs, time::Duration};
2
3use serde::{Deserialize, Deserializer};
4use thiserror::Error;
5use url::Url;
6
7#[derive(Debug, Error)]
8pub enum ConfigError {
9  #[error("read config: {0}")]
10  Read(#[from] std::io::Error),
11  #[error("parse config: {0}")]
12  Parse(#[from] toml::de::Error),
13  #[error("{0}")]
14  Validation(String),
15}
16
17#[cfg(test)]
18mod tests {
19  use super::*;
20
21  #[test]
22  fn loads_defaults() -> Result<(), ConfigError> {
23    let cfg = Config::load(None)?;
24    assert_eq!(cfg.server.listen, ":8080");
25    assert_eq!(cfg.cache.max_entries, 100_000);
26    assert_eq!(cfg.upstreams.len(), 1);
27    cfg.validate()?;
28    Ok(())
29  }
30
31  #[test]
32  fn parses_duration_toml() -> Result<(), toml::de::Error> {
33    let cfg: Config = toml::from_str(
34      "[server]\nread_timeout = \"30s\"\n\n[cache]\nttl = \"2h\"\n",
35    )?;
36    assert_eq!(cfg.server.read_timeout.0, Duration::from_secs(30));
37    assert_eq!(cfg.cache.ttl.0, Duration::from_secs(7200));
38    Ok(())
39  }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct HumanDuration(pub Duration);
44
45impl Default for HumanDuration {
46  fn default() -> Self {
47    Self(Duration::ZERO)
48  }
49}
50
51impl<'de> Deserialize<'de> for HumanDuration {
52  fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53  where
54    D: Deserializer<'de>,
55  {
56    humantime_serde::deserialize(deserializer).map(Self)
57  }
58}
59
60#[derive(Debug, Clone, Default, Deserialize)]
61#[serde(default)]
62pub struct UpstreamConfig {
63  pub url:        String,
64  pub priority:   i32,
65  pub public_key: String,
66}
67
68#[derive(Debug, Clone, Deserialize)]
69#[serde(default)]
70pub struct ServerConfig {
71  pub listen:         String,
72  pub read_timeout:   HumanDuration,
73  pub write_timeout:  HumanDuration,
74  pub cache_priority: i32,
75}
76
77impl Default for ServerConfig {
78  fn default() -> Self {
79    Self {
80      listen:         ":8080".to_string(),
81      read_timeout:   HumanDuration(Duration::from_secs(30)),
82      write_timeout:  HumanDuration(Duration::from_secs(30)),
83      cache_priority: 30,
84    }
85  }
86}
87
88#[derive(Debug, Clone, Deserialize)]
89#[serde(default)]
90pub struct CacheConfig {
91  pub db_path:       String,
92  pub max_entries:   i64,
93  pub ttl:           HumanDuration,
94  pub negative_ttl:  HumanDuration,
95  pub latency_alpha: f64,
96}
97
98impl Default for CacheConfig {
99  fn default() -> Self {
100    Self {
101      db_path:       "/var/lib/ncro/routes.db".to_string(),
102      max_entries:   100_000,
103      ttl:           HumanDuration(Duration::from_secs(60 * 60)),
104      negative_ttl:  HumanDuration(Duration::from_secs(10 * 60)),
105      latency_alpha: 0.3,
106    }
107  }
108}
109
110#[derive(Debug, Clone, Default, Deserialize)]
111#[serde(default)]
112pub struct PeerConfig {
113  pub addr:       String,
114  pub public_key: String,
115}
116
117#[derive(Debug, Clone, Deserialize)]
118#[serde(default)]
119pub struct MeshConfig {
120  pub enabled:          bool,
121  pub bind_addr:        String,
122  pub peers:            Vec<PeerConfig>,
123  #[serde(rename = "private_key")]
124  pub private_key_path: String,
125  pub gossip_interval:  HumanDuration,
126}
127
128impl Default for MeshConfig {
129  fn default() -> Self {
130    Self {
131      enabled:          false,
132      bind_addr:        "0.0.0.0:7946".to_string(),
133      peers:            Vec::new(),
134      private_key_path: String::new(),
135      gossip_interval:  HumanDuration(Duration::from_secs(30)),
136    }
137  }
138}
139
140#[derive(Debug, Clone, Deserialize)]
141#[serde(default)]
142pub struct DiscoveryConfig {
143  pub enabled:        bool,
144  pub service_name:   String,
145  pub domain:         String,
146  pub discovery_time: HumanDuration,
147  pub priority:       i32,
148}
149
150impl Default for DiscoveryConfig {
151  fn default() -> Self {
152    Self {
153      enabled:        false,
154      service_name:   "_nix-serve._tcp".to_string(),
155      domain:         "local".to_string(),
156      discovery_time: HumanDuration(Duration::from_secs(5)),
157      priority:       20,
158    }
159  }
160}
161
162#[derive(Debug, Clone, Deserialize)]
163#[serde(default)]
164pub struct LoggingConfig {
165  pub level:  String,
166  pub format: String,
167}
168
169impl Default for LoggingConfig {
170  fn default() -> Self {
171    Self {
172      level:  "info".to_string(),
173      format: "json".to_string(),
174    }
175  }
176}
177
178#[derive(Debug, Clone, Deserialize)]
179#[serde(default)]
180pub struct Config {
181  pub server:    ServerConfig,
182  pub upstreams: Vec<UpstreamConfig>,
183  pub cache:     CacheConfig,
184  pub mesh:      MeshConfig,
185  pub discovery: DiscoveryConfig,
186  pub logging:   LoggingConfig,
187}
188
189impl Default for Config {
190  fn default() -> Self {
191    Self {
192      server:    ServerConfig::default(),
193      upstreams: vec![UpstreamConfig {
194        url:        "https://cache.nixos.org".to_string(),
195        priority:   10,
196        public_key: String::new(),
197      }],
198      cache:     CacheConfig::default(),
199      mesh:      MeshConfig::default(),
200      discovery: DiscoveryConfig::default(),
201      logging:   LoggingConfig::default(),
202    }
203  }
204}
205
206impl Config {
207  pub fn load(path: Option<&str>) -> Result<Self, ConfigError> {
208    let mut cfg = if let Some(path) = path.filter(|p| !p.is_empty()) {
209      let data = fs::read_to_string(path)?;
210      toml::from_str::<Self>(&data)?
211    } else {
212      Self::default()
213    };
214
215    if let Ok(v) = env::var("NCRO_LISTEN")
216      && !v.is_empty()
217    {
218      cfg.server.listen = v;
219    }
220    if let Ok(v) = env::var("NCRO_DB_PATH")
221      && !v.is_empty()
222    {
223      cfg.cache.db_path = v;
224    }
225    if let Ok(v) = env::var("NCRO_LOG_LEVEL")
226      && !v.is_empty()
227    {
228      cfg.logging.level = v;
229    }
230
231    Ok(cfg)
232  }
233
234  pub fn validate(&self) -> Result<(), ConfigError> {
235    if self.upstreams.is_empty() {
236      return Err(ConfigError::Validation(
237        "at least one upstream is required".to_string(),
238      ));
239    }
240    for (i, upstream) in self.upstreams.iter().enumerate() {
241      if upstream.url.is_empty() {
242        return Err(ConfigError::Validation(format!(
243          "upstream[{i}]: URL is empty"
244        )));
245      }
246      Url::parse(&upstream.url).map_err(|err| {
247        ConfigError::Validation(format!(
248          "upstream[{i}]: invalid URL {:?}: {err}",
249          upstream.url
250        ))
251      })?;
252      if !upstream.public_key.is_empty() && !upstream.public_key.contains(':') {
253        return Err(ConfigError::Validation(format!(
254          "upstream[{i}]: public_key must be in 'name:base64(key)' Nix format"
255        )));
256      }
257    }
258    if self.server.listen.is_empty() {
259      return Err(ConfigError::Validation(
260        "server.listen is empty".to_string(),
261      ));
262    }
263    if self.server.cache_priority < 1 {
264      return Err(ConfigError::Validation(format!(
265        "server.cache_priority must be >= 1, got {}",
266        self.server.cache_priority
267      )));
268    }
269    if self.cache.latency_alpha <= 0.0 || self.cache.latency_alpha >= 1.0 {
270      return Err(ConfigError::Validation(format!(
271        "cache.latency_alpha must be between 0 and 1 exclusive, got {}",
272        self.cache.latency_alpha
273      )));
274    }
275    if self.cache.ttl.0.is_zero() {
276      return Err(ConfigError::Validation(
277        "cache.ttl must be positive".to_string(),
278      ));
279    }
280    if self.cache.negative_ttl.0.is_zero() {
281      return Err(ConfigError::Validation(
282        "cache.negative_ttl must be positive".to_string(),
283      ));
284    }
285    if self.cache.max_entries <= 0 {
286      return Err(ConfigError::Validation(
287        "cache.max_entries must be positive".to_string(),
288      ));
289    }
290    if self.mesh.enabled && self.mesh.peers.is_empty() {
291      return Err(ConfigError::Validation(
292        "mesh.enabled is true but no peers configured".to_string(),
293      ));
294    }
295    for (i, peer) in self.mesh.peers.iter().enumerate() {
296      if peer.addr.is_empty() {
297        return Err(ConfigError::Validation(format!(
298          "mesh.peers[{i}]: addr is empty"
299        )));
300      }
301      if !peer.public_key.is_empty() {
302        let bytes = hex::decode(&peer.public_key).map_err(|_| {
303          ConfigError::Validation(format!(
304            "mesh.peers[{i}]: public_key must be a hex-encoded 32-byte \
305             ed25519 key"
306          ))
307        })?;
308        if bytes.len() != 32 {
309          return Err(ConfigError::Validation(format!(
310            "mesh.peers[{i}]: public_key must be a hex-encoded 32-byte \
311             ed25519 key"
312          )));
313        }
314      }
315    }
316    if self.discovery.enabled {
317      if self.discovery.service_name.is_empty() {
318        return Err(ConfigError::Validation(
319          "discovery.service_name is required when discovery is enabled"
320            .to_string(),
321        ));
322      }
323      if self.discovery.domain.is_empty() {
324        return Err(ConfigError::Validation(
325          "discovery.domain is required when discovery is enabled".to_string(),
326        ));
327      }
328      if self.discovery.discovery_time.0.is_zero() {
329        return Err(ConfigError::Validation(
330          "discovery.discovery_time must be positive".to_string(),
331        ));
332      }
333    }
334    Ok(())
335  }
336}