1use 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
15pub struct NtpProtocol {
22 request_origin_timestamp: Option<u64>,
27 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 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
53impl 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()
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 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 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 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
169impl 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 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 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 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 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 timestamp > 0xFFFFFFFF {
277 timestamp
279 } else {
280 let ntp_seconds = timestamp + 2_208_988_800;
283 ntp_seconds << 32 }
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; ntp_seconds << 32 }
300
301 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 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 #[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 let li = (leap_indicator & 0x03) << 6; let vn = (version & 0x07) << 3; let mode = 0x04; packet[0] = li | vn | mode;
370
371 packet[1] = stratum;
373
374 packet[2] = poll;
376
377 packet[3] = precision as u8;
379
380 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 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 let ref_id_bytes = if reference_id.is_empty() {
390 [0u8; 4] } 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 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; let fraction = (ntp_time & 0xFFFFFFFF) as u32; 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 };
410
411 write_timestamp(
413 &mut packet,
414 16,
415 reference_timestamp.or_else(|| Some(Self::get_current_ntp_time())),
416 );
417
418 write_timestamp(&mut packet, 24, origin_timestamp);
423
424 write_timestamp(
426 &mut packet,
427 32,
428 receive_timestamp.or_else(|| Some(Self::get_current_ntp_time())),
429 );
430
431 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
442pub fn build_kod_packet(version: u8, origin_timestamp: Option<u64>, kiss_code: &str) -> Vec<u8> {
463 NtpProtocol::build_ntp_packet(
464 version,
465 3, 0, 0, 0, 0.0, 0.0, kiss_code, None, origin_timestamp,
474 None, None, )
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
602pub 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
660pub fn get_ntp_event_types() -> Vec<EventType> {
662 vec![NTP_REQUEST_EVENT.clone()]
663}