Skip to main content

rtime_core/
config.rs

1use serde::Deserialize;
2
3#[derive(Debug, Clone, Deserialize, Default)]
4pub struct RtimeConfig {
5    #[serde(default)]
6    pub general: GeneralConfig,
7    #[serde(default)]
8    pub clock: ClockConfig,
9    #[serde(default)]
10    pub ntp: NtpConfig,
11    #[serde(default)]
12    pub ptp: PtpConfig,
13    #[serde(default)]
14    pub metrics: MetricsConfig,
15    #[serde(default)]
16    pub management: ManagementConfig,
17}
18
19#[derive(Debug, Clone, Deserialize)]
20pub struct GeneralConfig {
21    #[serde(default = "default_log_level")]
22    pub log_level: String,
23}
24
25impl Default for GeneralConfig {
26    fn default() -> Self {
27        Self {
28            log_level: default_log_level(),
29        }
30    }
31}
32
33fn default_log_level() -> String {
34    "info".into()
35}
36
37#[derive(Debug, Clone, Deserialize)]
38pub struct ClockConfig {
39    #[serde(default = "default_true")]
40    pub discipline: bool,
41    #[serde(default = "default_step_threshold_ms")]
42    pub step_threshold_ms: f64,
43    #[serde(default = "default_panic_threshold_ms")]
44    pub panic_threshold_ms: f64,
45    #[serde(default = "default_true")]
46    pub allow_initial_step: bool,
47    #[serde(default = "default_clock_interface")]
48    pub interface: String,
49}
50
51impl Default for ClockConfig {
52    fn default() -> Self {
53        Self {
54            discipline: true,
55            step_threshold_ms: default_step_threshold_ms(),
56            panic_threshold_ms: default_panic_threshold_ms(),
57            allow_initial_step: true,
58            interface: default_clock_interface(),
59        }
60    }
61}
62
63fn default_true() -> bool {
64    true
65}
66
67fn default_step_threshold_ms() -> f64 {
68    128.0
69}
70
71fn default_panic_threshold_ms() -> f64 {
72    // 1000 seconds — matches the classic `ntpd` panic default. The previous
73    // value (1s) was a steady-state-tracking guard masquerading as the panic
74    // threshold; in practice it stranded clocks whenever a VM was suspended
75    // for longer than a second. The cold-boot bypass (`allow_initial_step`)
76    // still handles offsets larger than this on first sample.
77    1_000_000.0
78}
79
80fn default_clock_interface() -> String {
81    "system".into()
82}
83
84#[derive(Debug, Clone, Deserialize)]
85pub struct NtpConfig {
86    #[serde(default = "default_true")]
87    pub enabled: bool,
88    #[serde(default = "default_ntp_listen")]
89    pub listen: String,
90    #[serde(default = "default_rate_limit")]
91    pub rate_limit: f64,
92    #[serde(default = "default_rate_burst")]
93    pub rate_burst: u32,
94    #[serde(default)]
95    pub nts: NtsConfig,
96    #[serde(default)]
97    pub sources: Vec<NtpSourceConfig>,
98}
99
100impl Default for NtpConfig {
101    fn default() -> Self {
102        Self {
103            enabled: true,
104            listen: default_ntp_listen(),
105            rate_limit: default_rate_limit(),
106            rate_burst: default_rate_burst(),
107            nts: NtsConfig::default(),
108            sources: Vec::new(),
109        }
110    }
111}
112
113fn default_ntp_listen() -> String {
114    "127.0.0.1:123".into()
115}
116
117fn default_rate_limit() -> f64 {
118    16.0
119}
120
121fn default_rate_burst() -> u32 {
122    32
123}
124
125#[derive(Debug, Clone, Deserialize)]
126pub struct NtsConfig {
127    #[serde(default)]
128    pub enabled: bool,
129    #[serde(default = "default_nts_ke_listen")]
130    pub ke_listen: String,
131    pub certificate: Option<String>,
132    pub private_key: Option<String>,
133}
134
135impl Default for NtsConfig {
136    fn default() -> Self {
137        Self {
138            enabled: false,
139            ke_listen: default_nts_ke_listen(),
140            certificate: None,
141            private_key: None,
142        }
143    }
144}
145
146fn default_nts_ke_listen() -> String {
147    "127.0.0.1:4460".into()
148}
149
150#[derive(Debug, Clone, Deserialize)]
151pub struct NtpSourceConfig {
152    pub address: String,
153    #[serde(default)]
154    pub nts: bool,
155    #[serde(default = "default_min_poll")]
156    pub min_poll: i8,
157    #[serde(default = "default_max_poll")]
158    pub max_poll: i8,
159}
160
161fn default_min_poll() -> i8 {
162    4
163}
164
165fn default_max_poll() -> i8 {
166    10
167}
168
169#[derive(Debug, Clone, Deserialize)]
170pub struct PtpConfig {
171    #[serde(default)]
172    pub enabled: bool,
173    #[serde(default)]
174    pub domain: u8,
175    #[serde(default = "default_ptp_interface")]
176    pub interface: String,
177    #[serde(default = "default_ptp_transport")]
178    pub transport: String,
179    #[serde(default = "default_priority")]
180    pub priority1: u8,
181    #[serde(default = "default_priority")]
182    pub priority2: u8,
183    #[serde(default = "default_delay_mechanism")]
184    pub delay_mechanism: String,
185}
186
187impl Default for PtpConfig {
188    fn default() -> Self {
189        Self {
190            enabled: false,
191            domain: 0,
192            interface: default_ptp_interface(),
193            transport: default_ptp_transport(),
194            priority1: default_priority(),
195            priority2: default_priority(),
196            delay_mechanism: default_delay_mechanism(),
197        }
198    }
199}
200
201fn default_ptp_interface() -> String {
202    "eth0".into()
203}
204
205fn default_ptp_transport() -> String {
206    "udp-ipv4".into()
207}
208
209fn default_priority() -> u8 {
210    128
211}
212
213fn default_delay_mechanism() -> String {
214    "e2e".into()
215}
216
217#[derive(Debug, Clone, Deserialize)]
218pub struct MetricsConfig {
219    #[serde(default = "default_true")]
220    pub enabled: bool,
221    #[serde(default = "default_metrics_listen")]
222    pub listen: String,
223}
224
225impl Default for MetricsConfig {
226    fn default() -> Self {
227        Self {
228            enabled: true,
229            listen: default_metrics_listen(),
230        }
231    }
232}
233
234fn default_metrics_listen() -> String {
235    "127.0.0.1:9100".into()
236}
237
238#[derive(Debug, Clone, Deserialize)]
239pub struct ManagementConfig {
240    #[serde(default = "default_true")]
241    pub enabled: bool,
242    #[serde(default = "default_management_listen")]
243    pub listen: String,
244    /// Optional bearer token for API authentication.
245    /// If set, all management API requests must include
246    /// `Authorization: Bearer <token>` header.
247    pub api_key: Option<String>,
248}
249
250impl Default for ManagementConfig {
251    fn default() -> Self {
252        Self {
253            enabled: true,
254            listen: default_management_listen(),
255            api_key: None,
256        }
257    }
258}
259
260fn default_management_listen() -> String {
261    "127.0.0.1:9200".into()
262}
263
264impl RtimeConfig {
265    /// Validate configuration values after deserialization.
266    /// Returns an error describing the first invalid value found.
267    pub fn validate(&self) -> Result<(), ConfigError> {
268        // Clock thresholds must be positive and finite
269        if !self.clock.step_threshold_ms.is_finite() || self.clock.step_threshold_ms <= 0.0 {
270            return Err(ConfigError::InvalidValue(
271                "clock.step_threshold_ms must be a positive finite number".into(),
272            ));
273        }
274        if !self.clock.panic_threshold_ms.is_finite() || self.clock.panic_threshold_ms <= 0.0 {
275            return Err(ConfigError::InvalidValue(
276                "clock.panic_threshold_ms must be a positive finite number".into(),
277            ));
278        }
279        if self.clock.step_threshold_ms >= self.clock.panic_threshold_ms {
280            return Err(ConfigError::InvalidValue(format!(
281                "clock.step_threshold_ms ({}) must be less than clock.panic_threshold_ms ({})",
282                self.clock.step_threshold_ms, self.clock.panic_threshold_ms
283            )));
284        }
285
286        // Rate limiting values
287        if !self.ntp.rate_limit.is_finite() || self.ntp.rate_limit <= 0.0 {
288            return Err(ConfigError::InvalidValue(
289                "ntp.rate_limit must be a positive finite number".into(),
290            ));
291        }
292        if self.ntp.rate_burst == 0 {
293            return Err(ConfigError::InvalidValue(
294                "ntp.rate_burst must be greater than 0".into(),
295            ));
296        }
297
298        // Validate poll intervals for each source
299        for (i, source) in self.ntp.sources.iter().enumerate() {
300            if source.min_poll < 0 || source.min_poll > 17 {
301                return Err(ConfigError::InvalidValue(format!(
302                    "ntp.sources[{}].min_poll must be between 0 and 17",
303                    i
304                )));
305            }
306            if source.max_poll < 0 || source.max_poll > 17 {
307                return Err(ConfigError::InvalidValue(format!(
308                    "ntp.sources[{}].max_poll must be between 0 and 17",
309                    i
310                )));
311            }
312            if source.min_poll > source.max_poll {
313                return Err(ConfigError::InvalidValue(format!(
314                    "ntp.sources[{}].min_poll must be <= max_poll",
315                    i
316                )));
317            }
318        }
319
320        // Validate listen addresses are parseable
321        self.ntp
322            .listen
323            .parse::<std::net::SocketAddr>()
324            .map_err(|_| {
325                ConfigError::InvalidValue(format!(
326                    "invalid ntp.listen address: {}",
327                    self.ntp.listen
328                ))
329            })?;
330        if self.metrics.enabled {
331            self.metrics
332                .listen
333                .parse::<std::net::SocketAddr>()
334                .map_err(|_| {
335                    ConfigError::InvalidValue(format!(
336                        "invalid metrics.listen address: {}",
337                        self.metrics.listen
338                    ))
339                })?;
340        }
341        if self.management.enabled {
342            self.management
343                .listen
344                .parse::<std::net::SocketAddr>()
345                .map_err(|_| {
346                    ConfigError::InvalidValue(format!(
347                        "invalid management.listen address: {}",
348                        self.management.listen
349                    ))
350                })?;
351        }
352        if self.ntp.nts.enabled {
353            self.ntp
354                .nts
355                .ke_listen
356                .parse::<std::net::SocketAddr>()
357                .map_err(|_| {
358                    ConfigError::InvalidValue(format!(
359                        "invalid nts.ke_listen address: {}",
360                        self.ntp.nts.ke_listen
361                    ))
362                })?;
363
364            // Validate certificate and key files exist and are not world-readable
365            let cert_path = self.ntp.nts.certificate.as_deref().ok_or_else(|| {
366                ConfigError::InvalidValue("nts.certificate is required when NTS is enabled".into())
367            })?;
368            let key_path = self.ntp.nts.private_key.as_deref().ok_or_else(|| {
369                ConfigError::InvalidValue("nts.private_key is required when NTS is enabled".into())
370            })?;
371            for (label, path) in [
372                ("nts.certificate", cert_path),
373                ("nts.private_key", key_path),
374            ] {
375                let meta = std::fs::metadata(path).map_err(|e| {
376                    ConfigError::InvalidValue(format!("{} file '{}': {}", label, path, e))
377                })?;
378                if !meta.is_file() {
379                    return Err(ConfigError::InvalidValue(format!(
380                        "{} path '{}' is not a regular file",
381                        label, path
382                    )));
383                }
384                #[cfg(unix)]
385                {
386                    use std::os::unix::fs::PermissionsExt;
387                    let mode = meta.permissions().mode();
388                    if mode & 0o004 != 0 {
389                        return Err(ConfigError::InvalidValue(format!(
390                            "{} file '{}' is world-readable (mode {:o}); tighten permissions",
391                            label, path, mode
392                        )));
393                    }
394                }
395            }
396        }
397
398        // Reject empty or whitespace-only API keys
399        if self
400            .management
401            .api_key
402            .as_ref()
403            .is_some_and(|k| k.trim().is_empty())
404        {
405            return Err(ConfigError::InvalidValue(
406                "management.api_key must not be empty or whitespace-only".into(),
407            ));
408        }
409
410        // Non-loopback management/metrics checks
411        let mgmt_addr: std::net::SocketAddr = self
412            .management
413            .listen
414            .parse()
415            .unwrap_or_else(|_| "127.0.0.1:9200".parse().unwrap());
416        if self.management.enabled
417            && !mgmt_addr.ip().is_loopback()
418            && self.management.api_key.is_none()
419        {
420            return Err(ConfigError::InvalidValue(format!(
421                "management API on non-loopback address {} requires api_key to be set",
422                self.management.listen
423            )));
424        }
425
426        let metrics_addr: std::net::SocketAddr = self
427            .metrics
428            .listen
429            .parse()
430            .unwrap_or_else(|_| "127.0.0.1:9100".parse().unwrap());
431        if self.metrics.enabled && !metrics_addr.ip().is_loopback() {
432            return Err(ConfigError::InvalidValue(format!(
433                "metrics endpoint on non-loopback address {} is not allowed (bind to loopback or use a reverse proxy)",
434                self.metrics.listen
435            )));
436        }
437
438        Ok(())
439    }
440}
441
442/// Configuration validation errors.
443#[derive(Debug, thiserror::Error)]
444pub enum ConfigError {
445    #[error("invalid config value: {0}")]
446    InvalidValue(String),
447}