Skip to main content

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}