1use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
12pub struct SampleReport {
13 pub offset_s: f64,
15 pub delay_s: f64,
17 pub stratum: u8,
19 pub root_delay_s: f64,
21 pub root_dispersion_s: f64,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
36pub enum ControlEndpoint {
37 UnixPath(String),
39 Loopback(u16),
41}
42
43const DERIVED_PORT_BASE: u16 = 49_200;
46const DERIVED_PORT_SPAN: u16 = 300;
47
48pub 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
68pub fn control_endpoint(spec: &str) -> ControlEndpoint {
70 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
84fn 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
97pub struct NtsReport {
98 pub ke_host: String,
100 pub ntp_server: String,
102 pub ntp_port: u16,
103 pub authenticated: u32,
105 pub rejected: u32,
107 pub cookies_after: usize,
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
113pub struct QueryReport {
114 pub server: String,
115 pub address: String,
116 pub sent: u32,
118 pub received: u32,
119 pub samples: Vec<SampleReport>,
120 pub best_offset_s: Option<f64>,
122 pub best_delay_s: Option<f64>,
123 pub regress_offset_s: Option<f64>,
125 pub regress_freq_ppm: Option<f64>,
126 pub regress_sd_s: Option<f64>,
127 pub reference_id: String,
129 pub leap: String,
130 #[serde(skip_serializing_if = "Option::is_none", default)]
132 pub nts: Option<NtsReport>,
133}
134
135#[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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
162pub struct ClientRow {
163 pub address: String,
164 pub last_seen_ago_s: f64,
166 pub requests: u64,
167 pub responses: u64,
168 pub dropped: u64,
169 pub interleaved: bool,
171}
172
173#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
176#[serde(tag = "op", rename_all = "snake_case")]
177pub enum ControlRequest {
178 ServerStats,
180 Clients { limit: usize },
182 NtsData,
184 Ping,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
190#[serde(tag = "result", rename_all = "snake_case")]
191pub enum ControlResponse {
192 ServerStats(ServerStatsReport),
193 Clients {
198 rows: Vec<ClientRow>,
199 },
200 NtsData {
201 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 assert!(json.contains("\"best_offset_s\""));
246 assert!(!json.contains("\"nts\""));
248 }
249
250 #[test]
251 fn the_default_spec_resolves_the_same_way_for_everyone() {
252 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 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 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 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 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}