Skip to main content

netget/server/ntp/
actions.rs

1//! NTP protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use crate::utils::clock::{SystemTime, UNIX_EPOCH};
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15/// NTP protocol handler.
16///
17/// The server builds one instance per received datagram (see
18/// [`crate::server::ntp::NtpServer::spawn_with_llm_actions`]) so the request's own
19/// transmit timestamp and version travel with the actions generated for it. The
20/// registry's instance is created with [`NtpProtocol::new`] and carries no request.
21pub struct NtpProtocol {
22    /// The client's transmit timestamp, as the raw 64-bit NTP value taken from bytes
23    /// 40-47 of the request. RFC 5905 requires the server to copy it into the reply's
24    /// origin timestamp; a client that does not find its own value there discards the
25    /// reply, which looks exactly like a timeout.
26    request_origin_timestamp: Option<u64>,
27    /// NTP version the client used, echoed back in the reply's first byte.
28    request_version: u8,
29}
30
31impl NtpProtocol {
32    pub fn new() -> Self {
33        Self {
34            request_origin_timestamp: None,
35            request_version: 4,
36        }
37    }
38
39    /// Build a handler bound to one request: `origin_timestamp` is the client's raw
40    /// 64-bit transmit timestamp and `version` the NTP version it used (1-4).
41    pub fn for_request(origin_timestamp: Option<u64>, version: u8) -> Self {
42        Self {
43            request_origin_timestamp: origin_timestamp,
44            request_version: if (1..=4).contains(&version) {
45                version
46            } else {
47                4
48            },
49        }
50    }
51}
52
53// Implement Protocol trait (common functionality)
54impl Protocol for NtpProtocol {
55    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
56        Vec::new()
57    }
58    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
59        vec![
60            send_ntp_time_response_action(),
61            send_ntp_response_action(),
62            ignore_request_action(),
63        ]
64    }
65    fn protocol_name(&self) -> &'static str {
66        "NTP"
67    }
68    fn get_event_types(&self) -> Vec<EventType> {
69        get_ntp_event_types()
70    }
71    fn stack_name(&self) -> &'static str {
72        "ETH>IP>UDP>NTP"
73    }
74    fn keywords(&self) -> Vec<&'static str> {
75        vec!["ntp", "time"]
76    }
77    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
78        use crate::protocol::metadata::{
79            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
80        };
81
82        ProtocolMetadataV2::builder()
83            .connectionless()
84            // Answers on failure: a Kiss-o'-Death (RATE when overloaded, INIT otherwise), never
85            // the mechanical time response.
86            .answers_on_failure()
87            .state(DevelopmentState::Beta)
88            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(123))
89            .well_known_udp_port(123)
90            .implementation("Manual 48-byte NTP packet construction")
91            .llm_control("Optional: normal time responses are static by default (mechanical), LLM only on opt-in")
92            .e2e_testing(
93                "rsntp, a third-party SNTP client, in tests/server/ntp/test.rs -- not \
94                 #[ignore]d and not skip-gated (rsntp is a compiled-in dependency). It must \
95                 SUCCEED: it validates the origin-timestamp echo, the mode and the leap \
96                 indicator, which are the three things a real client rejects a reply over. The \
97                 raw 48-byte path alongside it decodes every field by hand against RFC 5905, \
98                 and llm_failure_test.rs / decision_tag_test.rs assert the Kiss-o'-Death on the \
99                 fail-closed paths, stratum included, so the wire and the log cannot drift \
100                 apart. \
101                 ONE CLIENT, and a second is blocked by the protocol rather than by effort. \
102                 The reference clients are sntp and ntpdate (ntp 4.2.8), both installed on this \
103                 machine, and NEITHER accepts a port: sntp takes a bare hostname-or-IP with no \
104                 -p, ntpdate's usage line has none either, and both reject `127.0.0.1:12345` as \
105                 an unresolvable name. A test binds an ephemeral port, so neither can be aimed \
106                 at it without running the server on 123 as root. This is the same shape as \
107                 dhcp, whose own metadata records that dhclient and ipconfig bind UDP/68, need \
108                 root and cannot target an ephemeral loopback port. \
109                 UNPROVEN: NTPv5, extension fields, authentication (none is implemented), \
110                 broadcast and symmetric modes, and the NTP control protocol.",
111            )
112            .notes("Client/server mode only (mode 3 -> mode 4). A normal time response is mechanical (stratum 2, LOCL, current-time timestamps, origin+version echoed from the request), so it is answered STATICALLY with no LLM round-trip by default. The LLM is consulted only when the operator opts in with a server instruction or per-event handler — the way to make the server skew or lie about the time. On LLM failure in opt-in mode the server falls back to the correct static time response. Sub-ms with scripting")
113            .build()
114    }
115    fn description(&self) -> &'static str {
116        "Network Time Protocol server for time synchronization"
117    }
118    fn example_prompt(&self) -> &'static str {
119        "pretend to be a ntp server on port 123"
120    }
121    fn group_name(&self) -> &'static str {
122        "Core"
123    }
124    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
125        use crate::llm::actions::StartupExamples;
126        StartupExamples::new(
127            // LLM-driven example
128            json!({
129                "type": "open_server",
130                "port": 123,
131                "base_stack": "ntp",
132                "instruction": "NTP server responding with current system time as stratum 2"
133            }),
134            // Script-based example
135            json!({
136                "type": "open_server",
137                "port": 123,
138                "base_stack": "ntp",
139                "event_handlers": [{
140                    "event_pattern": "ntp_request",
141                    "handler": {
142                        "type": "script",
143                        "language": "python",
144                        "code": "# Return NTP time response\nrespond([{'type': 'send_ntp_time_response', 'stratum': 2, 'reference_id': 'LOCL'}])"
145                    }
146                }]
147            }),
148            // Static handler example
149            json!({
150                "type": "open_server",
151                "port": 123,
152                "base_stack": "ntp",
153                "event_handlers": [{
154                    "event_pattern": "ntp_request",
155                    "handler": {
156                        "type": "static",
157                        "actions": [{
158                            "type": "send_ntp_time_response",
159                            "stratum": 2,
160                            "reference_id": "LOCL"
161                        }]
162                    }
163                }]
164            }),
165        )
166    }
167}
168
169// Implement Server trait (server-specific functionality)
170impl Server for NtpProtocol {
171    fn spawn(
172        &self,
173        ctx: crate::protocol::SpawnContext,
174    ) -> std::pin::Pin<
175        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
176    > {
177        Box::pin(async move {
178            use crate::server::ntp::NtpServer;
179            NtpServer::spawn_with_llm_actions(
180                ctx.legacy_listen_addr(),
181                ctx.llm_client,
182                ctx.state,
183                ctx.status_tx,
184                ctx.server_id,
185            )
186            .await
187        })
188    }
189    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
190        let action_type = action
191            .get("type")
192            .and_then(|v| v.as_str())
193            .context("Missing 'type' field in action")?;
194
195        match action_type {
196            "send_ntp_time_response" => self.execute_send_ntp_time_response(action),
197            "send_ntp_response" => self.execute_send_ntp_response(action),
198            "ignore_request" => Ok(ActionResult::NoAction),
199            _ => Err(anyhow::anyhow!("Unknown NTP action: {}", action_type)),
200        }
201    }
202}
203
204impl NtpProtocol {
205    fn execute_send_ntp_time_response(&self, action: serde_json::Value) -> Result<ActionResult> {
206        // Extract all NTP fields from the action
207        let stratum = action.get("stratum").and_then(|v| v.as_u64()).unwrap_or(2) as u8;
208
209        let reference_id = action
210            .get("reference_id")
211            .and_then(|v| v.as_str())
212            .unwrap_or("");
213
214        let leap_indicator = action
215            .get("leap_indicator")
216            .and_then(|v| v.as_u64())
217            .unwrap_or(0) as u8;
218
219        let poll = action.get("poll").and_then(|v| v.as_u64()).unwrap_or(6) as u8;
220
221        let precision = action
222            .get("precision")
223            .and_then(|v| v.as_i64())
224            .unwrap_or(-20) as i8;
225
226        let root_delay = action
227            .get("root_delay")
228            .and_then(|v| v.as_f64())
229            .unwrap_or(0.0);
230
231        let root_dispersion = action
232            .get("root_dispersion")
233            .and_then(|v| v.as_f64())
234            .unwrap_or(0.0);
235
236        // Timestamps - support "current_time", unix timestamp (seconds), or null
237        let reference_timestamp = Self::parse_timestamp(action.get("reference_timestamp"));
238        let receive_timestamp = Self::parse_timestamp(action.get("receive_timestamp"));
239        let transmit_timestamp = Self::parse_timestamp(action.get("transmit_timestamp"));
240
241        // Origin timestamp: whatever the action asked for, else the client's own transmit
242        // timestamp captured from this request. Falling back to "now" would produce a
243        // reply the client rejects, so an unknown origin stays zero.
244        let origin_timestamp = match action.get("origin_timestamp") {
245            Some(serde_json::Value::Null) | None => self.request_origin_timestamp,
246            Some(v) => Self::parse_timestamp(Some(v)).or(self.request_origin_timestamp),
247        };
248
249        // Build NTP response packet
250        let packet = Self::build_ntp_packet(
251            self.request_version,
252            leap_indicator,
253            stratum,
254            poll,
255            precision,
256            root_delay,
257            root_dispersion,
258            reference_id,
259            reference_timestamp,
260            origin_timestamp,
261            receive_timestamp,
262            transmit_timestamp,
263        );
264        Ok(ActionResult::Output(packet))
265    }
266
267    fn parse_timestamp(value: Option<&serde_json::Value>) -> Option<u64> {
268        match value {
269            Some(serde_json::Value::String(s)) if s == "current_time" => {
270                Some(Self::get_current_ntp_time())
271            }
272            Some(serde_json::Value::Number(n)) => {
273                n.as_u64().map(|timestamp| {
274                    // If value is > 2^32 (4,294,967,296), it's a full 64-bit NTP timestamp (seconds + fraction)
275                    // Otherwise, it's a Unix timestamp (seconds only) that needs conversion
276                    if timestamp > 0xFFFFFFFF {
277                        // Raw NTP timestamp (64-bit: 32-bit seconds + 32-bit fraction)
278                        timestamp
279                    } else {
280                        // Unix timestamp (seconds since 1970) - convert to NTP timestamp (seconds part only)
281                        // Note: This loses fractional precision, but LLM typically provides whole seconds
282                        let ntp_seconds = timestamp + 2_208_988_800;
283                        ntp_seconds << 32 // Shift to upper 32 bits, fraction = 0
284                    }
285                })
286            }
287            Some(serde_json::Value::Null) | None => None,
288            _ => None,
289        }
290    }
291
292    fn get_current_ntp_time() -> u64 {
293        let now = SystemTime::now()
294            .duration_since(UNIX_EPOCH)
295            .unwrap()
296            .as_secs();
297        let ntp_seconds = now + 2_208_988_800; // Unix epoch to NTP epoch offset
298        ntp_seconds << 32 // Return 64-bit timestamp: seconds in upper 32 bits, fraction=0 in lower 32 bits
299    }
300
301    /// Decode the raw-packet escape hatch.
302    ///
303    /// `data` is documented as hex, so it is decoded strictly as hex. Falling back to the
304    /// string's own bytes (the previous behaviour) silently put ASCII on the wire whenever
305    /// the model produced slightly malformed hex, and an NTP client cannot read that.
306    fn execute_send_ntp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
307        let data = action
308            .get("data")
309            .and_then(|v| v.as_str())
310            .context("Missing 'data' parameter")?;
311
312        // Models routinely emit "0x" prefixes and byte separators; strip them first.
313        let cleaned: String = data
314            .chars()
315            .filter(|c| !c.is_ascii_whitespace() && *c != ':')
316            .collect();
317        let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
318
319        if cleaned.len() % 2 != 0 {
320            return Err(anyhow::anyhow!(
321                "Invalid hex in 'data': {} hex digits is an odd number, and every byte is two \
322                 digits. Value was {data:?}",
323                cleaned.len()
324            ));
325        }
326
327        let bytes = hex::decode(cleaned).map_err(|e| {
328            anyhow::anyhow!(
329                "Invalid hex in 'data' ({data:?}): {e}. Use only 0-9 and a-f, two digits per byte. \
330                 This field is a raw packet - there is no text mode."
331            )
332        })?;
333
334        if bytes.len() < 48 {
335            return Err(anyhow::anyhow!(
336                "'data' decodes to {} bytes; an NTP packet is at least 48 (96 hex characters). \
337                 Prefer send_ntp_time_response, which builds a correct packet for you.",
338                bytes.len()
339            ));
340        }
341
342        Ok(ActionResult::Output(bytes))
343    }
344
345    /// Build a valid NTP response packet
346    #[allow(clippy::too_many_arguments)]
347    pub(crate) fn build_ntp_packet(
348        version: u8,
349        leap_indicator: u8,
350        stratum: u8,
351        poll: u8,
352        precision: i8,
353        root_delay: f64,
354        root_dispersion: f64,
355        reference_id: &str,
356        reference_timestamp: Option<u64>,
357        origin_timestamp: Option<u64>,
358        receive_timestamp: Option<u64>,
359        transmit_timestamp: Option<u64>,
360    ) -> Vec<u8> {
361        let mut packet = vec![0u8; 48];
362
363        // Byte 0: LI (2 bits), Version (3 bits), Mode=4 (3 bits, server).
364        // RFC 5905 says a server answers in the version the client used, so this echoes
365        // the request's version rather than hardcoding 4.
366        let li = (leap_indicator & 0x03) << 6; // LI in bits 7-6
367        let vn = (version & 0x07) << 3; // Version in bits 5-3
368        let mode = 0x04; // Mode 4 (server) in bits 2-0
369        packet[0] = li | vn | mode;
370
371        // Byte 1: Stratum
372        packet[1] = stratum;
373
374        // Byte 2: Poll interval (log2 seconds)
375        packet[2] = poll;
376
377        // Byte 3: Precision (log2 seconds, signed 8-bit)
378        packet[3] = precision as u8;
379
380        // Bytes 4-7: Root delay (32-bit fixed point: 16 bits integer, 16 bits fraction)
381        let root_delay_fixed = (root_delay * 65536.0) as u32;
382        packet[4..8].copy_from_slice(&root_delay_fixed.to_be_bytes());
383
384        // Bytes 8-11: Root dispersion (32-bit fixed point: 16 bits integer, 16 bits fraction)
385        let root_dispersion_fixed = (root_dispersion * 65536.0) as u32;
386        packet[8..12].copy_from_slice(&root_dispersion_fixed.to_be_bytes());
387
388        // Bytes 12-15: Reference ID (4-byte ASCII identifier)
389        let ref_id_bytes = if reference_id.is_empty() {
390            [0u8; 4] // Zeros if not specified
391        } else {
392            let mut bytes = [0u8; 4];
393            for (i, b) in reference_id.bytes().take(4).enumerate() {
394                bytes[i] = b;
395            }
396            bytes
397        };
398        packet[12..16].copy_from_slice(&ref_id_bytes);
399
400        // Helper to write timestamp (64-bit: upper 32 bits = seconds, lower 32 bits = fraction)
401        let write_timestamp = |packet: &mut [u8], offset: usize, timestamp: Option<u64>| {
402            if let Some(ntp_time) = timestamp {
403                let seconds = ((ntp_time >> 32) & 0xFFFFFFFF) as u32; // Upper 32 bits
404                let fraction = (ntp_time & 0xFFFFFFFF) as u32; // Lower 32 bits
405                packet[offset..offset + 4].copy_from_slice(&seconds.to_be_bytes());
406                packet[offset + 4..offset + 8].copy_from_slice(&fraction.to_be_bytes());
407            }
408            // else: leave as zeros
409        };
410
411        // Reference timestamp (bytes 16-23) - when the clock was last set
412        write_timestamp(
413            &mut packet,
414            16,
415            reference_timestamp.or_else(|| Some(Self::get_current_ntp_time())),
416        );
417
418        // Origin timestamp (bytes 24-31) - a verbatim copy of the client's transmit
419        // timestamp. Left as zeros when this request had none (a short or malformed
420        // packet): zeros at least make the mismatch obvious, whereas writing the current
421        // time would claim the client sent something it did not.
422        write_timestamp(&mut packet, 24, origin_timestamp);
423
424        // Receive timestamp (bytes 32-39) - when we received the request
425        write_timestamp(
426            &mut packet,
427            32,
428            receive_timestamp.or_else(|| Some(Self::get_current_ntp_time())),
429        );
430
431        // Transmit timestamp (bytes 40-47) - when we send the response
432        write_timestamp(
433            &mut packet,
434            40,
435            transmit_timestamp.or_else(|| Some(Self::get_current_ntp_time())),
436        );
437
438        packet
439    }
440}
441
442/// Build a Kiss-o'-Death packet (RFC 5905 §7.4) for a request we cannot answer.
443///
444/// NTP has no error message, but it does have a defined way for a server to say
445/// "do not use me": stratum 0, leap indicator 3 (unsynchronized), and a
446/// four-character kiss code in the reference identifier. `chrony`, `ntpd` and
447/// `ntpdate` all recognise it, refuse to take time from the packet, and stop
448/// polling — which is exactly right when the backend that would have decided the
449/// answer is unavailable. Writing nothing instead leaves the client retrying
450/// against a server it believes is merely slow.
451///
452/// It also fails closed: a KoD can never be mistaken for a time sample, so an
453/// outage cannot silently hand a client a fabricated clock reading.
454///
455/// The client's transmit timestamp is echoed as the origin timestamp for the
456/// same reason a normal reply echoes it — a reply that fails that check is
457/// discarded, which would put us back at silence.
458///
459/// `kiss_code` should be one of the registered codes; this server uses `RATE`
460/// (reduce your polling rate) when the failure is capacity exhaustion and `INIT`
461/// (association not yet synchronized) for every other failure.
462pub fn build_kod_packet(version: u8, origin_timestamp: Option<u64>, kiss_code: &str) -> Vec<u8> {
463    NtpProtocol::build_ntp_packet(
464        version,
465        3,         // LI = 3, unsynchronized
466        0,         // stratum 0 — this is what marks the packet as a KoD
467        0,         // poll
468        0,         // precision
469        0.0,       // root delay
470        0.0,       // root dispersion
471        kiss_code, // reference identifier carries the kiss code
472        None,      // reference timestamp: now
473        origin_timestamp,
474        None, // receive timestamp: now
475        None, // transmit timestamp: now
476    )
477}
478
479fn send_ntp_time_response_action() -> ActionDefinition {
480    ActionDefinition {
481        name: "send_ntp_time_response".to_string(),
482        description: "Send NTP time synchronization response. Most fields have sensible defaults, override only if instructed.".to_string(),
483        parameters: vec![
484            Parameter {
485                name: "leap_indicator".to_string(),
486                type_hint: "number".to_string(),
487                description: "Leap second warning: 0=no warning, 1=last minute has 61s, 2=last minute has 59s, 3=alarm/unsync. Default: 0".to_string(),
488                required: false,
489            },
490            Parameter {
491                name: "stratum".to_string(),
492                type_hint: "number".to_string(),
493                description: "Stratum level: 0=unspec, 1=primary (GPS/atomic), 2-15=secondary. Can be inferred from server instructions. Default: 2".to_string(),
494                required: false,
495            },
496            Parameter {
497                name: "poll".to_string(),
498                type_hint: "number".to_string(),
499                description: "Poll interval (log2 seconds): 4=16s, 6=64s, 10=1024s. Default: 6".to_string(),
500                required: false,
501            },
502            Parameter {
503                name: "precision".to_string(),
504                type_hint: "number".to_string(),
505                description: "Clock precision (log2 seconds): -6=~15ms, -20=~1us. Negative values. Default: -20".to_string(),
506                required: false,
507            },
508            Parameter {
509                name: "root_delay".to_string(),
510                type_hint: "number".to_string(),
511                description: "Total round-trip delay to primary reference (seconds, float). Default: 0.0".to_string(),
512                required: false,
513            },
514            Parameter {
515                name: "root_dispersion".to_string(),
516                type_hint: "number".to_string(),
517                description: "Max error relative to primary reference (seconds, float). Default: 0.0".to_string(),
518                required: false,
519            },
520            Parameter {
521                name: "reference_id".to_string(),
522                type_hint: "string".to_string(),
523                description: "4-char clock identifier: 'LOCL'=local, 'GPS.'=GPS, 'PPS.'=PPS, 'ATOM'=atomic, or IP address. Default: empty".to_string(),
524                required: false,
525            },
526            Parameter {
527                name: "reference_timestamp".to_string(),
528                type_hint: "string | number".to_string(),
529                description: "When clock was last set: 'current_time', Unix timestamp (seconds), or null. Default: current_time".to_string(),
530                required: false,
531            },
532            Parameter {
533                name: "origin_timestamp".to_string(),
534                type_hint: "number".to_string(),
535                description: "Leave this out. The server copies the client's own transmit timestamp from the request it is answering, which is what the client checks to accept the reply. Setting it to anything else (including the current time) makes the client discard the response and report a timeout. Only override it - with the exact 'client_transmit_timestamp' value from the event - when you are deliberately testing a mismatch".to_string(),
536                required: false,
537            },
538            Parameter {
539                name: "receive_timestamp".to_string(),
540                type_hint: "string | number".to_string(),
541                description: "When server received request: 'current_time', Unix timestamp, or null. Default: current_time".to_string(),
542                required: false,
543            },
544            Parameter {
545                name: "transmit_timestamp".to_string(),
546                type_hint: "string | number".to_string(),
547                description: "When server sends response: 'current_time', Unix timestamp, or null. Default: current_time".to_string(),
548                required: false,
549            },
550        ],
551        example: json!({
552            "type": "send_ntp_time_response",
553            "stratum": 2
554        }),
555        log_template: Some(
556            LogTemplate::new()
557                .with_info("-> NTP stratum {stratum}")
558                .with_debug("NTP send_ntp_time_response: stratum={stratum}"),
559        ),
560    }
561}
562
563fn send_ntp_response_action() -> ActionDefinition {
564    ActionDefinition {
565        name: "send_ntp_response".to_string(),
566        description: "Escape hatch: send an NTP packet you assembled yourself, byte for byte. Prefer send_ntp_time_response, which fills in the header and echoes the client's origin timestamp; use this only for packets that action cannot express (Kiss-o'-Death, deliberately malformed replies). Nothing is echoed for you here - bytes 24-31 must contain the client's own transmit timestamp or the client will discard the packet.".to_string(),
567        parameters: vec![Parameter {
568            name: "data".to_string(),
569            type_hint: "string".to_string(),
570            description: "The whole packet as hex, two digits per byte (spaces and ':' are allowed and ignored), at least 48 bytes = 96 hex characters. Decoded strictly as hex - it is never sent as text"
571                .to_string(),
572            required: true,
573        }],
574        example: json!({
575            "type": "send_ntp_response",
576            "data": "240201e900000000000000000000000000000000000000000000000000000000eca56dd14ae94680eca56dd14ae94680eca56dd14ae94680"
577        }),
578        log_template: Some(
579            LogTemplate::new()
580                .with_info("-> NTP raw response ({data_len}B)")
581                .with_debug("NTP send_ntp_response: {data_len} bytes"),
582        ),
583    }
584}
585
586fn ignore_request_action() -> ActionDefinition {
587    ActionDefinition {
588        name: "ignore_request".to_string(),
589        description: "Ignore this NTP request".to_string(),
590        parameters: vec![],
591        example: json!({
592            "type": "ignore_request"
593        }),
594        log_template: Some(
595            LogTemplate::new()
596                .with_info("NTP request ignored")
597                .with_debug("NTP ignore_request"),
598        ),
599    }
600}
601
602// ============================================================================
603// NTP Event Type Constants
604// ============================================================================
605
606/// NTP request event - triggered when NTP client sends a time request
607pub static NTP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
608    EventType::new("ntp_request", "NTP client sent a time synchronization request", json!({"type": "placeholder", "event_id": "ntp_request"}))
609    .with_parameters(vec![
610        Parameter {
611            name: "current_time".to_string(),
612            type_hint: "number".to_string(),
613            description: "The server's current time as a Unix timestamp (seconds since 1970), for reference when deciding what time to report".to_string(),
614            required: true,
615        },
616        Parameter {
617            name: "client_transmit_timestamp".to_string(),
618            type_hint: "number".to_string(),
619            description: "The client's transmit timestamp as the raw 64-bit NTP value (upper 32 bits seconds since 1900, lower 32 bits fraction). send_ntp_time_response copies it into the reply for you, so you do not need to pass it back - it is here only so a handler can inspect or deliberately alter it. Absent if the request was shorter than 48 bytes".to_string(),
620            required: false,
621        },
622        Parameter {
623            name: "client_transmit_unix".to_string(),
624            type_hint: "number".to_string(),
625            description: "The same client timestamp converted to whole Unix seconds, for readability. Lossy - never echo this one back as origin_timestamp".to_string(),
626            required: false,
627        },
628        Parameter {
629            name: "client_version".to_string(),
630            type_hint: "number".to_string(),
631            description: "NTP version the client used, 1-4. The reply is sent in the same version automatically".to_string(),
632            required: false,
633        },
634        Parameter {
635            name: "client_mode".to_string(),
636            type_hint: "number".to_string(),
637            description: "Mode field of the request: 3 is a normal client query. Other values (1, 2, 5, 6) are symmetric/broadcast/control packets that this server still answers as if they were client queries".to_string(),
638            required: false,
639        },
640        Parameter {
641            name: "bytes_received".to_string(),
642            type_hint: "number".to_string(),
643            description: "Size of the received datagram in bytes. A standard request is 48; larger means extension fields or an authentication MAC, which this server ignores".to_string(),
644            required: false,
645        },
646    ])
647    .with_actions(vec![
648        send_ntp_time_response_action(),
649        send_ntp_response_action(),
650        ignore_request_action(),
651    ])
652    .with_log_template(
653        LogTemplate::new()
654            .with_info("NTP request from {client_ip}")
655            .with_debug("NTP time sync request: {bytes_received}B from {client_ip}")
656            .with_trace("NTP request: {json_pretty(.)}"),
657    )
658});
659
660/// Get NTP event types
661pub fn get_ntp_event_types() -> Vec<EventType> {
662    vec![NTP_REQUEST_EVENT.clone()]
663}