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}