From f30cafb3bd7df59fddc278d543e59d4e07e15077 Mon Sep 17 00:00:00 2001 From: Mark Poliakov Date: Wed, 5 Mar 2025 17:21:33 +0200 Subject: [PATCH] user: add a basic NTP client --- userspace/Cargo.lock | 13 + userspace/Cargo.toml | 1 + userspace/etc/rc.d/20-timesync | 4 + userspace/lib/cross/src/lib.rs | 1 + userspace/lib/cross/src/sys/yggdrasil/mod.rs | 1 + userspace/lib/cross/src/sys/yggdrasil/time.rs | 16 ++ userspace/lib/cross/src/time.rs | 19 ++ userspace/tools/ntpc/Cargo.toml | 17 ++ userspace/tools/ntpc/src/main.rs | 249 ++++++++++++++++++ xtask/src/build/userspace.rs | 1 + 10 files changed, 322 insertions(+) create mode 100755 userspace/etc/rc.d/20-timesync create mode 100644 userspace/lib/cross/src/sys/yggdrasil/time.rs create mode 100644 userspace/lib/cross/src/time.rs create mode 100644 userspace/tools/ntpc/Cargo.toml create mode 100644 userspace/tools/ntpc/src/main.rs diff --git a/userspace/Cargo.lock b/userspace/Cargo.lock index 86c12034..403b4fdf 100644 --- a/userspace/Cargo.lock +++ b/userspace/Cargo.lock @@ -1536,6 +1536,19 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntpc" +version = "0.1.0" +dependencies = [ + "bytemuck", + "chrono", + "clap", + "cross", + "log", + "logsink", + "thiserror", +] + [[package]] name = "num-bigint-dig" version = "0.8.2" diff --git a/userspace/Cargo.toml b/userspace/Cargo.toml index c34e07ab..f7c29670 100644 --- a/userspace/Cargo.toml +++ b/userspace/Cargo.toml @@ -17,6 +17,7 @@ members = [ "tools/crypt", "tools/init", "tools/md2txt", + "tools/ntpc", "tools/rdb", "tools/red", "tools/rsh", diff --git a/userspace/etc/rc.d/20-timesync b/userspace/etc/rc.d/20-timesync new file mode 100755 index 00000000..33b4c754 --- /dev/null +++ b/userspace/etc/rc.d/20-timesync @@ -0,0 +1,4 @@ +#!/bin/sh + +# Sync every 20 minutes +/sbin/service start -- /bin/ntpc -i 1200 time.google.com:123 diff --git a/userspace/lib/cross/src/lib.rs b/userspace/lib/cross/src/lib.rs index b734c14e..142cab29 100644 --- a/userspace/lib/cross/src/lib.rs +++ b/userspace/lib/cross/src/lib.rs @@ -8,3 +8,4 @@ pub mod io; pub mod mem; pub mod net; pub mod signal; +pub mod time; diff --git a/userspace/lib/cross/src/sys/yggdrasil/mod.rs b/userspace/lib/cross/src/sys/yggdrasil/mod.rs index 8fbf4081..1ec418c9 100644 --- a/userspace/lib/cross/src/sys/yggdrasil/mod.rs +++ b/userspace/lib/cross/src/sys/yggdrasil/mod.rs @@ -5,6 +5,7 @@ pub mod pipe; pub mod poll; pub mod socket; pub mod term; +pub mod time; pub mod timer; use std::io; diff --git a/userspace/lib/cross/src/sys/yggdrasil/time.rs b/userspace/lib/cross/src/sys/yggdrasil/time.rs new file mode 100644 index 00000000..b5ae496e --- /dev/null +++ b/userspace/lib/cross/src/sys/yggdrasil/time.rs @@ -0,0 +1,16 @@ +use runtime::rt as yggdrasil_rt; +use yggdrasil_rt::time::{self, ClockType}; + +use crate::time::{SystemClock, SystemTime}; + +pub fn now(clock: SystemClock) -> SystemTime { + let clock = match clock { + SystemClock::Monotonic => ClockType::Monotonic, + SystemClock::RealTime => ClockType::RealTime, + }; + let raw = time::get_clock(clock).expect("Could not get clock"); + SystemTime { + seconds: raw.seconds(), + nanoseconds: raw.subsec_nanos() as u32, + } +} diff --git a/userspace/lib/cross/src/time.rs b/userspace/lib/cross/src/time.rs new file mode 100644 index 00000000..3f8b5151 --- /dev/null +++ b/userspace/lib/cross/src/time.rs @@ -0,0 +1,19 @@ +use crate::sys; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SystemTime { + pub seconds: u64, + pub nanoseconds: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemClock { + Monotonic, + RealTime, +} + +impl SystemTime { + pub fn now(clock: SystemClock) -> Self { + sys::time::now(clock) + } +} diff --git a/userspace/tools/ntpc/Cargo.toml b/userspace/tools/ntpc/Cargo.toml new file mode 100644 index 00000000..c8ff7ed6 --- /dev/null +++ b/userspace/tools/ntpc/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ntpc" +version = "0.1.0" +edition = "2021" + +[dependencies] +logsink.workspace = true +cross.workspace = true + +bytemuck.workspace = true +chrono.workspace = true +thiserror.workspace = true +clap.workspace = true +log.workspace = true + +[lints] +workspace = true diff --git a/userspace/tools/ntpc/src/main.rs b/userspace/tools/ntpc/src/main.rs new file mode 100644 index 00000000..a7a00dfd --- /dev/null +++ b/userspace/tools/ntpc/src/main.rs @@ -0,0 +1,249 @@ +#![feature(yggdrasil_os)] +use std::{ + io, + net::{Ipv4Addr, SocketAddr, ToSocketAddrs, UdpSocket}, + os::fd::AsRawFd, + process::ExitCode, + time::{Duration, SystemTime}, +}; + +use bytemuck::{Pod, Zeroable}; +use chrono::{DateTime, Utc}; +use clap::Parser; +use cross::io::{Poll, TimerFd}; + +const NTP_TIMESTAMP_EPOCH: u32 = 2208988800; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("{0}")] + Io(#[from] io::Error), + #[error("Timed out")] + TimedOut, + #[error("Invalid response version")] + InvalidResponseVersion, + #[error("Invalid response mode")] + InvalidResponseMode, + #[error("Invalid response origin timestamp")] + InvalidResponseOriginTimestamp, + #[error("Invalid response timestamp")] + InvalidResponseTimestamp, + #[error("Invalid server address")] + InvalidServerAddress, +} + +#[derive(Debug, Clone, Copy, Pod, Zeroable)] +#[repr(C)] +struct NtpPacketHeader { + status: u8, + ignored: [u8; 3], + root_delay: u32, + root_dispersion: u32, + reference_id: u32, + reference_timestamp: u64, + origin_timestamp: u64, + receive_timestamp: u64, + transmit_timestamp: u64, +} + +const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000; +const MICROSECONDS_IN_SECOND: u64 = 1_000_000; + +fn timestamp_now() -> u64 { + let now = SystemTime::now(); + let duration = now.duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let seconds = duration + .as_secs() + .saturating_add(NTP_TIMESTAMP_EPOCH as u64); + let microseconds = duration.subsec_micros() as u64; + let fraction = microseconds * u32::MAX as u64 / MICROSECONDS_IN_SECOND; + (seconds << 32) | fraction +} + +fn time_from_timestamp(ts: u64) -> Result, Error> { + let mut seconds = (ts >> 32).saturating_sub(NTP_TIMESTAMP_EPOCH as u64); + let mut seconds_fraction = ts & 0xFFFFFFFF; + seconds += seconds_fraction / u32::MAX as u64; + seconds_fraction %= u32::MAX as u64; + let nanoseconds = seconds_fraction * NANOSECONDS_IN_SECOND / (u32::MAX as u64); + DateTime::from_timestamp(seconds as i64, nanoseconds as u32) + .ok_or(Error::InvalidResponseTimestamp) +} + +fn handle_response(response: NtpPacketHeader, timestamp: u64) -> Result, Error> { + let response_version = (response.status >> 3) & 0x7; + let response_mode = response.status & 0x7; + if response_version != 4 { + return Err(Error::InvalidResponseVersion); + } + if response_mode != 4 { + return Err(Error::InvalidResponseMode); + } + let origin_timestamp = u64::from_be(response.origin_timestamp); + if origin_timestamp != timestamp { + eprintln!("{origin_timestamp} != {timestamp}"); + return Err(Error::InvalidResponseOriginTimestamp); + } + + let timestamp = u64::from_be(response.transmit_timestamp); + time_from_timestamp(timestamp) +} + +fn send_request(socket: &UdpSocket, server: &SocketAddr) -> Result { + let send_timestamp = timestamp_now(); + let client_packet = NtpPacketHeader { + status: (4u8 << 3) | 3, + ignored: [0; 3], + root_delay: 0, + root_dispersion: 0, + reference_id: 0, + reference_timestamp: 0, + origin_timestamp: 0, + receive_timestamp: 0, + transmit_timestamp: send_timestamp.to_be(), + }; + socket.send_to(bytemuck::bytes_of(&client_packet), server)?; + Ok(send_timestamp) +} + +fn do_request(server: SocketAddr) -> Result, Error> { + const ATTEMPTS: usize = 5; + const TIMEOUT_MAX: u64 = 3000; + + let mut poll = Poll::new()?; + let mut timer = TimerFd::new()?; + let mut buffer = NtpPacketHeader::zeroed(); + + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?; + let mut timeout = 500; + let mut response = None; + + poll.add(&timer)?; + poll.add(&socket)?; + + for attempt in 0..ATTEMPTS { + let send_timestamp = send_request(&socket, &server)?; + timer.start(Duration::from_millis(timeout))?; + timeout = (timeout * 2).min(TIMEOUT_MAX); + + // Wait for response + loop { + let event = poll.wait(None)?; + match event { + Some(fd) if fd == socket.as_raw_fd() => { + let (len, remote) = match socket.recv_from(bytemuck::bytes_of_mut(&mut buffer)) + { + Ok(result) => result, + Err(error) => { + eprintln!("Receive error: {error}"); + continue; + } + }; + if remote != server || len < size_of::() { + eprintln!("Remote != server or len too small"); + continue; + } + let t = handle_response(buffer, send_timestamp) + .inspect_err(|e| { + eprintln!("Response from {server}: {e}"); + }) + .ok(); + if let Some(t) = t { + response = Some(t); + break; + } + } + Some(fd) if fd == timer.as_raw_fd() => { + break; + } + _ => unreachable!(), + } + } + + if response.is_some() { + break; + } + + eprintln!("[{}/{}] Timed out", attempt + 1, ATTEMPTS); + } + + response.ok_or(Error::TimedOut) +} + +#[derive(Debug, Parser)] +struct Args { + #[clap(short, long, help = "Run in a loop, fetching new time every N seconds")] + interval: Option, + #[clap( + short, + long, + help = "Only print received time, do not change any settings" + )] + dry_run: bool, + server: String, +} + +fn handle_time(args: &Args, time: DateTime) -> Result<(), Error> { + if args.interval.is_none() { + println!("{time}"); + } else { + log::info!("{time}"); + } + Ok(()) +} + +fn resolve_server(args: &Args) -> Result { + let mut server = args.server.to_socket_addrs()?; + let server = server.next().ok_or(Error::InvalidServerAddress)?; + Ok(server) +} + +fn run_loop(args: &Args, interval: Duration) -> Result<(), Error> { + let server = resolve_server(args)?; + + loop { + let response = do_request(server); + + match response { + Ok(time) => { + if let Err(error) = handle_time(args, time) { + log::error!("handle_time(): {error}"); + } + } + Err(error) => { + log::error!("{}: {error}", args.server); + } + } + + std::thread::sleep(interval); + } +} + +fn run_once(args: &Args) -> Result<(), Error> { + let server = resolve_server(args)?; + let response = do_request(server)?; + handle_time(args, response)?; + Ok(()) +} + +fn run(args: &Args) -> Result<(), Error> { + match args.interval { + Some(interval) => { + let interval = Duration::from_secs(interval); + run_loop(args, interval) + } + None => run_once(args), + } +} + +fn main() -> ExitCode { + logsink::setup_logging(true); + let args = Args::parse(); + match run(&args) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} diff --git a/xtask/src/build/userspace.rs b/xtask/src/build/userspace.rs index 657e1d15..81f8a76d 100644 --- a/xtask/src/build/userspace.rs +++ b/xtask/src/build/userspace.rs @@ -60,6 +60,7 @@ const PROGRAMS: &[(&str, &str)] = &[ ("dnsq", "bin/dnsq"), ("ping", "bin/ping"), ("ncap", "sbin/ncap"), + ("ntpc", "bin/ntpc"), // colors ("colors", "bin/colors"), ("colors-bar", "bin/colors-bar"),