netget/server/ntp/mod.rs
1//! NTP server implementation
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::Arc;
8use tokio::net::UdpSocket;
9use tokio::sync::mpsc;
10use tracing::{error, warn};
11
12use crate::llm::action_helper::call_llm;
13use crate::llm::actions::protocol_trait::{ActionResult, Server};
14use crate::llm::ollama_client::OllamaClient;
15use crate::logging::emit::Log;
16use crate::protocol::Event;
17use crate::server::NtpProtocol;
18use crate::state::app_state::AppState;
19use actions::NTP_REQUEST_EVENT;
20
21/// NTP server that forwards requests to LLM
22pub struct NtpServer;
23
24impl NtpServer {
25 /// Spawn NTP server with integrated LLM actions
26 pub async fn spawn_with_llm_actions(
27 listen_addr: SocketAddr,
28 llm_client: OllamaClient,
29 app_state: Arc<AppState>,
30 status_tx: mpsc::UnboundedSender<String>,
31 server_id: crate::state::ServerId,
32 ) -> Result<SocketAddr> {
33 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
34 let local_addr = socket.local_addr()?;
35 Log::new(Some(&status_tx)).info(format!("NTP server listening on {}", local_addr));
36
37 let task_registrar = app_state.clone();
38 let accept_handle = tokio::spawn(async move {
39 // Large enough for extension fields / an authentication MAC. Only the first
40 // 48 bytes are interpreted, but a short buffer would silently truncate the
41 // datagram and misreport its size to the model.
42 let mut buffer = vec![0u8; 1024];
43
44 loop {
45 match socket.recv_from(&mut buffer).await {
46 Ok((n, peer_addr)) => {
47 let data = buffer[..n].to_vec();
48 let connection_id =
49 ConnectionId::new(app_state.get_next_unified_id().await);
50
51 // Add connection to ServerInstance (NTP "connection" = recent client)
52 use crate::state::server::{
53 ConnectionState as ServerConnectionState, ConnectionStatus,
54 ProtocolConnectionInfo,
55 };
56 let now = crate::utils::clock::Instant::now();
57 let conn_state = ServerConnectionState {
58 id: connection_id,
59 remote_addr: peer_addr,
60 local_addr,
61 bytes_sent: 0,
62 bytes_received: n as u64,
63 packets_sent: 0,
64 packets_received: 1,
65 last_activity: now,
66 status: ConnectionStatus::Active,
67 status_changed_at: now,
68 protocol_info: ProtocolConnectionInfo::empty(),
69 };
70 app_state
71 .add_connection_to_server(server_id, conn_state)
72 .await;
73 let _ = status_tx.send("__UPDATE_UI__".to_string());
74
75 // DEBUG: Log summary (FileOnly, hot per-packet path)
76 Log::new(Some(&status_tx))
77 .debug(format!("NTP received {} bytes from {}", n, peer_addr));
78
79 // TRACE: Log full payload (always hex for NTP, FileOnly)
80 let hex_str = hex::encode(&data);
81 Log::new(Some(&status_tx)).trace(format!("NTP data (hex): {}", hex_str));
82
83 let llm_clone = llm_client.clone();
84 let state_clone = app_state.clone();
85 let status_clone = status_tx.clone();
86 let socket_clone = socket.clone();
87
88 // Tracked, not detached: stop_server must abort this task too.
89 // Re-applied after taking this file whole from the Kiss-o'-Death
90 // branch, which predated the registration sweep.
91 let task_owner = app_state.clone();
92 task_owner
93 .spawn_server_task(server_id, async move {
94 // Get current Unix timestamp
95 use crate::utils::clock::{SystemTime, UNIX_EPOCH};
96 let current_unix_time = SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .unwrap()
99 .as_secs();
100
101 // Version (bits 5-3) and mode (bits 2-0) of the request. The
102 // reply must come back in the client's own version.
103 let (client_version, client_mode) = match data.first() {
104 Some(b) => ((b >> 3) & 0x07, b & 0x07),
105 None => (4, 3),
106 };
107
108 // Parse client's transmit timestamp from request (bytes 40-47).
109 // RFC 5905 requires this to come back verbatim as the reply's
110 // origin timestamp; the client uses it to match the response to
111 // its request and rejects anything else.
112 let (client_transmit_unix, client_transmit_ntp) = if data.len()
113 >= 48
114 {
115 let seconds = u32::from_be_bytes([
116 data[40], data[41], data[42], data[43],
117 ]) as u64;
118 let fraction = u32::from_be_bytes([
119 data[44], data[45], data[46], data[47],
120 ]) as u64;
121 let ntp_timestamp = (seconds << 32) | fraction; // Full 64-bit NTP timestamp
122
123 // Convert seconds part to Unix timestamp for the LLM prompt
124 let unix_ts = if seconds > 2_208_988_800 {
125 Some(seconds - 2_208_988_800)
126 } else {
127 None
128 };
129
130 (unix_ts, Some(ntp_timestamp))
131 } else {
132 (None, None)
133 };
134
135 // One protocol instance per request, carrying that request's
136 // origin timestamp and version, so overlapping requests cannot
137 // pick up each other's values.
138 let protocol =
139 NtpProtocol::for_request(client_transmit_ntp, client_version);
140
141 // Create NTP request event
142 let mut event_data = serde_json::json!({
143 "current_time": current_unix_time,
144 "client_version": client_version,
145 "client_mode": client_mode,
146 "bytes_received": data.len()
147 });
148
149 // Expose the raw 64-bit NTP value, which is what
150 // origin_timestamp needs; the Unix form is lossy and is
151 // provided only for readability.
152 if let Some(ntp_ts) = client_transmit_ntp {
153 event_data["client_transmit_timestamp"] =
154 serde_json::json!(ntp_ts);
155 }
156 if let Some(unix_ts) = client_transmit_unix {
157 event_data["client_transmit_unix"] = serde_json::json!(unix_ts);
158 }
159
160 let event = Event::new(&NTP_REQUEST_EVENT, event_data);
161
162 // A normal NTP time response is mechanical: every field has a
163 // correct default (stratum 2, LOCL reference id, current-time
164 // timestamps) and the client's transmit timestamp is echoed as the
165 // origin. There is nothing to decide, so by default we answer
166 // statically with NO LLM round-trip. The model is consulted only when
167 // the operator opts in — a server instruction or a per-event handler
168 // — which is how one asks the server to skew or lie about the time.
169 let wants_dynamic = operator_wants_dynamic(
170 &state_clone,
171 server_id,
172 &event.event_type.id,
173 )
174 .await;
175
176 if !wants_dynamic {
177 // No model was consulted at all: the operator opted into
178 // nothing, so the mechanical answer *is* the policy. Tagged
179 // distinctly so it can never be read as a model answer.
180 send_static_time_response(
181 &protocol,
182 socket_clone.as_ref(),
183 peer_addr,
184 &status_clone,
185 "static_default",
186 )
187 .await;
188 return;
189 }
190
191 Log::new(Some(&status_clone)).debug(format!(
192 "NTP calling LLM for request from {}",
193 peer_addr
194 ));
195
196 match call_llm(
197 &llm_clone,
198 &state_clone,
199 server_id,
200 None, // NTP uses UDP, no persistent connection
201 &event,
202 &protocol,
203 )
204 .await
205 {
206 Ok(execution_result) => {
207 // Display messages from LLM
208 for message in &execution_result.messages {
209 Log::new(Some(&status_clone))
210 .info(format!("{}", message));
211 }
212
213 Log::new(Some(&status_clone)).debug(format!(
214 "NTP parsed {} actions",
215 execution_result.raw_actions.len()
216 ));
217
218 // Process protocol results
219 Log::new(Some(&status_clone)).debug(format!(
220 "NTP got {} protocol results",
221 execution_result.protocol_results.len()
222 ));
223
224 // Classify the outcome *before* `protocol_results` is
225 // consumed below. Without this the log cannot tell a model
226 // that answered from one that refused (`ignore_request`)
227 // from one that said nothing at all - all three leave the
228 // same trace today, and two of them leave the client with
229 // no reply.
230 let produced_output = execution_result
231 .protocol_results
232 .iter()
233 .any(|r| !r.get_all_output().is_empty());
234 let explicitly_ignored =
235 execution_result.raw_actions.iter().any(|a| {
236 a.get("type").and_then(|t| t.as_str())
237 == Some("ignore_request")
238 });
239 let action_failures = execution_result
240 .failures
241 .iter()
242 .map(|f| format!("{}: {}", f.action, f.error))
243 .collect::<Vec<_>>()
244 .join("; ");
245
246 let decision_log = Log::new(Some(&status_clone));
247 if produced_output {
248 decision_log.info(format!(
249 "NTP request from {} decision=model_answer",
250 peer_addr
251 ));
252 } else if explicitly_ignored {
253 decision_log.info(format!(
254 "NTP request from {} decision=model_reject \
255 (ignore_request: no reply sent)",
256 peer_addr
257 ));
258 } else if !action_failures.is_empty() {
259 decision_log.error(format!(
260 "NTP request from {} decision=fail_closed_bad_action \
261 ({}); no reply sent",
262 peer_addr, action_failures
263 ));
264 } else {
265 decision_log.warn(format!(
266 "NTP request from {} decision=model_silent; no reply \
267 sent and the client keeps polling",
268 peer_addr
269 ));
270 }
271
272 for protocol_result in execution_result.protocol_results {
273 if let Some(output_data) =
274 protocol_result.get_all_output().first()
275 {
276 let _ = socket_clone
277 .send_to(output_data, peer_addr)
278 .await;
279
280 let log = Log::new(Some(&status_clone));
281
282 // DEBUG: Log summary (FileOnly, hot per-packet path)
283 log.debug(format!(
284 "NTP sent {} bytes to {}",
285 output_data.len(),
286 peer_addr
287 ));
288
289 // TRACE: Log full payload (always hex for NTP, FileOnly)
290 let hex_str = hex::encode(output_data);
291 log.trace(format!("NTP sent (hex): {}", hex_str));
292
293 log.info(format!(
294 "NTP response to {} ({} bytes)",
295 peer_addr,
296 output_data.len()
297 ));
298 } else {
299 Log::new(Some(&status_clone)).debug(
300 "NTP protocol result has no output data",
301 );
302 }
303 }
304 }
305 Err(e) => {
306 // The operator opted into LLM control and the backend
307 // failed. Answering with the mechanical stratum-2 time is
308 // a **fail-open**: the client accepts it and sets its
309 // clock, so an outage silently serves time on a server the
310 // operator had asked a model to decide for.
311 //
312 // NTP has no error message, but RFC 5905 §7.4 gives it a
313 // way to say "do not use me": a Kiss-o'-Death — stratum 0,
314 // LI 3, a four-character kiss code. chrony, ntpd and
315 // ntpdate all recognise it, refuse to take time from the
316 // packet, and back off. It can never be mistaken for a
317 // time sample, which is exactly what fails closed here,
318 // and it is still a *reply*, so the client is not left
319 // retrying against a server it thinks is merely slow.
320 //
321 // `RATE` when the backend is saturated (the client should
322 // slow down and retry) and `INIT` otherwise (not yet
323 // synchronised) are the registered codes for these two
324 // cases.
325 let (category, kiss_code, decision) =
326 match crate::utils::WireFailure::classify(&e) {
327 crate::utils::WireFailure::Overloaded => (
328 "overloaded",
329 "RATE",
330 "fail_closed_llm_overloaded",
331 ),
332 crate::utils::WireFailure::Unavailable => {
333 ("unavailable", "INIT", "fail_closed_llm_error")
334 }
335 };
336 Log::new(Some(&status_clone)).error(format!(
337 "NTP request from {} decision={} category={}: {} — \
338 answering Kiss-o'-Death ({}), which no client will \
339 take time from",
340 peer_addr, decision, category, e, kiss_code
341 ));
342 send_kod_response(
343 socket_clone.as_ref(),
344 peer_addr,
345 client_version,
346 client_transmit_ntp,
347 kiss_code,
348 &status_clone,
349 decision,
350 )
351 .await;
352 }
353 }
354 })
355 .await;
356 }
357 Err(e) => {
358 error!("NTP receive error: {}", e);
359 break;
360 }
361 }
362 }
363 });
364
365 task_registrar
366 .register_server_task(server_id, accept_handle)
367 .await;
368
369 Ok(local_addr)
370 }
371}
372
373/// Returns true if the operator opted into dynamic (LLM- or handler-driven) responses for
374/// this server: either a non-empty server instruction was given, or an event handler is
375/// configured for `event_id`. When false the protocol answers with a correct static default
376/// and never consults the model — a normal NTP time response is fully determined by the
377/// request plus the server's clock.
378async fn operator_wants_dynamic(
379 state: &AppState,
380 server_id: crate::state::ServerId,
381 event_id: &str,
382) -> bool {
383 state
384 .with_server_mut(server_id, |server| {
385 let has_instruction = !server.instruction.trim().is_empty();
386 let has_handler = server
387 .event_handler_config
388 .as_ref()
389 .map(|c| c.find_handler(event_id).is_some())
390 .unwrap_or(false);
391 has_instruction || has_handler
392 })
393 .await
394 .unwrap_or(false)
395}
396
397/// Build and send the mechanical NTP time response with no LLM involvement: stratum 2, LOCL
398/// reference clock, current-time timestamps. The per-request `protocol` echoes the client's
399/// transmit timestamp as the origin and answers in the client's own version.
400///
401/// `decision` is the token this send is recorded under — `static_default` when no model was
402/// consulted, `static_default_llm_error` when one was and it failed — so the two can never be
403/// conflated in the log.
404/// Answer a request the backend could not decide with a Kiss-o'-Death (RFC 5905 §7.4).
405///
406/// This is NTP's fail-closed reply. A KoD carries stratum 0 and LI 3, so a client discards
407/// it as a time sample rather than stepping its clock — which is the whole point: the
408/// alternative on this path is a usable stratum-2 answer, and that turns a backend outage
409/// into the server quietly serving time it was told not to serve on its own authority.
410///
411/// The client's transmit timestamp is echoed as the origin timestamp for the same reason a
412/// normal reply echoes it: a reply failing that check is discarded, which would put us back
413/// at silence.
414#[allow(clippy::too_many_arguments)]
415async fn send_kod_response(
416 socket: &UdpSocket,
417 peer_addr: SocketAddr,
418 version: u8,
419 origin_timestamp: Option<u64>,
420 kiss_code: &str,
421 status: &mpsc::UnboundedSender<String>,
422 decision: &str,
423) {
424 let packet =
425 crate::server::ntp::actions::build_kod_packet(version, origin_timestamp, kiss_code);
426 match socket.send_to(&packet, peer_addr).await {
427 Ok(sent) => {
428 Log::new(Some(status)).info(format!(
429 "NTP Kiss-o'-Death ({}) to {} ({} bytes) decision={}",
430 kiss_code, peer_addr, sent, decision
431 ));
432 }
433 Err(e) => {
434 Log::new(Some(status)).error(format!(
435 "NTP Kiss-o'-Death to {peer_addr} could not be sent \
436 decision=fail_closed_write_error: {e}"
437 ));
438 }
439 }
440}
441
442async fn send_static_time_response(
443 protocol: &NtpProtocol,
444 socket: &UdpSocket,
445 peer_addr: SocketAddr,
446 status: &mpsc::UnboundedSender<String>,
447 decision: &str,
448) {
449 let action = serde_json::json!({
450 "type": "send_ntp_time_response",
451 "stratum": 2,
452 "reference_id": "LOCL",
453 "reference_timestamp": "current_time",
454 "receive_timestamp": "current_time",
455 "transmit_timestamp": "current_time"
456 });
457 match protocol.execute_action(action) {
458 Ok(ActionResult::Output(bytes)) => {
459 let _ = socket.send_to(&bytes, peer_addr).await;
460 Log::new(Some(status)).info(format!(
461 "NTP static time response to {} ({} bytes) decision={}",
462 peer_addr,
463 bytes.len(),
464 decision
465 ));
466 }
467 Ok(_) => {
468 warn!(
469 "NTP static time response produced no output for {} \
470 decision=fail_closed_action_error",
471 peer_addr
472 );
473 }
474 Err(e) => {
475 Log::new(Some(status)).error(format!(
476 "NTP static time response to {peer_addr} failed \
477 decision=fail_closed_action_error: {e}"
478 ));
479 }
480 }
481}