ntp_daemon/config/
mod.rs

1pub mod dynamic;
2pub mod format;
3mod peer;
4mod server;
5pub mod subnet;
6
7use ntp_os_clock::DefaultNtpClock;
8use ntp_udp::{EnableTimestamps, InterfaceName};
9pub use peer::*;
10pub use server::*;
11
12use clap::Parser;
13use ntp_proto::{DefaultTimeSyncController, SystemConfig, TimeSyncController};
14use serde::{de, Deserialize, Deserializer};
15use std::{
16    io::ErrorKind,
17    os::unix::fs::PermissionsExt,
18    path::{Path, PathBuf},
19    sync::Arc,
20};
21use thiserror::Error;
22use tokio::{fs::read_to_string, io};
23use tracing::{info, warn};
24use tracing_subscriber::filter::EnvFilter;
25
26use crate::spawn::PeerId;
27
28use self::format::LogFormat;
29
30fn deserialize_option_env_filter<'de, D>(deserializer: D) -> Result<Option<EnvFilter>, D::Error>
31where
32    D: Deserializer<'de>,
33{
34    let data: Option<String> = Deserialize::deserialize(deserializer)?;
35
36    if let Some(dirs) = data {
37        // allow us to recognise configs with an empty log filter directive
38        if dirs.is_empty() {
39            Ok(None)
40        } else {
41            Ok(Some(EnvFilter::try_new(dirs).map_err(de::Error::custom)?))
42        }
43    } else {
44        Ok(None)
45    }
46}
47
48fn parse_env_filter(input: &str) -> Result<Arc<EnvFilter>, tracing_subscriber::filter::ParseError> {
49    EnvFilter::builder()
50        .with_regex(false)
51        .parse(input)
52        .map(Arc::new)
53}
54
55#[derive(Parser, Debug)]
56pub struct CmdArgs {
57    #[arg(
58        short,
59        long = "peer",
60        global = true,
61        value_name = "SERVER",
62        value_parser = PeerConfig::try_from_str,
63        help = "Override the peers in the configuration file"
64    )]
65    pub peers: Vec<PeerConfig>,
66
67    #[arg(
68        short,
69        long,
70        global = true,
71        value_name = "FILE",
72        help = "Path of the configuration file"
73    )]
74    pub config: Option<PathBuf>,
75
76    #[arg(
77        long,
78        short,
79        global = true,
80        value_name = "FILTER",
81        value_parser = parse_env_filter,
82        env = "NTP_LOG",
83        help = "Filter to apply to log messages"
84    )]
85    pub log_filter: Option<Arc<EnvFilter>>,
86
87    #[arg(
88        long,
89        global = true,
90        value_name = "FORMAT",
91        env = "NTP_LOG_FORMAT",
92        help = "Output format for logs (full, compact, pretty, json)"
93    )]
94    pub log_format: Option<LogFormat>,
95
96    #[arg(
97        short,
98        long = "server",
99        global = true,
100        value_name = "ADDR",
101        value_parser = ServerConfig::try_from_str,
102        help = "Override the servers to run from the configuration file"
103    )]
104    pub servers: Vec<ServerConfig>,
105}
106
107fn deserialize_ntp_clock<'de, D>(deserializer: D) -> Result<DefaultNtpClock, D::Error>
108where
109    D: Deserializer<'de>,
110{
111    let data: Option<PathBuf> = Deserialize::deserialize(deserializer)?;
112
113    if let Some(path) = data {
114        tracing::info!("using custom clock {path:?}");
115        DefaultNtpClock::from_path(&path).map_err(|e| serde::de::Error::custom(e.to_string()))
116    } else {
117        tracing::debug!("using REALTIME clock");
118        Ok(DefaultNtpClock::realtime())
119    }
120}
121
122fn deserialize_interface<'de, D>(deserializer: D) -> Result<Option<InterfaceName>, D::Error>
123where
124    D: Deserializer<'de>,
125{
126    let opt_interface_name: Option<InterfaceName> = Deserialize::deserialize(deserializer)?;
127
128    if let Some(interface_name) = opt_interface_name {
129        tracing::info!("using custom interface {}", interface_name);
130    } else {
131        tracing::info!("using default interface");
132    }
133
134    Ok(opt_interface_name)
135}
136
137#[derive(Deserialize, Debug, Copy, Clone, Default)]
138#[serde(rename_all = "kebab-case")]
139pub struct ClockConfig {
140    #[serde(deserialize_with = "deserialize_ntp_clock", default)]
141    pub clock: DefaultNtpClock,
142    #[serde(deserialize_with = "deserialize_interface", default)]
143    pub interface: Option<InterfaceName>,
144    pub enable_timestamps: EnableTimestamps,
145}
146
147#[derive(Deserialize, Debug, Default, Clone, Copy)]
148#[serde(rename_all = "kebab-case", deny_unknown_fields)]
149pub struct CombinedSystemConfig {
150    #[serde(flatten)]
151    pub system: SystemConfig,
152    #[serde(flatten)]
153    pub algorithm: <DefaultTimeSyncController<DefaultNtpClock, PeerId> as TimeSyncController<
154        DefaultNtpClock,
155        PeerId,
156    >>::AlgorithmConfig,
157}
158
159#[derive(Deserialize, Debug, Default)]
160#[serde(rename_all = "kebab-case", deny_unknown_fields)]
161pub struct Config {
162    #[serde(alias = "peer")]
163    pub peers: Vec<PeerConfig>,
164    #[serde(alias = "server", default)]
165    pub servers: Vec<ServerConfig>,
166    #[serde(alias = "nts-ke-server", default)]
167    pub nts_ke: Option<NtsKeConfig>,
168    #[serde(default)]
169    pub system: CombinedSystemConfig,
170    #[serde(deserialize_with = "deserialize_option_env_filter", default)]
171    pub log_filter: Option<EnvFilter>,
172    #[serde(default)]
173    pub log_format: LogFormat,
174    #[serde(default)]
175    pub observe: ObserveConfig,
176    #[serde(default)]
177    pub configure: ConfigureConfig,
178    #[serde(default)]
179    pub keyset: KeysetConfig,
180    #[serde(default)]
181    pub clock: ClockConfig,
182}
183
184const fn default_observe_permissions() -> u32 {
185    0o666
186}
187
188#[derive(Clone, Deserialize, Debug)]
189#[serde(rename_all = "kebab-case", deny_unknown_fields)]
190pub struct ObserveConfig {
191    #[serde(default)]
192    pub path: Option<PathBuf>,
193    #[serde(default = "default_observe_permissions")]
194    pub mode: u32,
195}
196
197const fn default_configure_permissions() -> u32 {
198    0o660
199}
200
201impl Default for ObserveConfig {
202    fn default() -> Self {
203        Self {
204            path: None,
205            mode: default_observe_permissions(),
206        }
207    }
208}
209
210#[derive(Clone, Deserialize, Debug)]
211#[serde(rename_all = "kebab-case", deny_unknown_fields)]
212pub struct ConfigureConfig {
213    #[serde(default)]
214    pub path: Option<std::path::PathBuf>,
215    #[serde(default = "default_configure_permissions")]
216    pub mode: u32,
217}
218
219impl Default for ConfigureConfig {
220    fn default() -> Self {
221        Self {
222            path: None,
223            mode: default_configure_permissions(),
224        }
225    }
226}
227
228#[derive(Error, Debug)]
229pub enum ConfigError {
230    #[error("io error while reading config: {0}")]
231    Io(#[from] io::Error),
232    #[error("config toml parsing error: {0}")]
233    Toml(#[from] toml::de::Error),
234}
235
236impl Config {
237    async fn from_file(file: impl AsRef<Path>) -> Result<Config, ConfigError> {
238        let meta = std::fs::metadata(&file).unwrap();
239        let perm = meta.permissions();
240
241        if perm.mode() as libc::mode_t & libc::S_IWOTH != 0 {
242            warn!("Unrestricted config file permissions: Others can write.");
243        }
244
245        let contents = read_to_string(file).await?;
246        Ok(toml::de::from_str(&contents)?)
247    }
248
249    async fn from_first_file(file: Option<impl AsRef<Path>>) -> Result<Config, ConfigError> {
250        // if an explicit file is given, always use that one
251        if let Some(f) = file {
252            let path: &Path = f.as_ref();
253            info!(?path, "using config file");
254            return Config::from_file(f).await;
255        }
256
257        // for the global file we also ignore it when there are permission errors
258        let global_path = Path::new("/etc/ntpd-rs/ntp.toml");
259        if global_path.exists() {
260            info!("using config file at default location `{:?}`", global_path);
261            match Config::from_file(global_path).await {
262                Err(ConfigError::Io(e)) if e.kind() == ErrorKind::PermissionDenied => {
263                    info!("permission denied on global config file! using default config ...");
264                }
265                other => {
266                    return other;
267                }
268            }
269        }
270
271        Ok(Config::default())
272    }
273
274    pub async fn from_args(
275        file: Option<impl AsRef<Path>>,
276        peers: Vec<PeerConfig>,
277        servers: Vec<ServerConfig>,
278    ) -> Result<Config, ConfigError> {
279        let mut config = Config::from_first_file(file).await?;
280
281        if !peers.is_empty() {
282            if !config.peers.is_empty() {
283                info!("overriding peers from configuration");
284            }
285            config.peers = peers;
286        }
287
288        if !servers.is_empty() {
289            if !config.servers.is_empty() {
290                info!("overriding servers from configuration");
291            }
292            config.servers = servers;
293        }
294
295        Ok(config)
296    }
297
298    /// Count potential number of peers in configuration
299    fn count_peers(&self) -> usize {
300        let mut count = 0;
301        for peer in &self.peers {
302            match peer {
303                PeerConfig::Standard(_) => count += 1,
304                PeerConfig::Nts(_) => count += 1,
305                PeerConfig::Pool(config) => count += config.max_peers,
306            }
307        }
308        count
309    }
310
311    /// Check that the config is reasonable. This function may panic if the
312    /// configuration is egregious, although it doesn't do so currently.
313    pub fn check(&self) -> bool {
314        let mut ok = true;
315
316        // Note: since we only check once logging is fully configured,
317        // using those fields should always work. This is also
318        // probably a good policy in general (config should always work
319        // but we may panic here to protect the user from themselves)
320        if self.peers.is_empty() {
321            warn!("No peers configured. Daemon will not change system time.");
322            ok = false;
323        }
324
325        if self.count_peers() < self.system.system.min_intersection_survivors {
326            warn!("Fewer peers configured than are required to agree on the current time. Daemon will not change system time.");
327            ok = false;
328        }
329
330        ok
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use std::{ffi::OsString, str::FromStr};
337
338    use ntp_proto::{NtpDuration, StepThreshold};
339
340    use super::*;
341
342    #[test]
343    fn test_config() {
344        let config: Config = toml::from_str("[[peers]]\naddr = \"example.com\"").unwrap();
345        assert_eq!(
346            config.peers,
347            vec![PeerConfig::Standard(StandardPeerConfig {
348                addr: NormalizedAddress::new_unchecked("example.com", 123),
349            })]
350        );
351
352        let config: Config =
353            toml::from_str("log-filter = \"\"\n[[peers]]\naddr = \"example.com\"").unwrap();
354        assert!(config.log_filter.is_none());
355        assert_eq!(
356            config.peers,
357            vec![PeerConfig::Standard(StandardPeerConfig {
358                addr: NormalizedAddress::new_unchecked("example.com", 123),
359            })]
360        );
361
362        let config: Config =
363            toml::from_str("log-filter = \"info\"\n[[peers]]\naddr = \"example.com\"").unwrap();
364        assert!(config.log_filter.is_some());
365        assert_eq!(
366            config.peers,
367            vec![PeerConfig::Standard(StandardPeerConfig {
368                addr: NormalizedAddress::new_unchecked("example.com", 123),
369            })]
370        );
371
372        let config: Config =
373            toml::from_str("[[peers]]\naddr = \"example.com\"\n[system]\npanic-threshold = 0")
374                .unwrap();
375        assert_eq!(
376            config.peers,
377            vec![PeerConfig::Standard(StandardPeerConfig {
378                addr: NormalizedAddress::new_unchecked("example.com", 123),
379            })]
380        );
381        assert_eq!(
382            config.system.system.panic_threshold.forward,
383            Some(NtpDuration::from_seconds(0.))
384        );
385        assert_eq!(
386            config.system.system.panic_threshold.backward,
387            Some(NtpDuration::from_seconds(0.))
388        );
389
390        let config: Config = toml::from_str(
391            "[[peers]]\naddr = \"example.com\"\n[system]\npanic-threshold = \"inf\"",
392        )
393        .unwrap();
394        assert_eq!(
395            config.peers,
396            vec![PeerConfig::Standard(StandardPeerConfig {
397                addr: NormalizedAddress::new_unchecked("example.com", 123),
398            })]
399        );
400        assert!(config.system.system.panic_threshold.forward.is_none());
401        assert!(config.system.system.panic_threshold.backward.is_none());
402
403        let config: Config = toml::from_str(
404            r#"
405            log-filter = "info"
406            log-format = "full"
407            [[peers]]
408            addr = "example.com"
409            [observe]
410            path = "/foo/bar/observe"
411            mode = 0o567
412            [configure]
413            path = "/foo/bar/configure"
414            mode = 0o123
415            "#,
416        )
417        .unwrap();
418        assert!(config.log_filter.is_some());
419
420        assert_eq!(config.observe.path, Some(PathBuf::from("/foo/bar/observe")));
421        assert_eq!(config.observe.mode, 0o567);
422
423        assert_eq!(
424            config.configure.path,
425            Some(PathBuf::from("/foo/bar/configure"))
426        );
427        assert_eq!(config.configure.mode, 0o123);
428
429        assert_eq!(
430            config.peers,
431            vec![PeerConfig::Standard(StandardPeerConfig {
432                addr: NormalizedAddress::new_unchecked("example.com", 123),
433            })]
434        );
435    }
436
437    #[test]
438    fn clap_no_arguments() {
439        use clap::Parser;
440
441        let arguments: [OsString; 0] = [];
442        let parsed_empty = CmdArgs::try_parse_from(arguments).unwrap();
443
444        assert!(parsed_empty.peers.is_empty());
445        assert!(parsed_empty.config.is_none());
446        assert!(parsed_empty.log_filter.is_none());
447    }
448
449    #[test]
450    fn clap_external_config() {
451        use clap::Parser;
452
453        let arguments = &["--", "--config", "other.toml"];
454        let parsed_empty = CmdArgs::try_parse_from(arguments).unwrap();
455
456        assert!(parsed_empty.peers.is_empty());
457        assert_eq!(parsed_empty.config, Some("other.toml".into()));
458        assert!(parsed_empty.log_filter.is_none());
459
460        let arguments = &["--", "-c", "other.toml"];
461        let parsed_empty = CmdArgs::try_parse_from(arguments).unwrap();
462
463        assert!(parsed_empty.peers.is_empty());
464        assert_eq!(parsed_empty.config, Some("other.toml".into()));
465        assert!(parsed_empty.log_filter.is_none());
466    }
467
468    #[test]
469    fn clap_log_filter() {
470        use clap::Parser;
471
472        let arguments = &["--", "--log-filter", "debug"];
473        let parsed_empty = CmdArgs::try_parse_from(arguments).unwrap();
474
475        assert!(parsed_empty.peers.is_empty());
476        assert!(parsed_empty.config.is_none());
477        assert_eq!(parsed_empty.log_filter.unwrap().to_string(), "debug");
478
479        let arguments = &["--", "-l", "debug"];
480        let parsed_empty = CmdArgs::try_parse_from(arguments).unwrap();
481
482        assert!(parsed_empty.peers.is_empty());
483        assert!(parsed_empty.config.is_none());
484        assert_eq!(parsed_empty.log_filter.unwrap().to_string(), "debug");
485    }
486
487    #[test]
488    fn clap_peers() {
489        use clap::Parser;
490
491        let arguments = &["--", "--peer", "foo.nl"];
492        let parsed_empty = CmdArgs::try_parse_from(arguments).unwrap();
493
494        assert_eq!(
495            parsed_empty.peers,
496            vec![PeerConfig::Standard(StandardPeerConfig {
497                addr: NormalizedAddress::new_unchecked("foo.nl", 123),
498            })]
499        );
500        assert!(parsed_empty.config.is_none());
501        assert!(parsed_empty.log_filter.is_none());
502
503        let arguments = &["--", "--peer", "foo.rs", "-p", "spam.nl:123"];
504        let parsed_empty = CmdArgs::try_parse_from(arguments).unwrap();
505
506        assert_eq!(
507            parsed_empty.peers,
508            vec![
509                PeerConfig::Standard(StandardPeerConfig {
510                    addr: NormalizedAddress::new_unchecked("foo.rs", 123),
511                }),
512                PeerConfig::Standard(StandardPeerConfig {
513                    addr: NormalizedAddress::new_unchecked("spam.nl", 123),
514                }),
515            ]
516        );
517        assert!(parsed_empty.config.is_none());
518        assert!(parsed_empty.log_filter.is_none());
519    }
520
521    #[test]
522    fn clap_peers_invalid() {
523        let arguments = &["--", "--peer", ":invalid:ipv6:123"];
524        assert!(CmdArgs::try_parse_from(arguments).is_err());
525    }
526
527    #[test]
528    fn toml_peers_invalid() {
529        let config: Result<Config, _> = toml::from_str(
530            r#"
531            [[peers]]
532            addr = ":invalid:ipv6:123"
533            "#,
534        );
535
536        assert!(config.is_err());
537    }
538
539    #[test]
540    fn system_config_accumulated_threshold() {
541        let config: Result<SystemConfig, _> = toml::from_str(
542            r#"
543            accumulated-threshold = 0
544            "#,
545        );
546
547        let config = config.unwrap();
548        assert!(config.accumulated_threshold.is_none());
549
550        let config: Result<SystemConfig, _> = toml::from_str(
551            r#"
552            accumulated-threshold = 1000
553            "#,
554        );
555
556        let config = config.unwrap();
557        assert_eq!(
558            config.accumulated_threshold,
559            Some(NtpDuration::from_seconds(1000.0))
560        );
561    }
562
563    #[test]
564    fn system_config_startup_panic_threshold() {
565        let config: Result<SystemConfig, _> = toml::from_str(
566            r#"
567            startup-panic-threshold = { forward = 10, backward = 20 }
568            "#,
569        );
570
571        let config = config.unwrap();
572        assert_eq!(
573            config.startup_panic_threshold.forward,
574            Some(NtpDuration::from_seconds(10.0))
575        );
576        assert_eq!(
577            config.startup_panic_threshold.backward,
578            Some(NtpDuration::from_seconds(20.0))
579        );
580    }
581
582    #[test]
583    fn duration_not_nan() {
584        #[derive(Debug, Deserialize)]
585        struct Helper {
586            #[allow(unused)]
587            duration: NtpDuration,
588        }
589
590        let result: Result<Helper, _> = toml::from_str(
591            r#"
592            duration = nan
593            "#,
594        );
595
596        let error = result.unwrap_err();
597        assert!(error.to_string().contains("expected a valid number"));
598    }
599
600    #[test]
601    fn step_threshold_not_nan() {
602        #[derive(Debug, Deserialize)]
603        struct Helper {
604            #[allow(unused)]
605            threshold: StepThreshold,
606        }
607
608        let result: Result<Helper, _> = toml::from_str(
609            r#"
610            threshold = nan
611            "#,
612        );
613
614        let error = result.unwrap_err();
615        assert!(error.to_string().contains("expected a positive number"));
616    }
617
618    #[test]
619    fn deny_unknown_fields() {
620        let config: Result<SystemConfig, _> = toml::from_str(
621            r#"
622            unknown-field = 42
623            "#,
624        );
625
626        let error = config.unwrap_err();
627        assert!(error.to_string().contains("unknown field"));
628    }
629
630    #[test]
631    fn clock_config() {
632        let config: Result<ClockConfig, _> = toml::from_str(
633            r#"
634            interface = "enp0s31f6"
635            enable-timestamps.rx-hardware = true
636            enable-timestamps.tx-software = true
637            "#,
638        );
639
640        let config = config.unwrap();
641
642        let expected = InterfaceName::from_str("enp0s31f6").unwrap();
643        assert_eq!(config.interface, Some(expected));
644
645        assert!(config.enable_timestamps.rx_software);
646        assert!(config.enable_timestamps.tx_software);
647    }
648}