netget/client/rip/mod.rs
1//! RIP (Routing Information Protocol) client implementation
2pub mod actions;
3
4pub use actions::RipClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::{Ipv4Addr, SocketAddr};
8use std::sync::Arc;
9use tokio::net::UdpSocket;
10use tokio::sync::{mpsc, Mutex};
11use tracing::{debug, error, info, trace};
12
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::rip::actions::{RIP_CLIENT_CONNECTED_EVENT, RIP_CLIENT_RESPONSE_RECEIVED_EVENT};
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::actions::protocol_trait::Protocol;
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23
24/// RIP protocol version
25#[derive(Debug, Clone, Copy, PartialEq)]
26#[repr(u8)]
27pub enum RipVersion {
28 V1 = 1,
29 V2 = 2,
30}
31
32/// RIP command types
33#[derive(Debug, Clone, Copy, PartialEq)]
34#[repr(u8)]
35pub enum RipCommand {
36 Request = 1,
37 Response = 2,
38}
39
40/// RIP route entry (24 bytes for RIPv2)
41#[derive(Debug, Clone)]
42pub struct RipRouteEntry {
43 pub address_family: u16,
44 pub route_tag: u16, // RIPv2 only (0 for RIPv1)
45 pub ip_address: Ipv4Addr,
46 pub subnet_mask: Ipv4Addr, // RIPv2 only (0.0.0.0 for RIPv1)
47 pub next_hop: Ipv4Addr, // RIPv2 only (0.0.0.0 for RIPv1)
48 pub metric: u32,
49}
50
51impl RipRouteEntry {
52 /// Create a new RIP route entry
53 pub fn new(ip: Ipv4Addr, metric: u32) -> Self {
54 Self {
55 address_family: 2, // AF_INET
56 route_tag: 0,
57 ip_address: ip,
58 subnet_mask: Ipv4Addr::new(0, 0, 0, 0),
59 next_hop: Ipv4Addr::new(0, 0, 0, 0),
60 metric,
61 }
62 }
63
64 /// Create a RIPv2 route entry with subnet mask
65 pub fn new_v2(ip: Ipv4Addr, subnet_mask: Ipv4Addr, next_hop: Ipv4Addr, metric: u32) -> Self {
66 Self {
67 address_family: 2,
68 route_tag: 0,
69 ip_address: ip,
70 subnet_mask,
71 next_hop,
72 metric,
73 }
74 }
75
76 /// Encode route entry to bytes
77 pub fn encode(&self) -> Vec<u8> {
78 let mut bytes = Vec::with_capacity(20);
79 bytes.extend_from_slice(&self.address_family.to_be_bytes());
80 bytes.extend_from_slice(&self.route_tag.to_be_bytes());
81 bytes.extend_from_slice(&self.ip_address.octets());
82 bytes.extend_from_slice(&self.subnet_mask.octets());
83 bytes.extend_from_slice(&self.next_hop.octets());
84 bytes.extend_from_slice(&self.metric.to_be_bytes());
85 bytes
86 }
87
88 /// Decode route entry from bytes
89 pub fn decode(data: &[u8]) -> Result<Self> {
90 if data.len() < 20 {
91 return Err(anyhow::anyhow!("RIP route entry too short"));
92 }
93
94 Ok(Self {
95 address_family: u16::from_be_bytes([data[0], data[1]]),
96 route_tag: u16::from_be_bytes([data[2], data[3]]),
97 ip_address: Ipv4Addr::new(data[4], data[5], data[6], data[7]),
98 subnet_mask: Ipv4Addr::new(data[8], data[9], data[10], data[11]),
99 next_hop: Ipv4Addr::new(data[12], data[13], data[14], data[15]),
100 metric: u32::from_be_bytes([data[16], data[17], data[18], data[19]]),
101 })
102 }
103}
104
105/// RIP message structure
106#[derive(Debug, Clone)]
107pub struct RipMessage {
108 pub command: RipCommand,
109 pub version: RipVersion,
110 pub routes: Vec<RipRouteEntry>,
111}
112
113impl RipMessage {
114 /// Create a RIP request message
115 pub fn request(version: RipVersion) -> Self {
116 // Request for entire routing table: address family 0, metric 16
117 Self {
118 command: RipCommand::Request,
119 version,
120 routes: vec![RipRouteEntry {
121 address_family: 0,
122 route_tag: 0,
123 ip_address: Ipv4Addr::new(0, 0, 0, 0),
124 subnet_mask: Ipv4Addr::new(0, 0, 0, 0),
125 next_hop: Ipv4Addr::new(0, 0, 0, 0),
126 metric: 16,
127 }],
128 }
129 }
130
131 /// Encode RIP message to bytes
132 pub fn encode(&self) -> Vec<u8> {
133 let mut bytes = Vec::new();
134
135 // Command (1 byte)
136 bytes.push(self.command as u8);
137
138 // Version (1 byte)
139 bytes.push(self.version as u8);
140
141 // Must be zero (2 bytes)
142 bytes.extend_from_slice(&[0, 0]);
143
144 // Route entries (20 bytes each)
145 for route in &self.routes {
146 bytes.extend_from_slice(&route.encode());
147 }
148
149 bytes
150 }
151
152 /// Decode RIP message from bytes
153 pub fn decode(data: &[u8]) -> Result<Self> {
154 if data.len() < 4 {
155 return Err(anyhow::anyhow!("RIP message too short"));
156 }
157
158 let command = match data[0] {
159 1 => RipCommand::Request,
160 2 => RipCommand::Response,
161 _ => return Err(anyhow::anyhow!("Invalid RIP command: {}", data[0])),
162 };
163
164 let version = match data[1] {
165 1 => RipVersion::V1,
166 2 => RipVersion::V2,
167 _ => return Err(anyhow::anyhow!("Invalid RIP version: {}", data[1])),
168 };
169
170 // Parse route entries
171 let mut routes = Vec::new();
172 let mut offset = 4;
173
174 while offset + 20 <= data.len() {
175 routes.push(RipRouteEntry::decode(&data[offset..offset + 20])?);
176 offset += 20;
177 }
178
179 Ok(Self {
180 command,
181 version,
182 routes,
183 })
184 }
185}
186
187/// Connection state for LLM processing
188#[derive(Debug, Clone, PartialEq)]
189enum ConnectionState {
190 Idle,
191 Processing,
192 Accumulating,
193}
194
195/// Per-client data for LLM handling
196struct ClientData {
197 state: ConnectionState,
198 queued_responses: Vec<RipMessage>,
199 memory: String,
200}
201
202/// RIP client that queries RIP routers
203pub struct RipClient;
204
205impl RipClient {
206 /// Connect to a RIP router with integrated LLM actions
207 pub async fn connect_with_llm_actions(
208 remote_addr: String,
209 llm_client: OllamaClient,
210 app_state: Arc<AppState>,
211 status_tx: mpsc::UnboundedSender<String>,
212 client_id: ClientId,
213 ) -> Result<SocketAddr> {
214 // Parse remote address
215 let remote_sock_addr: SocketAddr = remote_addr
216 .parse()
217 .context(format!("Invalid RIP router address: {}", remote_addr))?;
218
219 // Bind to any available local port (not 520, as that requires root)
220 let socket = UdpSocket::bind("0.0.0.0:0")
221 .await
222 .context("Failed to bind UDP socket")?;
223
224 let local_addr = socket.local_addr()?;
225
226 info!(
227 "RIP client {} bound to {} (target: {})",
228 client_id, local_addr, remote_sock_addr
229 );
230
231 // Update client state
232 app_state
233 .update_client_status(client_id, ClientStatus::Connected)
234 .await;
235 let _ = status_tx.send(format!("[CLIENT] RIP client {} connected", client_id));
236 let _ = status_tx.send("__UPDATE_UI__".to_string());
237
238 let socket_arc = Arc::new(socket);
239
240 // Initialize client data
241 let client_data = Arc::new(Mutex::new(ClientData {
242 state: ConnectionState::Idle,
243 queued_responses: Vec::new(),
244 memory: String::new(),
245 }));
246
247 let protocol = Arc::new(crate::client::rip::actions::RipClientProtocol::new());
248
249 // Command channel for injected actions (the dashboard's [ send ]). Registered, and
250 // drained by a live task, BEFORE the connected-event LLM call below: that call is
251 // awaited inline here, so a manual `*` routing rule parks it - and the whole point
252 // of the channel is that the operator can still reach the client while it waits.
253 let command_rx =
254 crate::client::command_support::register_command_channel(&app_state, client_id).await;
255 // Filled in once the receive loop exists (it is spawned after the connected-event
256 // call), so an injected `disconnect` can actually stop it and release the socket.
257 let read_abort: Arc<std::sync::OnceLock<tokio::task::AbortHandle>> =
258 Arc::new(std::sync::OnceLock::new());
259 let cmd_task = tokio::spawn(Self::command_loop(
260 command_rx,
261 protocol.clone(),
262 socket_arc.clone(),
263 remote_sock_addr,
264 client_id,
265 app_state.clone(),
266 status_tx.clone(),
267 read_abort.clone(),
268 ));
269 app_state.register_client_task(client_id, cmd_task).await;
270
271 // Call LLM with connected event
272 let event = Event::new(
273 &RIP_CLIENT_CONNECTED_EVENT,
274 serde_json::json!({
275 "remote_addr": remote_sock_addr.to_string(),
276 }),
277 );
278
279 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
280 // Snapshot the memory into a local BEFORE the match. Passing
281 // `&client_data.lock().await.memory` directly as an argument would put the
282 // `MutexGuard` in the match scrutinee, and scrutinee temporaries live until the
283 // end of the whole `match` — so the guard would be held across the LLM await and
284 // the `Ok` arm's own `client_data.lock()` below would deadlock on it.
285 let memory = client_data.lock().await.memory.clone();
286 match call_llm_for_client(
287 &llm_client,
288 &app_state,
289 client_id.to_string(),
290 &instruction,
291 &memory,
292 Some(&event),
293 protocol.as_ref(),
294 &status_tx,
295 )
296 .await
297 {
298 Ok(ClientLlmResult {
299 actions,
300 memory_updates,
301 }) => {
302 // Update memory
303 if let Some(mem) = memory_updates {
304 client_data.lock().await.memory = mem;
305 }
306
307 // Execute initial actions
308 for action in actions {
309 match protocol.as_ref().execute_action(action) {
310 Ok(result) => {
311 match Self::apply_action(
312 client_id,
313 result,
314 &socket_arc,
315 remote_sock_addr,
316 )
317 .await
318 {
319 Ok(Applied::Disconnect) => {
320 info!("RIP client {} disconnecting", client_id);
321 app_state.remove_client_handle(client_id).await;
322 app_state
323 .update_client_status(
324 client_id,
325 ClientStatus::Disconnected,
326 )
327 .await;
328 return Ok(local_addr);
329 }
330 Ok(_) => {}
331 Err(e) => error!("Failed to send RIP request: {}", e),
332 }
333 }
334 Err(e) => error!(
335 "RIP client {} could not execute action after connect: {}",
336 client_id, e
337 ),
338 }
339 }
340 }
341 Err(e) => {
342 error!("LLM error for RIP client {}: {}", client_id, e);
343 }
344 }
345 }
346
347 // An injected `disconnect` that arrived while the connected-event call was parked
348 // has already dropped the command handle. Honour it rather than starting a receive
349 // loop the operator just asked to stop.
350 if !app_state.has_client_handle(client_id).await {
351 return Ok(local_addr);
352 }
353
354 // Spawn receive loop
355 let socket_clone = socket_arc.clone();
356 let client_data_clone = client_data.clone();
357 // Registered with AppState so stop_client can abort this task —
358 // dropping a JoinHandle only detaches it in Tokio.
359 let task_registrar = app_state.clone();
360 let task_handle = tokio::spawn(async move {
361 let mut buffer = vec![0u8; 1500]; // Max UDP packet size
362
363 loop {
364 match socket_clone.recv_from(&mut buffer).await {
365 Ok((n, peer)) => {
366 trace!(
367 "RIP client {} received {} bytes from {}",
368 client_id,
369 n,
370 peer
371 );
372
373 // Parse RIP message
374 match RipMessage::decode(&buffer[..n]) {
375 Ok(msg) => {
376 let mut client_data_lock = client_data_clone.lock().await;
377
378 match client_data_lock.state {
379 ConnectionState::Idle => {
380 // Process immediately
381 client_data_lock.state = ConnectionState::Processing;
382 drop(client_data_lock);
383
384 // Drain anything the Processing/Accumulating arms
385 // parked, oldest first, and only then go back to Idle
386 // — the TCP reference loops back over `queued_data`
387 // the same way, where this used to `clear()` the queue
388 // unread.
389 //
390 // Today the queue cannot actually fill: this task is
391 // the only producer and it is parked on the LLM call
392 // below, so `Processing`/`Accumulating` are
393 // unreachable. The drain is here so the arms mean what
394 // they say if a second producer ever appears, not
395 // because datagrams are being lost now.
396 let mut current = msg;
397 loop {
398 // Call LLM with response event
399 if let Some(instruction) = app_state
400 .get_instruction_for_client(client_id)
401 .await
402 {
403 let routes: Vec<_> = current
404 .routes
405 .iter()
406 .map(|r| {
407 serde_json::json!({
408 "ip_address": r.ip_address.to_string(),
409 "subnet_mask": r.subnet_mask.to_string(),
410 "next_hop": r.next_hop.to_string(),
411 "metric": r.metric,
412 })
413 })
414 .collect();
415
416 let event = Event::new(
417 &RIP_CLIENT_RESPONSE_RECEIVED_EVENT,
418 serde_json::json!({
419 "version": current.version as u8,
420 "command": match current.command {
421 RipCommand::Request => "request",
422 RipCommand::Response => "response",
423 },
424 "route_count": routes.len(),
425 "routes": routes,
426 }),
427 );
428
429 // Snapshot before the match: a `MutexGuard` built
430 // in the scrutinee lives to the end of the whole
431 // `match`, so it would be held across the LLM
432 // await and the `Ok` arm's own `lock()` below
433 // would deadlock on it.
434 let memory =
435 client_data_clone.lock().await.memory.clone();
436 match call_llm_for_client(
437 &llm_client,
438 &app_state,
439 client_id.to_string(),
440 &instruction,
441 &memory,
442 Some(&event),
443 protocol.as_ref(),
444 &status_tx,
445 )
446 .await
447 {
448 Ok(ClientLlmResult {
449 actions,
450 memory_updates,
451 }) => {
452 // Update memory
453 if let Some(mem) = memory_updates {
454 client_data_clone.lock().await.memory =
455 mem;
456 }
457
458 // Execute actions
459 for action in actions {
460 match protocol.as_ref().execute_action(action) {
461 Ok(result) => {
462 match Self::apply_action(client_id, result, &socket_clone, remote_sock_addr).await {
463 Ok(Applied::Disconnect) => {
464 info!("RIP client {} disconnecting", client_id);
465 app_state.remove_client_handle(client_id).await;
466 app_state.update_client_status(client_id, ClientStatus::Disconnected).await;
467 let _ = status_tx.send("__UPDATE_UI__".to_string());
468 return;
469 }
470 Ok(_) => {}
471 Err(e) => error!("Failed to send RIP request: {}", e),
472 }
473 }
474 Err(e) => error!("RIP client {} could not execute action: {}", client_id, e),
475 }
476 }
477 }
478 Err(e) => {
479 error!(
480 "LLM error for RIP client {}: {}",
481 client_id, e
482 );
483 }
484 }
485 }
486
487 // Take the next datagram that queued up while the
488 // model was deciding, oldest first. Only when the
489 // queue is empty does the client go back to Idle.
490 let mut client_data_lock =
491 client_data_clone.lock().await;
492 if client_data_lock.queued_responses.is_empty() {
493 client_data_lock.state = ConnectionState::Idle;
494 break;
495 }
496 current = client_data_lock.queued_responses.remove(0);
497 }
498 }
499 ConnectionState::Processing => {
500 // Queue response
501 client_data_lock.queued_responses.push(msg);
502 client_data_lock.state = ConnectionState::Accumulating;
503 }
504 ConnectionState::Accumulating => {
505 // Continue queuing
506 client_data_lock.queued_responses.push(msg);
507 }
508 }
509 }
510 Err(e) => {
511 error!("Failed to parse RIP message: {}", e);
512 }
513 }
514 }
515 Err(e) => {
516 error!("RIP client {} recv error: {}", client_id, e);
517 app_state
518 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
519 .await;
520 let _ = status_tx.send("__UPDATE_UI__".to_string());
521 break;
522 }
523 }
524 }
525 // Every exit path lands here: drop the command handle so the dashboard stops
526 // offering [ send ] on a dead client, and a late send fails fast.
527 app_state.remove_client_handle(client_id).await;
528 let _ = status_tx.send("__UPDATE_UI__".to_string());
529 });
530 let _ = read_abort.set(task_handle.abort_handle());
531 task_registrar
532 .register_client_task(client_id, task_handle)
533 .await;
534
535 Ok(local_addr)
536 }
537
538 /// Drain injected commands until the channel closes (client removed) or an injected
539 /// `disconnect` ends the session.
540 ///
541 /// The generic `command_support::handle_stream_client_command` cannot serve this client:
542 /// `send_rip_request` yields `ClientActionResult::Custom` and the transport is a datagram
543 /// socket, not an `AsyncWrite`. The action goes through [`Self::apply_action`] - the same
544 /// function the LLM path uses - so the RIP encoding exists exactly once.
545 #[allow(clippy::too_many_arguments)]
546 async fn command_loop(
547 mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
548 protocol: Arc<crate::client::rip::actions::RipClientProtocol>,
549 socket: Arc<UdpSocket>,
550 remote_addr: SocketAddr,
551 client_id: ClientId,
552 app_state: Arc<AppState>,
553 status_tx: mpsc::UnboundedSender<String>,
554 read_abort: Arc<std::sync::OnceLock<tokio::task::AbortHandle>>,
555 ) {
556 while let Some(command) = command_rx.recv().await {
557 let action = command.action.clone();
558 let outcome = match protocol.execute_action(action.clone()) {
559 Err(e) => Ok(ClientSendOutcome::Rejected {
560 error: e.to_string(),
561 }),
562 Ok(result) => Self::apply_action(client_id, result, &socket, remote_addr)
563 .await
564 .map(|applied| match applied {
565 Applied::Disconnect => ClientSendOutcome::Disconnected,
566 Applied::Sent(0) => ClientSendOutcome::Executed {
567 detail: "executed (no datagram sent)".to_string(),
568 },
569 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
570 Applied::Nothing(detail) => ClientSendOutcome::Executed { detail },
571 }),
572 };
573
574 let outcome_json = match &outcome {
575 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
576 Err(e) => serde_json::json!({"error": e.to_string()}),
577 };
578 app_state
579 .record_access_log(
580 AccessLogOwner::Client(client_id.as_u32()),
581 protocol.protocol_name(),
582 None,
583 "injected_action",
584 action,
585 vec![outcome_json],
586 )
587 .await;
588
589 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
590 if let Err(e) = &outcome {
591 error!("RIP client {} injected action failed: {}", client_id, e);
592 let _ = status_tx.send(format!(
593 "[WARN] Client {} injected action failed: {}",
594 client_id, e
595 ));
596 }
597 let _ = status_tx.send("__UPDATE_UI__".to_string());
598 crate::client::command_support::reply(command, outcome);
599
600 if disconnect {
601 // UDP has no wire close, so "disconnected" means: stop receiving, release
602 // the socket, drop the handle so [ send ] is greyed out again. If the
603 // receive loop has not been spawned yet (the connected-event call is still
604 // parked), the missing handle makes `connect_with_llm_actions` skip it.
605 if let Some(abort) = read_abort.get() {
606 abort.abort();
607 }
608 app_state.remove_client_handle(client_id).await;
609 app_state
610 .update_client_status(client_id, ClientStatus::Disconnected)
611 .await;
612 let _ = status_tx.send("__UPDATE_UI__".to_string());
613 break;
614 }
615 }
616 }
617
618 /// Put one executed action on the wire. Shared by the connected-event path, the receive
619 /// loop and injected commands so the RIP request encoding exists exactly once.
620 async fn apply_action(
621 client_id: ClientId,
622 result: ClientActionResult,
623 socket: &UdpSocket,
624 remote_addr: SocketAddr,
625 ) -> Result<Applied> {
626 match result {
627 ClientActionResult::Custom { name, data } if name == "send_rip_request" => {
628 let version = data["version"]
629 .as_u64()
630 .context("send_rip_request is missing 'version'")?;
631 // Refuse a version we cannot encode rather than silently sending v2 and
632 // logging the number the caller asked for — RFC 1058 defines 1, RFC 2453
633 // defines 2, and there is no third.
634 let rip_version = match version {
635 1 => RipVersion::V1,
636 2 => RipVersion::V2,
637 other => {
638 return Err(anyhow::anyhow!(
639 "send_rip_request: unsupported RIP version {other} (must be 1 or 2)"
640 ))
641 }
642 };
643 let bytes = RipMessage::request(rip_version).encode();
644 let sent = socket.send_to(&bytes, remote_addr).await?;
645 debug!(
646 "RIP client {} sent request (version {}, {} bytes)",
647 client_id, rip_version as u8, sent
648 );
649 Ok(Applied::Sent(sent))
650 }
651 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
652 ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more".to_string())),
653 ClientActionResult::Custom { name, .. } => Ok(Applied::Nothing(format!(
654 "custom result '{name}' is not a RIP request; nothing sent"
655 ))),
656 other => Ok(Applied::Nothing(format!(
657 "action result {other:?} produced no datagram"
658 ))),
659 }
660 }
661}
662
663/// What [`RipClient::apply_action`] did with one action.
664#[derive(Debug)]
665enum Applied {
666 /// Datagram bytes actually handed to `send_to` (0 when nothing was sent).
667 Sent(usize),
668 /// Ran, but produced no datagram; the string says why.
669 Nothing(String),
670 /// The session should end.
671 Disconnect,
672}