diff --git a/docs/man/ntp-ctl.8.md b/docs/man/ntp-ctl.8.md index b3e99436b..d01c6b438 100644 --- a/docs/man/ntp-ctl.8.md +++ b/docs/man/ntp-ctl.8.md @@ -10,6 +10,7 @@ title: NTP-CTL(8) ntpd-rs 1.2.3 | ntpd-rs `ntp-ctl` validate [`-c` *path*] \ `ntp-ctl` status [`-f` *format*] [`-c` *path*] \ +`ntp-ctl` force-sync [`-c` *path*] \ `ntp-ctl` `-h` \ `ntp-ctl` `-v` @@ -48,6 +49,12 @@ with the daemon. : Returns status information about the current state of the ntp-daemon that the client connects to. +`force-sync` +: Interactively run a single synchronization of your clock. This command can + be used to do a one-off synchronization to the time sources configured in + your configuration file. This command should never be used without any + validation by a human operator. + # SEE ALSO [ntp-daemon(8)](ntp-daemon.8.md), diff --git a/docs/precompiled/man/ntp-ctl.8 b/docs/precompiled/man/ntp-ctl.8 index 755abdcd2..f31ffe800 100644 --- a/docs/precompiled/man/ntp-ctl.8 +++ b/docs/precompiled/man/ntp-ctl.8 @@ -30,6 +30,10 @@ .PD 0 .P .PD +\f[V]ntp-ctl\f[R] force-sync [\f[V]-c\f[R] \f[I]path\f[R]] +.PD 0 +.P +.PD \f[V]ntp-ctl\f[R] \f[V]-h\f[R] .PD 0 .P @@ -69,6 +73,13 @@ Checks if the configuration specified (or \f[V]status\f[R] Returns status information about the current state of the ntp-daemon that the client connects to. +.TP +\f[V]force-sync\f[R] +Interactively run a single synchronization of your clock. +This command can be used to do a one-off synchronization to the time +sources configured in your configuration file. +This command should never be used without any validation by a human +operator. .SH SEE ALSO .PP ntp-daemon(8), ntp-metrics-exporter(8), ntp.toml(5) diff --git a/ntp-proto/src/lib.rs b/ntp-proto/src/lib.rs index 535085eea..53fecc815 100644 --- a/ntp-proto/src/lib.rs +++ b/ntp-proto/src/lib.rs @@ -62,11 +62,11 @@ mod exports { ServerResponse, ServerStatHandler, SubnetParseError, }; #[cfg(feature = "__internal-test")] - pub use super::source::{source_snapshot, Measurement}; + pub use super::source::source_snapshot; pub use super::source::{ - AcceptSynchronizationError, NtpSource, NtpSourceAction, NtpSourceActionIterator, - NtpSourceSnapshot, NtpSourceUpdate, ObservableSourceState, ProtocolVersion, Reach, - SourceNtsData, + AcceptSynchronizationError, Measurement, NtpSource, NtpSourceAction, + NtpSourceActionIterator, NtpSourceSnapshot, NtpSourceUpdate, ObservableSourceState, + ProtocolVersion, Reach, SourceNtsData, }; pub use super::system::{ System, SystemAction, SystemActionIterator, SystemSnapshot, SystemSourceUpdate, diff --git a/ntp-proto/src/time_types.rs b/ntp-proto/src/time_types.rs index 967d3af17..3d23d4d9a 100644 --- a/ntp-proto/src/time_types.rs +++ b/ntp-proto/src/time_types.rs @@ -556,8 +556,7 @@ impl std::fmt::Debug for PollInterval { } impl PollInterval { - #[cfg(feature = "ntpv5")] - pub(crate) const NEVER: PollInterval = PollInterval(i8::MAX); + pub const NEVER: PollInterval = PollInterval(i8::MAX); #[cfg(test)] pub fn test_new(value: i8) -> Self { diff --git a/ntpd/src/ctl.rs b/ntpd/src/ctl.rs index 10bf7324e..7eb8e8f41 100644 --- a/ntpd/src/ctl.rs +++ b/ntpd/src/ctl.rs @@ -1,11 +1,15 @@ use std::{path::PathBuf, process::ExitCode}; -use crate::daemon::{config::CliArg, tracing::LogLevel, Config, ObservableState}; +use crate::{ + daemon::{config::CliArg, tracing::LogLevel, Config, ObservableState}, + force_sync, +}; use tracing_subscriber::util::SubscriberInitExt; const USAGE_MSG: &str = "\ usage: ntp-ctl validate [-c PATH] ntp-ctl status [-f FORMAT] [-c PATH] + ntp-ctl force-sync [-c PATH] ntp-ctl -h | ntp-ctl -v"; const DESCRIPTOR: &str = "ntp-ctl - ntp-daemon monitoring"; @@ -34,6 +38,7 @@ pub enum NtpCtlAction { Version, Validate, Status, + ForceSync, } #[derive(Debug, Default)] @@ -44,6 +49,7 @@ pub(crate) struct NtpCtlOptions { version: bool, validate: bool, status: bool, + force_sync: bool, action: NtpCtlAction, } @@ -104,6 +110,9 @@ impl NtpCtlOptions { "status" => { options.status = true; } + "force-sync" => { + options.force_sync = true; + } unknown => { eprintln!("Warning: Unknown command {unknown}"); } @@ -129,6 +138,8 @@ impl NtpCtlOptions { self.action = NtpCtlAction::Validate; } else if self.status { self.action = NtpCtlAction::Status; + } else if self.force_sync { + self.action = NtpCtlAction::ForceSync; } else { self.action = NtpCtlAction::Help; } @@ -172,6 +183,7 @@ pub async fn main() -> std::io::Result { Ok(ExitCode::SUCCESS) } NtpCtlAction::Validate => validate(options.config).await, + NtpCtlAction::ForceSync => force_sync::force_sync(options.config).await, NtpCtlAction::Status => { let config = Config::from_args(options.config, vec![], vec![]).await; diff --git a/ntpd/src/force_sync/algorithm.rs b/ntpd/src/force_sync/algorithm.rs new file mode 100644 index 000000000..9a6da254f --- /dev/null +++ b/ntpd/src/force_sync/algorithm.rs @@ -0,0 +1,180 @@ +use std::collections::HashMap; + +use ntp_proto::{ + Measurement, NtpClock, NtpDuration, PollInterval, SourceController, TimeSyncController, +}; +use serde::Deserialize; + +use crate::daemon::spawn::SourceId; + +pub(crate) struct SingleShotController { + pub(super) clock: C, + sources: HashMap, + min_poll_interval: PollInterval, + min_agreeing: usize, +} + +#[derive(Debug, Copy, Clone, Deserialize)] +pub(crate) struct SingleShotControllerConfig { + pub expected_sources: usize, +} + +pub(crate) struct SingleShotSourceController { + min_poll_interval: PollInterval, + done: bool, +} + +#[derive(Debug, Copy, Clone)] +pub(crate) enum SingleShotControllerMessage {} + +impl SingleShotController { + const ASSUMED_UNCERTAINTY: NtpDuration = NtpDuration::from_exponent(-1); + + fn try_steer(&self) { + if self.sources.len() < self.min_agreeing { + return; + } + + struct Event { + offset: NtpDuration, + count: isize, + } + let mut events: Vec<_> = self + .sources + .values() + .flat_map(|m| { + [ + Event { + offset: m.offset - Self::ASSUMED_UNCERTAINTY, + count: 1, + }, + Event { + offset: m.offset + Self::ASSUMED_UNCERTAINTY, + count: -1, + }, + ] + .into_iter() + }) + .collect(); + events.sort_by(|a, b| a.offset.cmp(&b.offset)); + + let mut peak = 0; + let mut peak_offset = events[0].offset; + let mut cur = 0; + for ev in events { + cur += ev.count; + if cur > peak { + peak = cur; + peak_offset = ev.offset; + } + } + + if peak as usize >= self.min_agreeing { + let mut sum = 0.0; + let mut count = 0; + for source in self.sources.values() { + if source.offset.abs_diff(peak_offset) < Self::ASSUMED_UNCERTAINTY { + count += 1; + sum += source.offset.to_seconds() + } + } + + let avg_offset = NtpDuration::from_seconds(sum / (count as f64)); + self.offer_clock_change(avg_offset); + + std::process::exit(0); + } + } +} + +impl TimeSyncController for SingleShotController { + type Clock = C; + type SourceId = SourceId; + type AlgorithmConfig = SingleShotControllerConfig; + type ControllerMessage = SingleShotControllerMessage; + type SourceMessage = Measurement; + type SourceController = SingleShotSourceController; + + fn new( + clock: Self::Clock, + synchronization_config: ntp_proto::SynchronizationConfig, + source_defaults_config: ntp_proto::SourceDefaultsConfig, + algorithm_config: Self::AlgorithmConfig, + ) -> Result::Error> { + Ok(SingleShotController { + clock, + sources: HashMap::new(), + min_poll_interval: source_defaults_config.poll_interval_limits.min, + min_agreeing: synchronization_config + .minimum_agreeing_sources + .max(algorithm_config.expected_sources / 2), + }) + } + + fn take_control(&mut self) -> Result<(), ::Error> { + //no need for actions + Ok(()) + } + + fn add_source(&mut self, _id: Self::SourceId) -> Self::SourceController { + SingleShotSourceController { + min_poll_interval: self.min_poll_interval, + done: false, + } + } + + fn remove_source(&mut self, id: Self::SourceId) { + self.sources.remove(&id); + } + + fn source_update(&mut self, id: Self::SourceId, usable: bool) { + if !usable { + self.sources.remove(&id); + } + } + + fn source_message( + &mut self, + id: Self::SourceId, + message: Self::SourceMessage, + ) -> ntp_proto::StateUpdate { + self.sources.insert(id, message); + // TODO, check and update time once we have sufficient sources + self.try_steer(); + Default::default() + } + + fn time_update(&mut self) -> ntp_proto::StateUpdate { + // no need for action + Default::default() + } +} + +impl SourceController for SingleShotSourceController { + type ControllerMessage = SingleShotControllerMessage; + type SourceMessage = Measurement; + + fn handle_message(&mut self, _message: Self::ControllerMessage) { + //ignore + } + + fn handle_measurement( + &mut self, + measurement: ntp_proto::Measurement, + ) -> Option { + self.done = true; + Some(measurement) + } + + fn desired_poll_interval(&self) -> ntp_proto::PollInterval { + if self.done { + PollInterval::NEVER + } else { + self.min_poll_interval + } + } + + fn observe(&self) -> ntp_proto::ObservableSourceTimedata { + ntp_proto::ObservableSourceTimedata::default() + } +} diff --git a/ntpd/src/force_sync/mod.rs b/ntpd/src/force_sync/mod.rs new file mode 100644 index 000000000..b8bed11b8 --- /dev/null +++ b/ntpd/src/force_sync/mod.rs @@ -0,0 +1,166 @@ +use std::{ + io::{IsTerminal, Write}, + path::PathBuf, + process::ExitCode, + time::{SystemTime, UNIX_EPOCH}, +}; + +use algorithm::{SingleShotController, SingleShotControllerConfig}; +use ntp_proto::{NtpClock, NtpDuration}; + +#[cfg(feature = "unstable_nts-pool")] +use crate::daemon::config::NtsPoolSourceConfig; +use crate::daemon::{ + config::{self, PoolSourceConfig}, + initialize_logging_parse_config, nts_key_provider, spawn, + tracing::LogLevel, +}; + +mod algorithm; + +fn human_readable_duration(abs_offset: f64) -> String { + let mut offset = abs_offset; + let mut res = String::new(); + if offset >= 86400.0 { + let days = (offset / 86400.0).floor() as u64; + offset -= days as f64 * 86400.0; + res.push_str(&format!("{} day(s) ", days)); + } + if offset >= 3600.0 { + let hours = (offset / 3600.0).floor() as u64; + offset -= hours as f64 * 3600.0; + res.push_str(&format!("{} hour(s) ", hours)); + } + if offset >= 60.0 { + let minutes = (offset / 60.0).floor() as u64; + offset -= minutes as f64 * 60.0; + res.push_str(&format!("{} minute(s) ", minutes)); + } + if offset >= 1.0 { + res.push_str(&format!("{:.0} second(s)", offset)); + } + res +} + +fn try_date_display(offset: NtpDuration) -> Option { + let time = SystemTime::now(); + let since_epoch = time + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let ts = since_epoch + (offset.to_seconds() as u64); + + std::process::Command::new("date") + .arg("-d") + .arg(format!("@{}", ts)) + .arg("+%c") + .output() + .ok() + .and_then(|output| { + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } + }) +} + +impl SingleShotController { + fn offer_clock_change(&self, offset: NtpDuration) { + let offset_ms = offset.to_seconds(); + if offset.abs() < NtpDuration::from_seconds(1.0) { + println!("Your clock is already within 1s of the correct time"); + return; + } + + if let Some(s) = try_date_display(NtpDuration::ZERO) { + println!("The current local time is: {s}"); + } + + if let Some(s) = try_date_display(offset) { + println!("It looks like the time should be: {s}"); + } + + if offset < NtpDuration::ZERO { + println!( + "It looks like your clock is ahead by {}", + human_readable_duration(-offset_ms) + ); + } else { + println!( + "It looks like your clock is behind by {}", + human_readable_duration(offset_ms) + ); + } + println!("Please validate externally that this offset is correct"); + print!("Do you want to update your local clock? [y/N] "); + std::io::stdout().flush().unwrap(); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); + if input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes" { + match self.clock.step_clock(offset) { + Ok(_) => println!("Time updated successfully"), + Err(_) => println!("Could not update clock, do you have the right permissions?"), + } + } else { + println!("Time not updated"); + } + } +} + +pub(crate) async fn force_sync(config: Option) -> std::io::Result { + let config = initialize_logging_parse_config(Some(LogLevel::Warn), config).await; + + // Warn/error if the config is unreasonable. We do this after finishing + // tracing setup to ensure logging is fully configured. + config.check(); + + if !std::io::stdin().is_terminal() { + eprintln!("This command must be run interactively"); + return Ok(ExitCode::FAILURE); + } + + println!("Determining current time..."); + + // Count number of sources + let mut total_sources = 0; + for source in &config.sources { + match source { + config::NtpSourceConfig::Standard(_) | config::NtpSourceConfig::Nts(_) => { + total_sources += 1 + } + config::NtpSourceConfig::Pool(PoolSourceConfig { count, .. }) => total_sources += count, + #[cfg(feature = "unstable_nts-pool")] + config::NtpSourceConfig::NtsPool(NtsPoolSourceConfig { count, .. }) => { + total_sources += count + } + } + } + + // We will need to have a keyset for the daemon + let keyset = nts_key_provider::spawn(config.keyset).await; + + #[cfg(feature = "hardware-timestamping")] + let clock_config = config.clock; + + #[cfg(not(feature = "hardware-timestamping"))] + let clock_config = config::ClockConfig::default(); + + ::tracing::debug!("Configuration loaded, spawning daemon jobs"); + let (main_loop_handle, _) = spawn::>( + config.synchronization.synchronization_base, + SingleShotControllerConfig { + expected_sources: total_sources, + }, + config.source_defaults, + clock_config, + &config.sources, + &[], // No serving when operating in force sync mode + keyset.clone(), + ) + .await?; + + let _ = main_loop_handle.await; + + Ok(ExitCode::SUCCESS) +} diff --git a/ntpd/src/lib.rs b/ntpd/src/lib.rs index eede21d2f..d5933044e 100644 --- a/ntpd/src/lib.rs +++ b/ntpd/src/lib.rs @@ -2,6 +2,7 @@ mod ctl; mod daemon; +mod force_sync; mod metrics; pub use ctl::main as ctl_main;