Skip to main content

rusty_time_api/
lib.rs

1//! rusty_time-api — typed reports and ops.
2//!
3//! Everything a human sees through `rtimec` or `rtimed --json` is one of these
4//! types serialized as JSON: the CLI is a thin consumer, and a test or an agent
5//! is the same consumer with a different transport (mission plan §5). Internal
6//! wire moves to oxicode at M4; the public shape stays JSON.
7
8use serde::{Deserialize, Serialize};
9
10/// One measured exchange, as reported by `rtimed query`.
11#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
12pub struct SampleReport {
13    /// Seconds to ADD to the local clock.
14    pub offset_s: f64,
15    /// Round-trip delay, seconds.
16    pub delay_s: f64,
17    /// Server stratum.
18    pub stratum: u8,
19    /// Server-reported root delay + dispersion, seconds.
20    pub root_delay_s: f64,
21    pub root_dispersion_s: f64,
22}
23
24/// Where the control plane actually listens, resolved from what the operator
25/// typed.
26///
27/// Unix has domain sockets, Windows does not (its equivalent is a named pipe,
28/// which lands with the Win32 pipe server). Rather than make every script and
29/// CI job branch on the platform, the *same* `--control` argument resolves on
30/// both: a path becomes a deterministic loopback port on Windows, derived from
31/// the path text so the daemon and `rtimec` independently agree on it.
32///
33/// The daemon prints the resolved endpoint at startup, so the mapping is
34/// visible rather than a silent surprise.
35#[derive(Clone, Debug, PartialEq, Eq, Hash)]
36pub enum ControlEndpoint {
37    /// A Unix domain socket at this path.
38    UnixPath(String),
39    /// A TCP endpoint on loopback.
40    Loopback(u16),
41}
42
43/// Loopback ports we derive into: the IANA dynamic range, avoiding anything an
44/// OS is likely to hand out for an ephemeral connection.
45const DERIVED_PORT_BASE: u16 = 49_200;
46const DERIVED_PORT_SPAN: u16 = 300;
47
48/// The default control name.
49///
50/// Defined here, once, because the daemon and `rtimec` must agree: on Windows
51/// the name is hashed into a port, so two *different* default strings would
52/// resolve to two different ports and `rtimec` would quietly fail to find a
53/// daemon that is running perfectly well.
54pub fn default_control_spec() -> String {
55    #[cfg(windows)]
56    {
57        "rusty_time".to_string()
58    }
59    #[cfg(not(windows))]
60    {
61        match std::env::var("XDG_RUNTIME_DIR") {
62            Ok(dir) if !dir.is_empty() => format!("{dir}/rusty_time.sock"),
63            _ => "/tmp/rusty_time.sock".to_string(),
64        }
65    }
66}
67
68/// Resolve a `--control` argument for this platform.
69pub fn control_endpoint(spec: &str) -> ControlEndpoint {
70    // An explicit host:port is honoured everywhere.
71    if let Some((_, port)) = spec.rsplit_once(':')
72        && let Ok(port) = port.parse::<u16>()
73    {
74        return ControlEndpoint::Loopback(port);
75    }
76
77    if cfg!(windows) {
78        ControlEndpoint::Loopback(derive_port(spec))
79    } else {
80        ControlEndpoint::UnixPath(spec.to_string())
81    }
82}
83
84/// A stable port for a given control name. FNV-1a: tiny, dependency-free, and
85/// — the property that matters — identical in both processes and across runs.
86fn derive_port(spec: &str) -> u16 {
87    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
88    for byte in spec.as_bytes() {
89        hash ^= *byte as u64;
90        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
91    }
92    DERIVED_PORT_BASE + (hash % DERIVED_PORT_SPAN as u64) as u16
93}
94
95/// What NTS did during a query (`status.ntsdata`'s client half).
96#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
97pub struct NtsReport {
98    /// The host key establishment ran against.
99    pub ke_host: String,
100    /// The NTP server KE pointed us at — may differ from `ke_host`.
101    pub ntp_server: String,
102    pub ntp_port: u16,
103    /// Responses that passed AEAD verification.
104    pub authenticated: u32,
105    /// Responses dropped because they did not (forged, stale cookie, NAK).
106    pub rejected: u32,
107    /// Unspent cookies remaining when the query finished.
108    pub cookies_after: usize,
109}
110
111/// The result of a one-shot `rtimed query`.
112#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
113pub struct QueryReport {
114    pub server: String,
115    pub address: String,
116    /// Exchanges attempted and completed.
117    pub sent: u32,
118    pub received: u32,
119    pub samples: Vec<SampleReport>,
120    /// Minimum-delay sample's offset — the headline number.
121    pub best_offset_s: Option<f64>,
122    pub best_delay_s: Option<f64>,
123    /// Regression view when enough samples exist.
124    pub regress_offset_s: Option<f64>,
125    pub regress_freq_ppm: Option<f64>,
126    pub regress_sd_s: Option<f64>,
127    /// Reference ID of the server (textual for stratum 1, hex otherwise).
128    pub reference_id: String,
129    pub leap: String,
130    /// Present only when the query ran under NTS.
131    #[serde(skip_serializing_if = "Option::is_none", default)]
132    pub nts: Option<NtsReport>,
133}
134
135/// Daemon tracking state (the `status.tracking` op).
136#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
137pub struct TrackingReport {
138    pub synchronized: bool,
139    pub offset_s: f64,
140    pub freq_ppm: f64,
141    pub error_bound_s: f64,
142    pub poll_log2: i8,
143}
144
145/// Server counters (`status.serverstats`) — the chronyc `serverstats` analog.
146#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
147pub struct ServerStatsReport {
148    pub ntp_requests: u64,
149    pub ntp_responses: u64,
150    pub dropped_rate_limit: u64,
151    pub kiss_of_death: u64,
152    pub interleaved_responses: u64,
153    pub refused: u64,
154    pub clients_tracked: usize,
155    pub clients_evicted: u64,
156    pub uptime_s: u64,
157    pub stratum: u8,
158}
159
160/// One row of the MRU client log (`debug.clients`).
161#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
162pub struct ClientRow {
163    pub address: String,
164    /// Seconds since this client was last seen.
165    pub last_seen_ago_s: f64,
166    pub requests: u64,
167    pub responses: u64,
168    pub dropped: u64,
169    /// Whether this client is currently using interleaved mode.
170    pub interleaved: bool,
171}
172
173/// A request on the control socket. One variant per op (mission plan §5): the
174/// CLI, a test and an agent are all just clients of these.
175#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
176#[serde(tag = "op", rename_all = "snake_case")]
177pub enum ControlRequest {
178    /// `status.serverstats`
179    ServerStats,
180    /// `debug.clients`
181    Clients { limit: usize },
182    /// `status.ntsdata` — key ids only, never key material.
183    NtsData,
184    /// Liveness.
185    Ping,
186}
187
188/// The answer to a [`ControlRequest`].
189#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
190#[serde(tag = "result", rename_all = "snake_case")]
191pub enum ControlResponse {
192    ServerStats(ServerStatsReport),
193    /// A struct variant, not `Clients(Vec<..>)`, deliberately: serde's
194    /// internally-tagged representation cannot encode a newtype variant that
195    /// wraps a *sequence*, and the failure appears only at serialization —
196    /// the op works in-process and returns an empty reply over the socket.
197    Clients {
198        rows: Vec<ClientRow>,
199    },
200    NtsData {
201        /// Master key identifiers currently held. Key material is never
202        /// serialized — an operator needs to know rotation happened, not what
203        /// the keys are.
204        master_key_ids: Vec<u32>,
205    },
206    Pong {
207        version: String,
208    },
209    Error {
210        message: String,
211    },
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn query_report_json_shape_is_stable() {
220        let r = QueryReport {
221            server: "pool.ntp.org".into(),
222            address: "1.2.3.4:123".into(),
223            sent: 4,
224            received: 4,
225            samples: vec![SampleReport {
226                offset_s: 0.0012,
227                delay_s: 0.031,
228                stratum: 2,
229                root_delay_s: 0.01,
230                root_dispersion_s: 0.002,
231            }],
232            best_offset_s: Some(0.0012),
233            best_delay_s: Some(0.031),
234            regress_offset_s: None,
235            regress_freq_ppm: None,
236            regress_sd_s: None,
237            reference_id: "c0a80101".into(),
238            leap: "no-warning".into(),
239            nts: None,
240        };
241        let json = serde_json::to_string(&r).expect("serialize");
242        let back: QueryReport = serde_json::from_str(&json).expect("deserialize");
243        assert_eq!(r, back);
244        // Public wire fields are snake_case with explicit units in the name.
245        assert!(json.contains("\"best_offset_s\""));
246        // A plain query must not emit an empty nts object.
247        assert!(!json.contains("\"nts\""));
248    }
249
250    #[test]
251    fn the_default_spec_resolves_the_same_way_for_everyone() {
252        // The daemon and rtimec each call this independently. If they ever
253        // produced different strings, Windows would hash them to different
254        // ports and rtimec would report "is rtimed running?" about a daemon
255        // that is running fine.
256        let a = control_endpoint(&default_control_spec());
257        let b = control_endpoint(&default_control_spec());
258        assert_eq!(a, b);
259    }
260
261    #[test]
262    fn an_explicit_port_is_honoured_on_every_platform() {
263        assert_eq!(
264            control_endpoint("127.0.0.1:9999"),
265            ControlEndpoint::Loopback(9999)
266        );
267    }
268
269    #[test]
270    fn the_same_path_resolves_identically_in_both_processes() {
271        // The daemon and rtimec each resolve independently; if they disagreed,
272        // rtimec would connect to a port nothing is listening on.
273        let a = control_endpoint("/run/rusty_time.sock");
274        let b = control_endpoint("/run/rusty_time.sock");
275        assert_eq!(a, b, "resolution must be deterministic");
276    }
277
278    #[test]
279    fn different_names_get_different_endpoints() {
280        // Two daemons with different control names must not collide, or the
281        // second would fail to bind and the first would answer for both.
282        let mut seen = std::collections::HashSet::new();
283        let names = [
284            "/run/rusty_time.sock",
285            "/tmp/a.sock",
286            "/tmp/b.sock",
287            "rusty_time",
288            "test-rig-1",
289            "test-rig-2",
290        ];
291        for name in names {
292            seen.insert(control_endpoint(name));
293        }
294        assert!(
295            seen.len() >= names.len() - 1,
296            "control names collided: {seen:?}"
297        );
298    }
299
300    #[test]
301    fn derived_ports_stay_in_the_intended_range() {
302        for name in ["a", "b", "/very/long/path/to/a/socket", ""] {
303            let port = derive_port(name);
304            assert!(
305                (DERIVED_PORT_BASE..DERIVED_PORT_BASE + DERIVED_PORT_SPAN).contains(&port),
306                "{name} derived out-of-range port {port}"
307            );
308        }
309    }
310
311    #[cfg(unix)]
312    #[test]
313    fn a_path_stays_a_unix_socket_on_unix() {
314        assert_eq!(
315            control_endpoint("/run/rusty_time.sock"),
316            ControlEndpoint::UnixPath("/run/rusty_time.sock".into())
317        );
318    }
319
320    #[test]
321    fn control_ops_round_trip_over_the_wire() {
322        // Every op must survive the JSON hop unchanged: rtimec, a test and an
323        // agent are the same client with different transports.
324        let requests = vec![
325            ControlRequest::Ping,
326            ControlRequest::ServerStats,
327            ControlRequest::Clients { limit: 10 },
328            ControlRequest::NtsData,
329        ];
330        for req in requests {
331            let json = serde_json::to_string(&req).expect("serialize");
332            let back: ControlRequest = serde_json::from_str(&json).expect("deserialize");
333            assert_eq!(req, back, "op did not survive the wire: {json}");
334        }
335
336        let responses = vec![
337            ControlResponse::Pong {
338                version: "0.1.0".into(),
339            },
340            ControlResponse::ServerStats(ServerStatsReport {
341                ntp_requests: 10,
342                ntp_responses: 8,
343                dropped_rate_limit: 2,
344                ..ServerStatsReport::default()
345            }),
346            ControlResponse::Clients {
347                rows: vec![ClientRow {
348                    address: "192.0.2.1:123".into(),
349                    last_seen_ago_s: 1.5,
350                    requests: 3,
351                    responses: 3,
352                    dropped: 0,
353                    interleaved: true,
354                }],
355            },
356            ControlResponse::NtsData {
357                master_key_ids: vec![1, 2],
358            },
359            ControlResponse::Error {
360                message: "nope".into(),
361            },
362        ];
363        for resp in responses {
364            let json = serde_json::to_string(&resp).expect("serialize");
365            let back: ControlResponse = serde_json::from_str(&json).expect("deserialize");
366            assert_eq!(resp, back);
367        }
368    }
369
370    #[test]
371    fn nts_data_never_carries_key_material() {
372        // The type makes it unrepresentable: there is nowhere to put a key.
373        let resp = ControlResponse::NtsData {
374            master_key_ids: vec![0xDEAD_BEEF],
375        };
376        let json = serde_json::to_string(&resp).expect("serialize");
377        assert!(json.contains("master_key_ids"));
378        assert!(!json.contains("key\":\"") && !json.to_lowercase().contains("secret"));
379    }
380
381    #[test]
382    fn nts_report_round_trips() {
383        let mut r = QueryReport {
384            server: "time.cloudflare.com".into(),
385            address: "1.1.1.1:123".into(),
386            sent: 4,
387            received: 4,
388            samples: Vec::new(),
389            best_offset_s: Some(-0.002),
390            best_delay_s: Some(0.02),
391            regress_offset_s: None,
392            regress_freq_ppm: None,
393            regress_sd_s: None,
394            reference_id: "0a0a0a0a".into(),
395            leap: "no-warning".into(),
396            nts: Some(NtsReport {
397                ke_host: "time.cloudflare.com".into(),
398                ntp_server: "time.cloudflare.com".into(),
399                ntp_port: 123,
400                authenticated: 4,
401                rejected: 0,
402                cookies_after: 8,
403            }),
404        };
405        let json = serde_json::to_string(&r).expect("serialize");
406        assert!(json.contains("\"authenticated\":4"));
407        let back: QueryReport = serde_json::from_str(&json).expect("deserialize");
408        assert_eq!(r, back);
409        r.nts = None;
410        assert_ne!(r, back);
411    }
412}