Skip to main content

netget/client/ntp/
mod.rs

1//! NTP client implementation
2pub mod actions;
3
4pub use actions::NtpClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::net::UdpSocket;
10use tokio::sync::{mpsc, Mutex};
11use tracing::{error, info, trace};
12
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::ntp::actions::NTP_CLIENT_RESPONSE_RECEIVED_EVENT;
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ClientLlmResult;
18use crate::protocol::Event;
19use crate::state::app_state::AppState;
20use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
21use crate::state::{AccessLogOwner, ClientId, ClientStatus};
22
23/// NTP client that queries NTP servers
24pub struct NtpClient;
25
26impl NtpClient {
27    /// Connect to an NTP server with integrated LLM actions
28    pub async fn connect_with_llm_actions(
29        remote_addr: String,
30        llm_client: OllamaClient,
31        app_state: Arc<AppState>,
32        status_tx: mpsc::UnboundedSender<String>,
33        client_id: ClientId,
34    ) -> Result<SocketAddr> {
35        let remote_sock_addr = Self::resolve_server(&remote_addr).await?;
36
37        // Bind an ephemeral local port in the family of the address we send to: an IPv4
38        // socket cannot send to an IPv6 address, nor the other way round.
39        let local_bind = if remote_sock_addr.is_ipv6() {
40            "[::]:0"
41        } else {
42            "0.0.0.0:0"
43        };
44        let socket = UdpSocket::bind(local_bind)
45            .await
46            .context("Failed to bind UDP socket")?;
47
48        let local_addr = socket.local_addr()?;
49
50        info!(
51            "NTP client {} connected to {} (local: {})",
52            client_id, remote_sock_addr, local_addr
53        );
54
55        // Update client state
56        app_state
57            .update_client_status(client_id, ClientStatus::Connected)
58            .await;
59        let _ = status_tx.send(format!("[CLIENT] NTP client {} connected", client_id));
60        let _ = status_tx.send("__UPDATE_UI__".to_string());
61
62        // Wrap socket in Arc for sharing
63        let socket = Arc::new(socket);
64        let socket_clone = socket.clone();
65
66        // Only one query may own the socket's receive side at a time: the connect-time
67        // query and any injected `query_time` would otherwise race for the same datagram
68        // and one of them would parse the other's reply.
69        let query_lock = Arc::new(Mutex::new(()));
70
71        // Command channel for injected actions (the dashboard's [ send ]).
72        //
73        // Registered BEFORE the initial LLM call below, which a manual `*` routing rule can
74        // park for minutes - [ send ] must work for the whole park.
75        let command_rx =
76            crate::client::command_support::register_command_channel(&app_state, client_id).await;
77
78        let cmd_state = app_state.clone();
79        let cmd_status_tx = status_tx.clone();
80        let cmd_llm = llm_client.clone();
81        let cmd_socket = socket.clone();
82        let cmd_lock = query_lock.clone();
83        let cmd_task = tokio::spawn(async move {
84            Self::command_loop(
85                command_rx,
86                cmd_socket,
87                remote_sock_addr,
88                cmd_lock,
89                client_id,
90                cmd_llm,
91                cmd_state,
92                cmd_status_tx,
93            )
94            .await;
95        });
96        app_state.register_client_task(client_id, cmd_task).await;
97
98        // Spawn task to handle LLM-directed queries
99        // Registered with AppState so stop_client can abort this task —
100        // dropping a JoinHandle only detaches it in Tokio.
101        let task_registrar = app_state.clone();
102        let query_lock_for_llm = query_lock.clone();
103        let task_handle = tokio::spawn(async move {
104            // Initial LLM call to get first action (usually query_time)
105            if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
106                let protocol = Arc::new(crate::client::ntp::actions::NtpClientProtocol::new());
107
108                // Call LLM with connected event
109                match call_llm_for_client(
110                    &llm_client,
111                    &app_state,
112                    client_id.to_string(),
113                    &instruction,
114                    "", // No memory initially
115                    // The connected event, not `None`.
116                    //
117                    // NTP_CLIENT_CONNECTED_EVENT was declared and nothing raised it, so
118                    // this call arrived with no event attached: an `ntp_connected` handler
119                    // the operator or the model wrote could never match, and
120                    // `client_llm_action_set` could not union the event's own actions in.
121                    Some(&Event::new(
122                        &crate::client::ntp::actions::NTP_CLIENT_CONNECTED_EVENT,
123                        serde_json::json!({ "remote_addr": remote_addr.clone() }),
124                    )),
125                    protocol.as_ref(),
126                    &status_tx,
127                )
128                .await
129                {
130                    Ok(ClientLlmResult {
131                        actions,
132                        memory_updates: _,
133                    }) => {
134                        // Execute initial actions
135                        for action in actions {
136                            match protocol.as_ref().execute_action(action) {
137                                Ok(ClientActionResult::Custom { name, data: _ })
138                                    if name == "ntp_query" =>
139                                {
140                                    // Send the query, then read and report the reply. Same
141                                    // two functions the injected-command path uses.
142                                    match Self::send_query(&socket_clone, remote_sock_addr).await {
143                                        Ok(sent) => {
144                                            trace!(
145                                                "NTP client {} sent {} byte query to {}",
146                                                client_id,
147                                                sent,
148                                                remote_sock_addr
149                                            );
150                                            Self::await_and_report_response(
151                                                &socket_clone,
152                                                remote_sock_addr,
153                                                &query_lock_for_llm,
154                                                client_id,
155                                                &llm_client,
156                                                &app_state,
157                                                &status_tx,
158                                                &instruction,
159                                                protocol.as_ref(),
160                                            )
161                                            .await;
162                                        }
163                                        Err(e) => {
164                                            error!(
165                                                "NTP client {} failed to send query: {}",
166                                                client_id, e
167                                            );
168                                        }
169                                    }
170                                }
171                                Ok(ClientActionResult::Disconnect) => {
172                                    info!("NTP client {} disconnecting", client_id);
173                                    app_state
174                                        .update_client_status(client_id, ClientStatus::Disconnected)
175                                        .await;
176                                    let _ = status_tx.send("__UPDATE_UI__".to_string());
177                                    break;
178                                }
179                                Ok(_) => {}
180                                Err(e) => {
181                                    error!("NTP client {} rejected action: {}", client_id, e);
182                                }
183                            }
184                        }
185                    }
186                    Err(e) => {
187                        error!("LLM error for NTP client {}: {}", client_id, e);
188                    }
189                }
190            }
191
192            // Mark as disconnected after query completes. The socket stays bound and the
193            // command channel stays registered: an injected `query_time` can still run
194            // another query, which is the multi-query path this client otherwise lacks.
195            app_state
196                .update_client_status(client_id, ClientStatus::Disconnected)
197                .await;
198            let _ = status_tx.send("__UPDATE_UI__".to_string());
199        });
200        task_registrar
201            .register_client_task(client_id, task_handle)
202            .await;
203
204        Ok(local_addr)
205    }
206
207    /// Resolve the NTP server's `host:port`.
208    ///
209    /// A literal `IP:port` is used as given, with no name lookup. A hostname is resolved and
210    /// its first IPv4 address is taken when it has one, otherwise its first address. UDP has
211    /// no handshake that would reveal an unreachable address and move on to the next, and
212    /// `localhost` commonly resolves to `::1` ahead of `127.0.0.1`, where a server listening
213    /// on IPv4 alone never hears the query.
214    async fn resolve_server(remote_addr: &str) -> Result<SocketAddr> {
215        if let Ok(addr) = remote_addr.parse::<SocketAddr>() {
216            return Ok(addr);
217        }
218        let candidates: Vec<SocketAddr> = tokio::net::lookup_host(remote_addr)
219            .await
220            .with_context(|| {
221                format!(
222                    "Could not resolve NTP server address {remote_addr:?} (expected host:port, \
223                     e.g. 127.0.0.1:123 or ntp.example.org:123)"
224                )
225            })?
226            .collect();
227        candidates
228            .iter()
229            .find(|addr| addr.is_ipv4())
230            .or_else(|| candidates.first())
231            .copied()
232            .with_context(|| format!("NTP server address {remote_addr:?} resolved to no address"))
233    }
234
235    /// Send one request and read its reply. Raises no event and calls no LLM.
236    ///
237    /// This is what a `query_time` the model asks for in answer to
238    /// `ntp_response_received` runs through: reporting that reply as another event would
239    /// let one query drive the model in an unbounded loop, and would make
240    /// report -> query -> report a self-referential async chain rustc cannot prove `Send`.
241    async fn query_once(socket: &Arc<UdpSocket>, remote: SocketAddr) -> Result<NtpTimestamps> {
242        Self::send_query(socket, remote).await?;
243        let mut buffer = vec![0u8; 48];
244        let n = tokio::time::timeout(std::time::Duration::from_secs(5), socket.recv(&mut buffer))
245            .await
246            .map_err(|_| anyhow::anyhow!("timed out waiting for NTP reply"))??;
247        Ok(Self::parse_ntp_response(&buffer[..n]))
248    }
249
250    /// Put one NTP request on the wire, returning the byte count `send_to` reported.
251    async fn send_query(socket: &Arc<UdpSocket>, remote: SocketAddr) -> Result<usize> {
252        let packet = Self::build_ntp_request();
253        let sent = socket.send_to(&packet, remote).await?;
254        Ok(sent)
255    }
256
257    /// Wait for the server's reply to a query we just sent, parse it, and hand it to the
258    /// model as an `ntp_response_received` event.
259    ///
260    /// Holds `query_lock` across the receive so two concurrent queries cannot steal each
261    /// other's datagram.
262    #[allow(clippy::too_many_arguments)]
263    async fn await_and_report_response(
264        socket: &Arc<UdpSocket>,
265        remote: SocketAddr,
266        query_lock: &Arc<Mutex<()>>,
267        client_id: ClientId,
268        llm_client: &OllamaClient,
269        app_state: &Arc<AppState>,
270        status_tx: &mpsc::UnboundedSender<String>,
271        instruction: &str,
272        protocol: &dyn Client,
273    ) {
274        let mut buffer = vec![0u8; 48];
275        let received = {
276            let _guard = query_lock.lock().await;
277            tokio::time::timeout(
278                std::time::Duration::from_secs(5),
279                socket.recv_from(&mut buffer),
280            )
281            .await
282        };
283
284        let n = match received {
285            Ok(Ok((n, from_addr))) => {
286                trace!(
287                    "NTP client {} received {} bytes from {}",
288                    client_id,
289                    n,
290                    from_addr
291                );
292                n
293            }
294            Ok(Err(e)) => {
295                error!("NTP client {} recv error: {}", client_id, e);
296                return;
297            }
298            Err(_) => {
299                error!("NTP client {} timed out waiting for response", client_id);
300                return;
301            }
302        };
303
304        let timestamps = Self::parse_ntp_response(&buffer[..n]);
305
306        let event = Event::new(
307            &NTP_CLIENT_RESPONSE_RECEIVED_EVENT,
308            serde_json::json!({
309                "origin_timestamp": timestamps.origin_timestamp,
310                "receive_timestamp": timestamps.receive_timestamp,
311                "transmit_timestamp": timestamps.transmit_timestamp,
312                "stratum": timestamps.stratum,
313                "precision": timestamps.precision,
314            }),
315        );
316
317        let memory = app_state
318            .get_memory_for_client(client_id)
319            .await
320            .unwrap_or_default();
321
322        match call_llm_for_client(
323            llm_client,
324            app_state,
325            client_id.to_string(),
326            instruction,
327            &memory,
328            Some(&event),
329            protocol,
330            status_tx,
331        )
332        .await
333        {
334            Ok(ClientLlmResult {
335                actions,
336                memory_updates,
337            }) => {
338                if let Some(mem) = memory_updates {
339                    app_state.set_memory_for_client(client_id, mem).await;
340                }
341
342                // Execute what the model asked for. These were discarded, so a model that
343                // read a timestamp and wanted another sample was silently ignored.
344                //
345                // A `query_time` follow-up sends one more request and reads its reply
346                // here, raising no further event. That bounds the loop -- otherwise each
347                // reply would raise an event whose answer could ask for another query,
348                // forever -- and keeps the type non-recursive, which is what
349                // tokio::spawn's Send bound requires.
350                use crate::llm::actions::client_trait::ClientActionResult;
351                for action in actions {
352                    match protocol.execute_action(action.clone()) {
353                        Ok(ClientActionResult::Custom { name, .. }) if name == "query_time" => {
354                            match Self::query_once(socket, remote).await {
355                                Ok(t) => info!(
356                                    "NTP client {} follow-up query_time: stratum {}",
357                                    client_id, t.stratum
358                                ),
359                                Err(e) => error!(
360                                    "NTP client {} follow-up query_time failed: {}",
361                                    client_id, e
362                                ),
363                            }
364                        }
365                        Ok(_) => {}
366                        Err(e) => error!(
367                            "NTP client {} rejected its own follow-up action: {}",
368                            client_id, e
369                        ),
370                    }
371                }
372            }
373            Err(e) => {
374                error!("LLM error for NTP client {}: {}", client_id, e);
375            }
376        }
377    }
378
379    /// Drain injected commands until the channel closes (the client was removed) or an
380    /// injected `disconnect` ends the session.
381    ///
382    /// `query_time` reports `Sent { 48 }` the moment the request datagram is on the wire —
383    /// the reply is then awaited and reported to the model *after* the caller has been
384    /// answered, so a manual routing rule parked on `ntp_response_received` cannot hold the
385    /// dashboard's [ send ] open for its whole timeout.
386    #[allow(clippy::too_many_arguments)]
387    async fn command_loop(
388        mut command_rx: mpsc::Receiver<ClientCommand>,
389        socket: Arc<UdpSocket>,
390        remote: SocketAddr,
391        query_lock: Arc<Mutex<()>>,
392        client_id: ClientId,
393        llm_client: OllamaClient,
394        app_state: Arc<AppState>,
395        status_tx: mpsc::UnboundedSender<String>,
396    ) {
397        use crate::llm::actions::protocol_trait::Protocol;
398
399        let protocol = NtpClientProtocol::new();
400
401        while let Some(command) = command_rx.recv().await {
402            let action = command.action.clone();
403
404            // `query_time` is the only verb that puts bytes on the wire; the reply is
405            // handled after the caller is answered, so `queried` carries that follow-up.
406            let mut queried = false;
407            let outcome = match protocol.execute_action(action.clone()) {
408                Err(e) => Ok(ClientSendOutcome::Rejected {
409                    error: e.to_string(),
410                }),
411                Ok(ClientActionResult::Custom { name, .. }) if name == "ntp_query" => {
412                    match Self::send_query(&socket, remote).await {
413                        Ok(bytes_sent) => {
414                            queried = true;
415                            Ok(ClientSendOutcome::Sent { bytes_sent })
416                        }
417                        Err(e) => Err(e),
418                    }
419                }
420                Ok(ClientActionResult::Custom { name, .. }) => Ok(ClientSendOutcome::Executed {
421                    detail: format!("custom result '{name}' is not an NTP client verb"),
422                }),
423                Ok(ClientActionResult::Disconnect) => Ok(ClientSendOutcome::Disconnected),
424                Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
425                    detail: "analyze_response is interpretation only; nothing written to the wire"
426                        .to_string(),
427                }),
428                Ok(_) => Ok(ClientSendOutcome::Executed {
429                    detail: "action produced no NTP request".to_string(),
430                }),
431            };
432
433            let outcome_json = match &outcome {
434                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
435                Err(e) => serde_json::json!({"error": e.to_string()}),
436            };
437            app_state
438                .record_access_log(
439                    AccessLogOwner::Client(client_id.as_u32()),
440                    protocol.protocol_name(),
441                    None,
442                    "injected_action",
443                    action,
444                    vec![outcome_json],
445                )
446                .await;
447
448            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
449            if let Err(e) = &outcome {
450                error!("NTP client {} injected action failed: {}", client_id, e);
451                let _ = status_tx.send(format!(
452                    "[WARN] Client {} injected action failed: {}",
453                    client_id, e
454                ));
455            }
456            let _ = status_tx.send("__UPDATE_UI__".to_string());
457            crate::client::command_support::reply(command, outcome);
458
459            if disconnect {
460                app_state
461                    .update_client_status(client_id, ClientStatus::Disconnected)
462                    .await;
463                let _ = status_tx.send(format!(
464                    "[CLIENT] NTP client {} disconnected (injected action)",
465                    client_id
466                ));
467                break;
468            }
469
470            if queried {
471                if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
472                    Self::await_and_report_response(
473                        &socket,
474                        remote,
475                        &query_lock,
476                        client_id,
477                        &llm_client,
478                        &app_state,
479                        &status_tx,
480                        &instruction,
481                        &protocol,
482                    )
483                    .await;
484                }
485            }
486        }
487
488        app_state.remove_client_handle(client_id).await;
489        let _ = status_tx.send("__UPDATE_UI__".to_string());
490    }
491
492    /// Build an NTP request packet (48 bytes)
493    fn build_ntp_request() -> Vec<u8> {
494        let mut packet = vec![0u8; 48];
495
496        // Set LI = 0, VN = 3, Mode = 3 (client)
497        packet[0] = 0x1b; // 00 011 011 = LI=0, VN=3, Mode=3
498
499        // Set transmit timestamp to current time
500        use crate::utils::clock::{SystemTime, UNIX_EPOCH};
501        let unix_time = SystemTime::now()
502            .duration_since(UNIX_EPOCH)
503            .unwrap()
504            .as_secs();
505
506        // Convert Unix timestamp to NTP timestamp (add NTP epoch offset)
507        let ntp_timestamp = unix_time + 2_208_988_800;
508
509        // Write transmit timestamp (bytes 40-47)
510        let ntp_timestamp_bytes = ntp_timestamp.to_be_bytes();
511        packet[40..44].copy_from_slice(&ntp_timestamp_bytes[4..8]);
512        // Fraction field (bytes 44-47) left as 0
513
514        packet
515    }
516
517    /// Parse NTP response packet
518    fn parse_ntp_response(data: &[u8]) -> NtpTimestamps {
519        if data.len() < 48 {
520            return NtpTimestamps::default();
521        }
522
523        // Extract stratum (byte 1)
524        let stratum = data[1];
525
526        // Extract precision (byte 3)
527        let precision = data[3] as i8;
528
529        // Extract origin timestamp (bytes 24-31)
530        let origin_seconds = u32::from_be_bytes([data[24], data[25], data[26], data[27]]) as u64;
531        let _origin_fraction = u32::from_be_bytes([data[28], data[29], data[30], data[31]]) as u64;
532        let origin_timestamp = if origin_seconds > 2_208_988_800 {
533            origin_seconds - 2_208_988_800
534        } else {
535            origin_seconds
536        };
537
538        // Extract receive timestamp (bytes 32-39)
539        let receive_seconds = u32::from_be_bytes([data[32], data[33], data[34], data[35]]) as u64;
540        let _receive_fraction = u32::from_be_bytes([data[36], data[37], data[38], data[39]]) as u64;
541        let receive_timestamp = if receive_seconds > 2_208_988_800 {
542            receive_seconds - 2_208_988_800
543        } else {
544            receive_seconds
545        };
546
547        // Extract transmit timestamp (bytes 40-47)
548        let transmit_seconds = u32::from_be_bytes([data[40], data[41], data[42], data[43]]) as u64;
549        let _transmit_fraction =
550            u32::from_be_bytes([data[44], data[45], data[46], data[47]]) as u64;
551        let transmit_timestamp = if transmit_seconds > 2_208_988_800 {
552            transmit_seconds - 2_208_988_800
553        } else {
554            transmit_seconds
555        };
556
557        NtpTimestamps {
558            origin_timestamp,
559            receive_timestamp,
560            transmit_timestamp,
561            stratum,
562            precision,
563        }
564    }
565}
566
567#[derive(Debug, Default)]
568struct NtpTimestamps {
569    origin_timestamp: u64,
570    receive_timestamp: u64,
571    transmit_timestamp: u64,
572    stratum: u8,
573    precision: i8,
574}