netget/client/igmp/mod.rs
1//! IGMP client implementation for multicast group management
2pub mod actions;
3
4pub use actions::IgmpClientProtocol;
5
6use anyhow::{Context, Result};
7use std::collections::HashSet;
8use std::net::{Ipv4Addr, SocketAddr};
9use std::sync::Arc;
10use tokio::net::UdpSocket;
11use tokio::sync::{mpsc, Mutex};
12use tracing::{error, info, trace, warn};
13
14use crate::client::igmp::actions::{IGMP_CLIENT_CONNECTED_EVENT, IGMP_CLIENT_DATA_RECEIVED_EVENT};
15use crate::client::llm_budget::call_llm_for_client;
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::actions::protocol_trait::Protocol;
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::protocol::Event;
21use crate::state::app_state::AppState;
22use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
23use crate::state::{AccessLogOwner, ClientId, ClientStatus};
24
25/// How much of a received datagram is hex-encoded into the `igmp_data_received` event.
26///
27/// A UDP datagram can be 64 KiB, which is 128 KiB of hex — enough to fill a model's whole
28/// context with one multicast frame, and to do it again for every frame that arrives. The
29/// event carries the true `data_length` and a `data_truncated` flag either way, so the model
30/// is told what it is not being shown rather than quietly given a prefix.
31const MAX_EVENT_PAYLOAD_BYTES: usize = 2048;
32
33/// Hex for the event, plus whether it was cut short.
34fn payload_hex(data: &[u8]) -> (String, bool) {
35 if data.len() <= MAX_EVENT_PAYLOAD_BYTES {
36 (hex::encode(data), false)
37 } else {
38 (hex::encode(&data[..MAX_EVENT_PAYLOAD_BYTES]), true)
39 }
40}
41
42/// Per-client data for LLM handling.
43///
44/// There is deliberately no Idle/Processing/Accumulating state machine here. The one the other
45/// protocols carry exists to stop two concurrent readers making concurrent LLM calls on one
46/// connection; this client has a single receive task that awaits its LLM round-trip inline
47/// before it calls `recv_from` again, so `Processing` could never be observed. The version that
48/// had one queued nothing (the queue was only ever reachable from the unreachable arm) and
49/// ended each pass by *clearing* the queue, so had it ever fired it would have silently dropped
50/// the datagrams it collected. Datagrams that arrive during an LLM call wait in the kernel
51/// socket buffer, which is where they belong.
52struct IgmpClientData {
53 memory: String,
54 joined_groups: HashSet<Ipv4Addr>,
55}
56
57/// IGMP client for multicast group management
58pub struct IgmpClient;
59
60impl IgmpClient {
61 /// Connect (bind) IGMP client with integrated LLM actions
62 pub async fn connect_with_llm_actions(
63 bind_addr: String,
64 llm_client: OllamaClient,
65 app_state: Arc<AppState>,
66 status_tx: mpsc::UnboundedSender<String>,
67 client_id: ClientId,
68 ) -> Result<SocketAddr> {
69 // Parse bind address - use 0.0.0.0:0 if not specified
70 let socket_addr: SocketAddr = if bind_addr.is_empty() || bind_addr == "igmp" {
71 "0.0.0.0:0".parse()?
72 } else {
73 bind_addr.parse().context("Invalid bind address")?
74 };
75
76 // Create UDP socket for multicast reception
77 let socket = UdpSocket::bind(socket_addr)
78 .await
79 .context("Failed to bind UDP socket for IGMP client")?;
80
81 let local_addr = socket.local_addr()?;
82
83 info!("IGMP client {} bound to {}", client_id, local_addr);
84
85 // Update client state
86 app_state
87 .update_client_status(client_id, ClientStatus::Connected)
88 .await;
89 let _ = status_tx.send(format!("[CLIENT] IGMP client {} ready", client_id));
90 let _ = status_tx.send("__UPDATE_UI__".to_string());
91
92 // Wrap socket in Arc for shared access
93 let socket_arc = Arc::new(socket);
94
95 // Initialize client data
96 let client_data = Arc::new(Mutex::new(IgmpClientData {
97 memory: String::new(),
98 joined_groups: HashSet::new(),
99 }));
100
101 let protocol = Arc::new(IgmpClientProtocol::new());
102
103 // Command channel for injected actions (the dashboard's [ send ]). Registered, and
104 // drained by a live task, BEFORE the connected-event LLM call below: that call is
105 // awaited inline here, so a manual `*` routing rule parks it - and the whole point
106 // of the channel is that the operator can still reach the client while it waits.
107 let command_rx =
108 crate::client::command_support::register_command_channel(&app_state, client_id).await;
109 // Filled in once the receive loop exists (it is spawned after the connected-event
110 // call), so an injected `disconnect` can actually stop it and release the socket.
111 let read_abort: Arc<std::sync::OnceLock<tokio::task::AbortHandle>> =
112 Arc::new(std::sync::OnceLock::new());
113 let cmd_task = tokio::spawn(Self::command_loop(
114 command_rx,
115 protocol.clone(),
116 socket_arc.clone(),
117 client_data.clone(),
118 client_id,
119 app_state.clone(),
120 status_tx.clone(),
121 read_abort.clone(),
122 ));
123 app_state.register_client_task(client_id, cmd_task).await;
124
125 // Trigger initial connected event
126 let client_instance = app_state.get_client(client_id).await;
127 let instruction = client_instance
128 .as_ref()
129 .map(|c| c.instruction.clone())
130 .unwrap_or_default();
131
132 let connected_event = Event::new(
133 &IGMP_CLIENT_CONNECTED_EVENT,
134 serde_json::json!({
135 "local_addr": local_addr.to_string(),
136 }),
137 );
138
139 // Initial LLM call. Its actions used to be discarded (`let _ = ...`), so a model
140 // that answered the connected event with a `join_multicast_group` was ignored;
141 // they now go through the same `apply_action` the read loop and injected commands
142 // use.
143 match call_llm_for_client(
144 &llm_client,
145 &app_state,
146 client_id.to_string(),
147 &instruction,
148 "",
149 Some(&connected_event),
150 protocol.as_ref(),
151 &status_tx,
152 )
153 .await
154 {
155 Ok(result) => {
156 for action in result.actions {
157 match protocol.as_ref().execute_action(action) {
158 Ok(action_result) => {
159 if let Err(e) = Self::apply_action(
160 client_id,
161 action_result,
162 &socket_arc,
163 &client_data,
164 &status_tx,
165 )
166 .await
167 {
168 error!(
169 "IGMP client {} action failed after connect: {}",
170 client_id, e
171 );
172 let _ = status_tx.send(format!(
173 "[CLIENT] IGMP client {} action failed after connect: {}",
174 client_id, e
175 ));
176 }
177 }
178 Err(e) => {
179 // On the status stream as well as in the log: this is where a
180 // model's malformed answer to `igmp_connected` lands, and until
181 // it was surfaced a client told to join a group could report a
182 // clean start having joined nothing.
183 warn!(
184 "IGMP client {} could not execute action after connect: {}",
185 client_id, e
186 );
187 let _ = status_tx.send(format!(
188 "[CLIENT] IGMP client {} rejected an action after connect: {}",
189 client_id, e
190 ));
191 }
192 }
193 }
194 }
195 Err(e) => error!("LLM error on igmp_client_connected event: {}", e),
196 }
197
198 // An injected `disconnect` that arrived while the connected-event call was parked
199 // has already dropped the command handle. Honour it rather than starting a receive
200 // loop the operator just asked to stop.
201 if !app_state.has_client_handle(client_id).await {
202 return Ok(local_addr);
203 }
204
205 // Clone references for read loop
206 let socket_clone = socket_arc.clone();
207 let app_state_clone = app_state.clone();
208 let status_tx_clone = status_tx.clone();
209 let llm_client_clone = llm_client.clone();
210 let client_data_clone = client_data.clone();
211 let protocol = protocol.clone();
212
213 // Spawn read loop for receiving multicast data
214 // Registered with AppState so stop_client can abort this task —
215 // dropping a JoinHandle only detaches it in Tokio.
216 let task_registrar = app_state.clone();
217 let task_handle = tokio::spawn(async move {
218 let mut buffer = vec![0u8; 65536];
219 // Lifecycle, at INFO: without it there is no way to tell "the datagram never
220 // arrived" from "nothing was ever listening for it", and the two have very
221 // different causes.
222 info!("IGMP client {} receive loop listening", client_id);
223
224 loop {
225 match socket_clone.recv_from(&mut buffer).await {
226 Ok((n, peer_addr)) => {
227 let data = buffer[..n].to_vec();
228 trace!(
229 "IGMP client {} received {} bytes from {}",
230 client_id,
231 n,
232 peer_addr
233 );
234
235 // Ask the model what to do about this datagram and apply its answer.
236 // No state machine guards this: the same task does the `recv_from`
237 // and awaits the LLM call, so nothing else can be mid-call here
238 // (see `IgmpClientData`).
239 let Some(instruction) =
240 app_state_clone.get_instruction_for_client(client_id).await
241 else {
242 continue;
243 };
244
245 let memory = {
246 let data_lock = client_data_clone.lock().await;
247 data_lock.memory.clone()
248 };
249
250 let (data_hex, truncated) = payload_hex(&data);
251 let event = Event::new(
252 &IGMP_CLIENT_DATA_RECEIVED_EVENT,
253 serde_json::json!({
254 "data_hex": data_hex,
255 "data_length": n,
256 "data_truncated": truncated,
257 "source_addr": peer_addr.to_string(),
258 }),
259 );
260
261 match call_llm_for_client(
262 &llm_client_clone,
263 &app_state_clone,
264 client_id.to_string(),
265 &instruction,
266 &memory,
267 Some(&event),
268 protocol.as_ref(),
269 &status_tx_clone,
270 )
271 .await
272 {
273 Ok(ClientLlmResult {
274 actions,
275 memory_updates,
276 }) => {
277 if let Some(mem) = memory_updates {
278 client_data_clone.lock().await.memory = mem;
279 }
280
281 for action in actions {
282 match protocol.as_ref().execute_action(action) {
283 Ok(result) => {
284 match Self::apply_action(
285 client_id,
286 result,
287 &socket_clone,
288 &client_data_clone,
289 &status_tx_clone,
290 )
291 .await
292 {
293 Ok(Applied::Disconnect) => {
294 info!(
295 "IGMP client {} disconnecting",
296 client_id
297 );
298 app_state_clone
299 .remove_client_handle(client_id)
300 .await;
301 app_state_clone
302 .update_client_status(
303 client_id,
304 ClientStatus::Disconnected,
305 )
306 .await;
307 let _ = status_tx_clone
308 .send("__UPDATE_UI__".to_string());
309 return;
310 }
311 Ok(_) => {}
312 Err(e) => {
313 error!(
314 "IGMP client {} action failed: {}",
315 client_id, e
316 );
317 let _ = status_tx_clone.send(format!(
318 "[CLIENT] IGMP client {} action failed: {}",
319 client_id, e
320 ));
321 }
322 }
323 }
324 Err(e) => {
325 // Visible, not only in the log file: an action the
326 // protocol cannot execute means the model's answer
327 // never reached the wire, and nothing else says so.
328 warn!(
329 "Action execution error for IGMP client {}: {}",
330 client_id, e
331 );
332 let _ = status_tx_clone.send(format!(
333 "[CLIENT] IGMP client {} rejected an action: {}",
334 client_id, e
335 ));
336 }
337 }
338 }
339 }
340 Err(e) => {
341 error!("LLM error for IGMP client {}: {}", client_id, e);
342 }
343 }
344 }
345 Err(e) => {
346 error!("IGMP client {} read error: {}", client_id, e);
347 app_state_clone
348 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
349 .await;
350 let _ = status_tx_clone
351 .send(format!("[CLIENT] IGMP client {} error: {}", client_id, e));
352 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
353 break;
354 }
355 }
356 }
357 // Every exit path lands here: drop the command handle so the dashboard stops
358 // offering [ send ] on a dead client, and a late send fails fast.
359 app_state_clone.remove_client_handle(client_id).await;
360 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
361 });
362 let _ = read_abort.set(task_handle.abort_handle());
363 task_registrar
364 .register_client_task(client_id, task_handle)
365 .await;
366
367 Ok(local_addr)
368 }
369
370 /// Drain injected commands until the channel closes (client removed) or an injected
371 /// `disconnect` ends the session.
372 ///
373 /// The generic `command_support::handle_stream_client_command` cannot serve this client:
374 /// every IGMP verb yields `ClientActionResult::Custom` and the transport is a datagram
375 /// socket, not an `AsyncWrite`. The action goes through [`Self::apply_action`] - the same
376 /// function the connected-event path and the receive loop use.
377 #[allow(clippy::too_many_arguments)]
378 async fn command_loop(
379 mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
380 protocol: Arc<IgmpClientProtocol>,
381 socket: Arc<UdpSocket>,
382 client_data: Arc<Mutex<IgmpClientData>>,
383 client_id: ClientId,
384 app_state: Arc<AppState>,
385 status_tx: mpsc::UnboundedSender<String>,
386 read_abort: Arc<std::sync::OnceLock<tokio::task::AbortHandle>>,
387 ) {
388 while let Some(command) = command_rx.recv().await {
389 let action = command.action.clone();
390 let outcome = match protocol.execute_action(action.clone()) {
391 Err(e) => Ok(ClientSendOutcome::Rejected {
392 error: e.to_string(),
393 }),
394 Ok(result) => {
395 Self::apply_action(client_id, result, &socket, &client_data, &status_tx)
396 .await
397 .map(|applied| match applied {
398 Applied::Disconnect => ClientSendOutcome::Disconnected,
399 Applied::Sent(0) => ClientSendOutcome::Executed {
400 detail: "executed (no datagram sent)".to_string(),
401 },
402 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
403 Applied::Nothing(detail) => ClientSendOutcome::Executed { detail },
404 })
405 }
406 };
407
408 let outcome_json = match &outcome {
409 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
410 Err(e) => serde_json::json!({"error": e.to_string()}),
411 };
412 app_state
413 .record_access_log(
414 AccessLogOwner::Client(client_id.as_u32()),
415 protocol.protocol_name(),
416 None,
417 "injected_action",
418 action,
419 vec![outcome_json],
420 )
421 .await;
422
423 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
424 if let Err(e) = &outcome {
425 error!("IGMP client {} injected action failed: {}", client_id, e);
426 let _ = status_tx.send(format!(
427 "[WARN] Client {} injected action failed: {}",
428 client_id, e
429 ));
430 }
431 let _ = status_tx.send("__UPDATE_UI__".to_string());
432 crate::client::command_support::reply(command, outcome);
433
434 if disconnect {
435 if let Some(abort) = read_abort.get() {
436 abort.abort();
437 }
438 app_state.remove_client_handle(client_id).await;
439 app_state
440 .update_client_status(client_id, ClientStatus::Disconnected)
441 .await;
442 let _ = status_tx.send("__UPDATE_UI__".to_string());
443 break;
444 }
445 }
446 }
447
448 /// Apply one executed action. Shared by the connected-event path, the receive loop and
449 /// injected commands so the multicast machinery exists exactly once.
450 ///
451 /// The group joins run on the client's **own** receive socket. They used to be applied
452 /// to a throwaway `socket2::Socket` created inside the match and dropped at the end of
453 /// it, so the membership died immediately and the socket the read loop was polling never
454 /// joined anything - the client reported success and received no multicast.
455 async fn apply_action(
456 client_id: ClientId,
457 result: ClientActionResult,
458 socket: &UdpSocket,
459 client_data: &Arc<Mutex<IgmpClientData>>,
460 status_tx: &mpsc::UnboundedSender<String>,
461 ) -> Result<Applied> {
462 match result {
463 ClientActionResult::Custom { name, data } if name == "join_multicast_group" => {
464 let (mcast_ip, iface_ip) = Self::group_args(&data)?;
465 if client_data.lock().await.joined_groups.contains(&mcast_ip) {
466 return Ok(Applied::Nothing(format!(
467 "already a member of {mcast_ip}; no IGMP report emitted"
468 )));
469 }
470 socket
471 .join_multicast_v4(mcast_ip, iface_ip)
472 .with_context(|| format!("failed to join multicast group {mcast_ip}"))?;
473 client_data.lock().await.joined_groups.insert(mcast_ip);
474 info!(
475 "IGMP client {} joined multicast group {}",
476 client_id, mcast_ip
477 );
478 let _ = status_tx.send(format!("[CLIENT] Joined multicast group {}", mcast_ip));
479 Ok(Applied::Nothing(format!(
480 "joined {mcast_ip} on {iface_ip}; the kernel emits the IGMP membership report"
481 )))
482 }
483 ClientActionResult::Custom { name, data } if name == "leave_multicast_group" => {
484 let (mcast_ip, iface_ip) = Self::group_args(&data)?;
485 socket
486 .leave_multicast_v4(mcast_ip, iface_ip)
487 .with_context(|| format!("failed to leave multicast group {mcast_ip}"))?;
488 client_data.lock().await.joined_groups.remove(&mcast_ip);
489 info!(
490 "IGMP client {} left multicast group {}",
491 client_id, mcast_ip
492 );
493 let _ = status_tx.send(format!("[CLIENT] Left multicast group {}", mcast_ip));
494 Ok(Applied::Nothing(format!(
495 "left {mcast_ip} on {iface_ip}; the kernel emits the IGMP leave"
496 )))
497 }
498 ClientActionResult::Custom { name, data } if name == "send_multicast" => {
499 let mcast = data["multicast_addr"]
500 .as_str()
501 .context("send_multicast is missing 'multicast_addr'")?;
502 let port = data["port"]
503 .as_u64()
504 .context("send_multicast is missing 'port'")?;
505 let bytes = crate::client::wire_values::bytes(
506 data["data"]
507 .as_array()
508 .context("send_multicast is missing decoded 'data'")?,
509 )?;
510 let dest_addr: SocketAddr = format!("{}:{}", mcast, port)
511 .parse()
512 .with_context(|| format!("invalid multicast destination {mcast}:{port}"))?;
513 let sent = socket.send_to(&bytes, dest_addr).await?;
514 trace!(
515 "IGMP client {} sent {} bytes to {}",
516 client_id,
517 sent,
518 dest_addr
519 );
520 let _ = status_tx.send(format!(
521 "[CLIENT] Sent {} bytes to multicast {}",
522 sent, dest_addr
523 ));
524 Ok(Applied::Sent(sent))
525 }
526 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
527 ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more".to_string())),
528 ClientActionResult::Custom { name, .. } => Ok(Applied::Nothing(format!(
529 "custom result '{name}' is not an IGMP verb; nothing sent"
530 ))),
531 other => Ok(Applied::Nothing(format!(
532 "action result {other:?} produced no datagram"
533 ))),
534 }
535 }
536
537 /// Pull the multicast/interface address pair out of a join/leave action's data.
538 fn group_args(data: &serde_json::Value) -> Result<(Ipv4Addr, Ipv4Addr)> {
539 let mcast: Ipv4Addr = data["multicast_addr"]
540 .as_str()
541 .context("missing 'multicast_addr'")?
542 .parse()
543 .context("invalid multicast address")?;
544 let iface: Ipv4Addr = data["interface_addr"]
545 .as_str()
546 .unwrap_or("0.0.0.0")
547 .parse()
548 .context("invalid interface address")?;
549 Ok((mcast, iface))
550 }
551}
552
553/// What [`IgmpClient::apply_action`] did with one action.
554#[derive(Debug)]
555enum Applied {
556 /// Datagram bytes actually handed to `send_to` (0 when nothing was sent).
557 Sent(usize),
558 /// Ran, but produced no datagram of our own; the string says what happened.
559 Nothing(String),
560 /// The session should end.
561 Disconnect,
562}