1pub 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
23pub struct NtpClient;
25
26impl NtpClient {
27 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 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 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 let socket = Arc::new(socket);
64 let socket_clone = socket.clone();
65
66 let query_lock = Arc::new(Mutex::new(()));
70
71 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 let task_registrar = app_state.clone();
102 let query_lock_for_llm = query_lock.clone();
103 let task_handle = tokio::spawn(async move {
104 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 match call_llm_for_client(
110 &llm_client,
111 &app_state,
112 client_id.to_string(),
113 &instruction,
114 "", 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 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 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 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 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 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 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 #[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 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 #[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 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 fn build_ntp_request() -> Vec<u8> {
494 let mut packet = vec![0u8; 48];
495
496 packet[0] = 0x1b; 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 let ntp_timestamp = unix_time + 2_208_988_800;
508
509 let ntp_timestamp_bytes = ntp_timestamp.to_be_bytes();
511 packet[40..44].copy_from_slice(&ntp_timestamp_bytes[4..8]);
512 packet
515 }
516
517 fn parse_ntp_response(data: &[u8]) -> NtpTimestamps {
519 if data.len() < 48 {
520 return NtpTimestamps::default();
521 }
522
523 let stratum = data[1];
525
526 let precision = data[3] as i8;
528
529 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 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 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}