Skip to main content

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}