src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/host.rs
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-28 16:58:20 -0500
committerLuke Hoersten <[email protected]>2026-07-28 16:58:20 -0500
commit07d4713f554b2ae2ccf4871f6be0590c129342b0 (patch)
treee9675e5e8a7cfce544927339c8f93ee7f412a71b /src/host.rs
Implement mattertimesync: one-shot Matter time synchronization CLI
A standalone CLI Matter controller that joins Matter devices as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on rs-matter 0.2.0, the official CSA Rust Matter stack: its PASE/CASE initiators, Commissioner flow, builtin mDNS, and generated cluster clients. One-shot runs from a systemd timer; there is no daemon. Hardware-validated end to end (commission, inspect, sync, decommission) against an IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home. Design and operation are documented in the README. 34 unit tests, clippy clean.
Diffstat (limited to 'src/host.rs')
-rw-r--r--src/host.rs88
1 files changed, 88 insertions, 0 deletions
diff --git a/src/host.rs b/src/host.rs
new file mode 100644
index 0000000..feb5a34
--- /dev/null
+++ b/src/host.rs
@@ -0,0 +1,88 @@
+//! Host clock trust: the device must never be set from a clock we do not
+//! trust.
+//!
+//! The primary check measures the actual clock error with a single SNTP
+//! query (RFC 4330) and accepts the host when the offset is under a second.
+//! That is a direct measurement, platform-independent, and stronger than
+//! asking the OS whether it believes it is synchronized. When no NTP server
+//! is reachable, systemd-timesyncd's verdict (`timedatectl`, Linux) is the
+//! fallback; hosts with neither fail safe.
+
+use std::net::UdpSocket;
+use std::process::Command;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+const NTP_SERVERS: [&str; 2] = ["time.apple.com:123", "pool.ntp.org:123"];
+/// Accept the host clock when it is within this many seconds of NTP time.
+/// Well inside the 5s sync read-back tolerance, far above network jitter.
+const MAX_OFFSET_SECONDS: f64 = 1.0;
+/// Seconds between the NTP era (1900) and the Unix epoch (1970).
+const NTP_UNIX_OFFSET: f64 = 2_208_988_800.0;
+/// The NTP fraction field is 32-bit fixed-point in units of 1/2^32 seconds;
+/// dividing by 2^32 converts it to seconds.
+const NTP_FRACTION_SCALE: f64 = (1u64 << 32) as f64;
+
+pub fn clock_is_ntp_synchronized() -> bool {
+ for server in NTP_SERVERS {
+ if let Some(offset) = sntp_offset(server) {
+ let ok = offset.abs() <= MAX_OFFSET_SECONDS;
+ if ok {
+ log::debug!("Host clock is {offset:+.3}s from {server}; trusted");
+ } else {
+ log::warn!("Host clock is {offset:+.3}s from {server}; not trusted");
+ }
+ return ok;
+ }
+ }
+ log::debug!("No NTP server reachable; falling back to timedatectl");
+ timedatectl_says_synchronized()
+}
+
+/// One SNTP client exchange: returns the approximate offset of the local
+/// clock relative to the server (positive = local clock ahead). Uses the
+/// request/response midpoint, so the error is bounded by half the round
+/// trip, which is milliseconds against a threshold of a second.
+fn sntp_offset(server: &str) -> Option<f64> {
+ let socket = UdpSocket::bind(("0.0.0.0", 0)).ok()?;
+ socket.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
+ socket.connect(server).ok()?;
+
+ let mut request = [0u8; 48];
+ request[0] = 0b00_100_011; // LI 0, version 4, mode 3 (client)
+ let sent_at = unix_now();
+ socket.send(&request).ok()?;
+
+ let mut response = [0u8; 48];
+ let len = socket.recv(&mut response).ok()?;
+ let received_at = unix_now();
+ if len < 48 || response[0] & 0x07 != 4 {
+ // Not a server-mode reply.
+ return None;
+ }
+
+ // Transmit timestamp: seconds since 1900 plus a 32-bit binary fraction.
+ let seconds = u32::from_be_bytes(response[40..44].try_into().ok()?) as f64;
+ let fraction =
+ u32::from_be_bytes(response[44..48].try_into().ok()?) as f64 / NTP_FRACTION_SCALE;
+ let server_time = seconds + fraction - NTP_UNIX_OFFSET;
+ if server_time <= 0.0 {
+ return None;
+ }
+ Some((sent_at + received_at) / 2.0 - server_time)
+}
+
+fn unix_now() -> f64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs_f64())
+ .unwrap_or(0.0)
+}
+
+/// systemd-timesyncd's opinion; false on hosts without timedatectl.
+fn timedatectl_says_synchronized() -> bool {
+ Command::new("timedatectl")
+ .args(["show", "-p", "NTPSynchronized", "--value"])
+ .output()
+ .map(|output| output.status.success() && output.stdout.trim_ascii() == b"yes")
+ .unwrap_or(false)
+}