Skip to main content

netget/client/snmp/
mod.rs

1//! SNMP client implementation
2pub mod actions;
3
4pub use actions::SnmpClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::net::UdpSocket;
11use tokio::sync::mpsc;
12use tokio::time::timeout;
13use tracing::{debug, error, info, trace};
14
15use crate::client::llm_budget::call_llm_for_client;
16use crate::client::snmp::actions::{
17    SNMP_CLIENT_CONNECTED_EVENT, SNMP_CLIENT_RESPONSE_RECEIVED_EVENT,
18};
19use crate::llm::actions::client_trait::Client;
20use crate::llm::ollama_client::OllamaClient;
21use crate::llm::ClientLlmResult;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
25use crate::state::{AccessLogOwner, ClientId, ClientStatus};
26
27// SNMP protocol support
28use rasn::ber;
29use rasn::types::{Integer, ObjectIdentifier};
30use rasn_smi::v1::{ObjectSyntax as V1ObjectSyntax, SimpleSyntax as V1SimpleSyntax};
31use rasn_smi::v2::{ObjectSyntax as V2ObjectSyntax, SimpleSyntax as V2SimpleSyntax};
32use rasn_snmp::{v1, v2, v2c};
33use serde_json::Value;
34
35use crate::server::snmp::usm;
36
37/// SNMP client configuration
38#[derive(Clone)]
39struct SnmpConfig {
40    community: String,
41    version: SnmpVersion,
42    timeout_ms: u64,
43    retries: u32,
44    /// SNMPv3: the user and the engine it has discovered.
45    usm: Option<Arc<usm::Manager>>,
46    context_name: String,
47}
48
49impl std::fmt::Debug for SnmpConfig {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("SnmpConfig")
52            .field("version", &self.version)
53            .field("timeout_ms", &self.timeout_ms)
54            .field("retries", &self.retries)
55            .field("v3_user", &self.usm.as_ref().map(|m| m.user.name.clone()))
56            .finish_non_exhaustive()
57    }
58}
59
60impl Default for SnmpConfig {
61    fn default() -> Self {
62        Self {
63            community: "public".to_string(),
64            version: SnmpVersion::V2c,
65            timeout_ms: 5000,
66            retries: 3,
67            usm: None,
68            context_name: String::new(),
69        }
70    }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum SnmpVersion {
75    V1,
76    V2c,
77    V3,
78}
79
80/// Parse startup parameters
81fn parse_startup_params(params: Option<crate::protocol::StartupParams>) -> Result<SnmpConfig> {
82    let mut config = SnmpConfig::default();
83
84    if let Some(params) = params {
85        if let Some(community) = params.get_optional_string("community")? {
86            config.community = community;
87        }
88        if let Some(version) = params.get_optional_string("version")? {
89            config.version = match version.to_lowercase().as_str() {
90                "v1" | "1" => SnmpVersion::V1,
91                "v2c" | "v2" | "2c" | "2" => SnmpVersion::V2c,
92                "v3" | "3" => SnmpVersion::V3,
93                other => anyhow::bail!("SNMP version {other:?} is not v1, v2c or v3"),
94            };
95        }
96        if let Some(user) = params.get_optional_object("v3_user")? {
97            config.usm = Some(Arc::new(usm::Manager::new(usm::User::from_json(
98                &Value::Object(user.clone()),
99            )?)));
100        }
101        if let Some(context) = params.get_optional_string("context_name")? {
102            anyhow::ensure!(context.len() <= 32, "context_name is at most 32 bytes");
103            config.context_name = context;
104        }
105        if let Some(timeout) = params.get_optional_i64("timeout_ms")? {
106            config.timeout_ms =
107                u64::try_from(timeout).context("SNMP timeout_ms must be nonnegative")?;
108        }
109        if let Some(retries) = params.get_optional_i64("retries")? {
110            config.retries = u32::try_from(retries).context("SNMP retries must fit u32")?;
111        }
112    }
113
114    match (config.version, config.usm.is_some()) {
115        (SnmpVersion::V3, false) => anyhow::bail!("version v3 needs a v3_user"),
116        (SnmpVersion::V1 | SnmpVersion::V2c, true) => {
117            anyhow::bail!("v3_user is set but version is not v3")
118        }
119        _ => {}
120    }
121    Ok(config)
122}
123
124/// Helper function to parse OID string to ObjectIdentifier
125fn parse_oid(oid_str: &str) -> ObjectIdentifier {
126    let components: Vec<u32> = oid_str
127        .split('.')
128        .filter_map(|s| s.parse::<u32>().ok())
129        .collect();
130
131    if components.is_empty() {
132        // Return a default OID if parsing fails
133        ObjectIdentifier::new_unchecked(vec![1, 3, 6, 1, 2, 1, 1, 1, 0].into())
134    } else {
135        ObjectIdentifier::new_unchecked(components.into())
136    }
137}
138
139/// SNMP client that connects to an SNMP agent
140pub struct SnmpClient;
141
142impl SnmpClient {
143    /// Connect to an SNMP agent with integrated LLM actions
144    pub async fn connect_with_llm_actions(
145        remote_addr: String,
146        llm_client: OllamaClient,
147        app_state: Arc<AppState>,
148        status_tx: mpsc::UnboundedSender<String>,
149        client_id: ClientId,
150        startup_params: Option<crate::protocol::StartupParams>,
151    ) -> Result<SocketAddr> {
152        // Parse configuration
153        let config = parse_startup_params(startup_params)?;
154        debug!("SNMP client config: {:?}", config);
155
156        // Bind UDP socket to any local port
157        let socket = UdpSocket::bind("0.0.0.0:0")
158            .await
159            .context("Failed to bind UDP socket")?;
160
161        let local_addr = socket.local_addr()?;
162
163        // Connect to remote agent (sets default destination for send/recv)
164        socket.connect(&remote_addr).await.context(format!(
165            "Failed to connect to SNMP agent at {}",
166            remote_addr
167        ))?;
168
169        let remote_sock_addr: SocketAddr = remote_addr.parse().context("Invalid remote address")?;
170
171        // SNMPv3: learn the agent's engine before anything else, so every request can be
172        // authenticated against it. An agent that never answers discovery cannot be talked to.
173        if let Some(manager) = &config.usm {
174            Self::discover(&socket, manager, &config)
175                .await
176                .with_context(|| format!("SNMPv3 engine discovery with {remote_addr} failed"))?;
177            info!(
178                "SNMP client {} discovered engine {}",
179                client_id,
180                hex::encode(manager.engine_id().unwrap_or_default())
181            );
182        }
183
184        info!(
185            "SNMP client {} connected to {} (local: {})",
186            client_id, remote_sock_addr, local_addr
187        );
188
189        // Update client state
190        app_state
191            .update_client_status(client_id, ClientStatus::Connected)
192            .await;
193        let _ = status_tx.send(format!("[CLIENT] SNMP client {} connected", client_id));
194        let _ = status_tx.send("__UPDATE_UI__".to_string());
195
196        let protocol = Arc::new(SnmpClientProtocol::new());
197        let socket = Arc::new(socket);
198        let config = Arc::new(config);
199
200        // Command channel: lets the dashboard (and any programmatic caller) inject
201        // actions into this client via AppState::send_to_client.
202        //
203        // Registered BEFORE the connected event is handled: a `manual` routing rule can
204        // park that event at the dashboard for minutes, and until registration the UI
205        // reports "no command channel" - reading as a protocol limitation when it is
206        // only a queue.
207        let command_rx =
208            crate::client::command_support::register_command_channel(&app_state, client_id).await;
209
210        // SNMP has no standing read loop - each request awaits its own reply - so the
211        // commands are drained by a task of their own.
212        let cmd_protocol = protocol.clone();
213        let cmd_socket = socket.clone();
214        let cmd_config = config.clone();
215        let cmd_llm = llm_client.clone();
216        let cmd_state = app_state.clone();
217        let cmd_status = status_tx.clone();
218        let cmd_task = tokio::spawn(async move {
219            Self::command_loop(
220                command_rx,
221                cmd_protocol,
222                cmd_socket,
223                cmd_config,
224                client_id,
225                cmd_llm,
226                cmd_state,
227                cmd_status,
228            )
229            .await;
230        });
231        app_state.register_client_task(client_id, cmd_task).await;
232
233        // Call LLM with connected event
234        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
235            let event = Event::new(
236                &SNMP_CLIENT_CONNECTED_EVENT,
237                serde_json::json!({
238                    "remote_addr": remote_sock_addr.to_string(),
239                }),
240            );
241
242            let memory = app_state
243                .get_memory_for_client(client_id)
244                .await
245                .unwrap_or_default();
246
247            let llm_clone = llm_client.clone();
248            let state_clone = app_state.clone();
249            let status_clone = status_tx.clone();
250            let protocol_clone = protocol.clone();
251            let socket_clone = socket.clone();
252            let config = config.clone();
253
254            // Spawn initial LLM call
255            // Registered with AppState so stop_client can abort this task —
256            // dropping a JoinHandle only detaches it in Tokio.
257            let task_registrar = app_state.clone();
258            let task_handle = tokio::spawn(async move {
259                match call_llm_for_client(
260                    &llm_clone,
261                    &state_clone,
262                    client_id.to_string(),
263                    &instruction,
264                    &memory,
265                    Some(&event),
266                    protocol_clone.as_ref(),
267                    &status_clone,
268                )
269                .await
270                {
271                    Ok(ClientLlmResult {
272                        actions,
273                        memory_updates,
274                    }) => {
275                        // Update memory
276                        if let Some(mem) = memory_updates {
277                            state_clone.set_memory_for_client(client_id, mem).await;
278                        }
279
280                        // Execute initial actions
281                        Self::execute_actions(
282                            actions,
283                            &protocol_clone,
284                            &socket_clone,
285                            client_id,
286                            &config,
287                            &llm_clone,
288                            &state_clone,
289                            &status_clone,
290                        )
291                        .await;
292                    }
293                    Err(e) => {
294                        error!(
295                            "Initial LLM call failed for SNMP client {}: {}",
296                            client_id, e
297                        );
298                    }
299                }
300            });
301            task_registrar
302                .register_client_task(client_id, task_handle)
303                .await;
304        }
305
306        Ok(local_addr)
307    }
308
309    /// Execute SNMP actions from LLM
310    async fn execute_actions(
311        actions: Vec<Value>,
312        protocol: &Arc<SnmpClientProtocol>,
313        socket: &Arc<UdpSocket>,
314        client_id: ClientId,
315        config: &Arc<SnmpConfig>,
316        llm_client: &OllamaClient,
317        app_state: &Arc<AppState>,
318        status_tx: &mpsc::UnboundedSender<String>,
319    ) {
320        use crate::llm::actions::client_trait::ClientActionResult;
321
322        for action in actions {
323            match protocol.execute_action(action) {
324                Ok(ClientActionResult::Custom { name, data }) => {
325                    match Self::build_request(&name, &data, config) {
326                        Ok((request_bytes, request_type)) => {
327                            if let Err(e) = Self::send_request_and_handle_response(
328                                socket,
329                                &request_bytes,
330                                request_type,
331                                config,
332                                client_id,
333                                llm_client,
334                                app_state,
335                                status_tx,
336                                protocol,
337                            )
338                            .await
339                            {
340                                error!("Failed to send SNMP {}: {}", request_type, e);
341                            }
342                        }
343                        Err(e) => {
344                            debug!("SNMP client {} could not build request: {}", client_id, e);
345                        }
346                    }
347                }
348                Ok(ClientActionResult::Disconnect) => {
349                    info!("SNMP client {} disconnecting", client_id);
350                    app_state
351                        .update_client_status(client_id, ClientStatus::Disconnected)
352                        .await;
353                    let _ = status_tx.send("__UPDATE_UI__".to_string());
354                }
355                Ok(ClientActionResult::WaitForMore) => {
356                    // No action needed
357                }
358                Ok(_) => {}
359                Err(e) => {
360                    error!("Failed to execute SNMP action: {}", e);
361                }
362            }
363        }
364    }
365
366    /// RFC 3414 §4: probe with an empty engine and learn it from the report.
367    async fn discover(
368        socket: &UdpSocket,
369        manager: &usm::Manager,
370        config: &SnmpConfig,
371    ) -> Result<()> {
372        let mut buffer = vec![0u8; 65535];
373        for _ in 0..=config.retries {
374            let (msg_id, probe) = manager.discovery()?;
375            socket.send(&probe).await?;
376            let deadline = tokio::time::Instant::now() + Duration::from_millis(config.timeout_ms);
377            while let Ok(Ok(n)) = tokio::time::timeout_at(deadline, socket.recv(&mut buffer)).await
378            {
379                if crate::server::snmp::check_ber_structure(
380                    &buffer[..n],
381                    crate::server::snmp::MAX_BER_DEPTH,
382                )
383                .is_err()
384                {
385                    continue;
386                }
387                if manager.learn(&buffer[..n], msg_id).is_ok() {
388                    return Ok(());
389                }
390            }
391        }
392        Err(anyhow::anyhow!(
393            "no discovery report after {} tries",
394            config.retries + 1
395        ))
396    }
397
398    /// Wrap a v2c-encoded request's PDU in an SNMPv3 message for the configured user.
399    fn to_v3(bytes: &[u8], config: &SnmpConfig) -> Result<Vec<u8>> {
400        let manager = config.usm.as_ref().context("v3 without a user")?;
401        let message: v2c::Message<v2::Pdus> =
402            ber::decode(bytes).map_err(|e| anyhow::anyhow!("request PDU does not decode: {e}"))?;
403        manager.wrap_request(message.data, config.context_name.as_bytes())
404    }
405
406    /// Encode the request datagram for one executed action.
407    ///
408    /// Pure - no I/O - so the LLM path and the command channel put byte-identical
409    /// PDUs on the wire. Returns the bytes plus the PDU name used in the response
410    /// event.
411    fn build_request(
412        name: &str,
413        data: &Value,
414        config: &SnmpConfig,
415    ) -> Result<(Vec<u8>, &'static str)> {
416        let (bytes, kind) = Self::build_community_request(name, data, config)?;
417        if config.version == SnmpVersion::V3 {
418            return Ok((Self::to_v3(&bytes, config)?, kind));
419        }
420        Ok((bytes, kind))
421    }
422
423    /// The v1/v2c datagram; for v3 the v2c one, whose PDU [`Self::to_v3`] re-wraps.
424    fn build_community_request(
425        name: &str,
426        data: &Value,
427        config: &SnmpConfig,
428    ) -> Result<(Vec<u8>, &'static str)> {
429        let oids: Vec<String> = data
430            .get("oids")
431            .and_then(|v| v.as_array())
432            .map(|a| {
433                a.iter()
434                    .filter_map(|v| v.as_str().map(String::from))
435                    .collect()
436            })
437            .unwrap_or_default();
438        let request_id = rand::random::<i32>();
439
440        match name {
441            "snmp_get" => {
442                let bytes = match config.version {
443                    SnmpVersion::V1 => {
444                        Self::build_v1_get_request(&oids, &config.community, request_id)?
445                    }
446                    SnmpVersion::V2c | SnmpVersion::V3 => {
447                        Self::build_v2c_get_request(&oids, &config.community, request_id)?
448                    }
449                };
450                Ok((bytes, "GetRequest"))
451            }
452            "snmp_getnext" => {
453                let bytes = match config.version {
454                    SnmpVersion::V1 => {
455                        Self::build_v1_getnext_request(&oids, &config.community, request_id)?
456                    }
457                    SnmpVersion::V2c | SnmpVersion::V3 => {
458                        Self::build_v2c_getnext_request(&oids, &config.community, request_id)?
459                    }
460                };
461                Ok((bytes, "GetNextRequest"))
462            }
463            "snmp_getbulk" => {
464                if matches!(config.version, SnmpVersion::V1) {
465                    return Err(anyhow::anyhow!("GETBULK needs SNMPv2c or SNMPv3"));
466                }
467                let non_repeaters =
468                    crate::client::wire_values::number::<u32>(&data, "non_repeaters", 0)?;
469                let max_repetitions =
470                    crate::client::wire_values::number::<u32>(&data, "max_repetitions", 10)?;
471                let bytes = Self::build_v2c_getbulk_request(
472                    &oids,
473                    &config.community,
474                    request_id,
475                    non_repeaters,
476                    max_repetitions,
477                )?;
478                Ok((bytes, "GetBulkRequest"))
479            }
480            "snmp_set" => {
481                let variables = data
482                    .get("variables")
483                    .and_then(|v| v.as_array())
484                    .context("Missing 'variables' array")?;
485                let bytes = match config.version {
486                    SnmpVersion::V1 => {
487                        Self::build_v1_set_request(variables, &config.community, request_id)?
488                    }
489                    SnmpVersion::V2c | SnmpVersion::V3 => {
490                        Self::build_v2c_set_request(variables, &config.community, request_id)?
491                    }
492                };
493                Ok((bytes, "SetRequest"))
494            }
495            other => Err(anyhow::anyhow!(
496                "custom result '{other}' is not an SNMP wire verb"
497            )),
498        }
499    }
500
501    /// Send request and handle response
502    async fn send_request_and_handle_response(
503        socket: &Arc<UdpSocket>,
504        request_bytes: &[u8],
505        request_type: &str,
506        config: &Arc<SnmpConfig>,
507        client_id: ClientId,
508        llm_client: &OllamaClient,
509        app_state: &Arc<AppState>,
510        status_tx: &mpsc::UnboundedSender<String>,
511        protocol: &Arc<SnmpClientProtocol>,
512    ) -> Result<()> {
513        socket.send(request_bytes).await?;
514        Self::await_response(
515            socket,
516            request_bytes,
517            request_type,
518            config,
519            client_id,
520            llm_client,
521            app_state,
522            status_tx,
523            protocol,
524        )
525        .await
526    }
527
528    /// Wait for the reply to a request already on the wire, resending it on timeout
529    /// until `config.retries` is exhausted.
530    ///
531    /// Split out of [`Self::send_request_and_handle_response`] so the command channel
532    /// can report `Sent { bytes_sent }` the moment the datagram leaves - an injected
533    /// caller must not be made to wait out `timeout_ms * (retries + 1)` before it
534    /// learns whether its request was sent.
535    #[allow(clippy::too_many_arguments)]
536    async fn await_response(
537        socket: &Arc<UdpSocket>,
538        request_bytes: &[u8],
539        request_type: &str,
540        config: &Arc<SnmpConfig>,
541        client_id: ClientId,
542        llm_client: &OllamaClient,
543        app_state: &Arc<AppState>,
544        status_tx: &mpsc::UnboundedSender<String>,
545        protocol: &Arc<SnmpClientProtocol>,
546    ) -> Result<()> {
547        let timeout_duration = Duration::from_millis(config.timeout_ms);
548        let mut retries = config.retries;
549
550        loop {
551            // Wait for response with timeout
552            let mut buffer = vec![0u8; 65535];
553            match timeout(timeout_duration, socket.recv(&mut buffer)).await {
554                Ok(Ok(n)) => {
555                    let response_data = &buffer[..n];
556                    trace!("SNMP response (hex): {}", hex::encode(response_data));
557
558                    // Parse response and call LLM
559                    Self::handle_response(
560                        response_data,
561                        request_type,
562                        client_id,
563                        llm_client,
564                        app_state,
565                        status_tx,
566                        protocol,
567                        socket,
568                        config,
569                    )
570                    .await?;
571
572                    return Ok(());
573                }
574                Ok(Err(e)) => {
575                    return Err(e.into());
576                }
577                Err(_) => {
578                    // Timeout
579                    if retries > 0 {
580                        retries -= 1;
581                        debug!(
582                            "SNMP client {} request timeout, retrying ({} left)",
583                            client_id, retries
584                        );
585                        socket.send(request_bytes).await?;
586                        continue;
587                    } else {
588                        return Err(anyhow::anyhow!(
589                            "SNMP request timeout after {} retries",
590                            config.retries
591                        ));
592                    }
593                }
594            }
595        }
596    }
597
598    /// Drain injected commands until the channel closes (the client was removed) or an
599    /// injected `disconnect` ends the session.
600    ///
601    /// Bespoke rather than `command_support::handle_stream_client_command` because
602    /// every SNMP verb yields `ClientActionResult::Custom` and the socket is a
603    /// connected datagram socket, not a write half. The logging and reply are
604    /// byte-for-byte what the generic helper does.
605    ///
606    /// The outcome is reported as soon as the request datagram is on the wire; the
607    /// reply is then awaited in this same task, so the response event still reaches
608    /// the LLM exactly as it does on the LLM path, and a second command simply queues
609    /// behind the first (the channel is bounded, so a caller gets "client busy" rather
610    /// than an unbounded backlog).
611    #[allow(clippy::too_many_arguments)]
612    async fn command_loop(
613        mut command_rx: mpsc::Receiver<ClientCommand>,
614        protocol: Arc<SnmpClientProtocol>,
615        socket: Arc<UdpSocket>,
616        config: Arc<SnmpConfig>,
617        client_id: ClientId,
618        llm_client: OllamaClient,
619        app_state: Arc<AppState>,
620        status_tx: mpsc::UnboundedSender<String>,
621    ) {
622        use crate::llm::actions::client_trait::ClientActionResult;
623        use crate::llm::actions::protocol_trait::Protocol;
624
625        while let Some(command) = command_rx.recv().await {
626            let action = command.action.clone();
627
628            // Built here so the request bytes can be awaited on after the reply.
629            let mut pending: Option<(Vec<u8>, &'static str)> = None;
630
631            let outcome = match protocol.execute_action(action.clone()) {
632                Err(e) => Ok(ClientSendOutcome::Rejected {
633                    error: e.to_string(),
634                }),
635                Ok(ClientActionResult::Custom { name, data }) => {
636                    match Self::build_request(&name, &data, &config) {
637                        Err(e) => Ok(ClientSendOutcome::Rejected {
638                            error: e.to_string(),
639                        }),
640                        Ok((request_bytes, request_type)) => {
641                            match socket.send(&request_bytes).await {
642                                Ok(bytes_sent) => {
643                                    pending = Some((request_bytes, request_type));
644                                    Ok(ClientSendOutcome::Sent { bytes_sent })
645                                }
646                                Err(e) => Err(anyhow::anyhow!("send failed: {e}")),
647                            }
648                        }
649                    }
650                }
651                Ok(ClientActionResult::Disconnect) => Ok(ClientSendOutcome::Disconnected),
652                Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
653                    detail: "wait_for_more".to_string(),
654                }),
655                Ok(other) => Ok(ClientSendOutcome::Executed {
656                    detail: format!("no wire effect: {other:?}"),
657                }),
658            };
659
660            let outcome_json = match &outcome {
661                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
662                Err(e) => serde_json::json!({"error": e.to_string()}),
663            };
664            app_state
665                .record_access_log(
666                    AccessLogOwner::Client(client_id.as_u32()),
667                    protocol.protocol_name(),
668                    None,
669                    "injected_action",
670                    action,
671                    vec![outcome_json],
672                )
673                .await;
674
675            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
676            if let Err(e) = &outcome {
677                error!("SNMP client {} injected action failed: {}", client_id, e);
678                let _ = status_tx.send(format!(
679                    "[WARN] Client {} injected action failed: {}",
680                    client_id, e
681                ));
682            }
683            let _ = status_tx.send("__UPDATE_UI__".to_string());
684            crate::client::command_support::reply(command, outcome);
685
686            if disconnect {
687                app_state
688                    .update_client_status(client_id, ClientStatus::Disconnected)
689                    .await;
690                let _ = status_tx.send(format!(
691                    "[CLIENT] SNMP client {} disconnected (injected action)",
692                    client_id
693                ));
694                let _ = status_tx.send("__UPDATE_UI__".to_string());
695                break;
696            }
697
698            if let Some((request_bytes, request_type)) = pending {
699                if let Err(e) = Self::await_response(
700                    &socket,
701                    &request_bytes,
702                    request_type,
703                    &config,
704                    client_id,
705                    &llm_client,
706                    &app_state,
707                    &status_tx,
708                    &protocol,
709                )
710                .await
711                {
712                    debug!(
713                        "SNMP client {} got no usable reply to injected {}: {}",
714                        client_id, request_type, e
715                    );
716                }
717            }
718        }
719
720        // Every exit path lands here: drop the command handle so the dashboard stops
721        // offering [ send ] on a dead client (a late send then fails fast).
722        app_state.remove_client_handle(client_id).await;
723        let _ = status_tx.send("__UPDATE_UI__".to_string());
724    }
725
726    /// Handle SNMP response
727    async fn handle_response(
728        response_data: &[u8],
729        request_type: &str,
730        client_id: ClientId,
731        llm_client: &OllamaClient,
732        app_state: &Arc<AppState>,
733        status_tx: &mpsc::UnboundedSender<String>,
734        protocol: &Arc<SnmpClientProtocol>,
735        socket: &Arc<UdpSocket>,
736        config: &Arc<SnmpConfig>,
737    ) -> Result<()> {
738        // The response side has the same fatal decoder as the agent side: `rasn`'s BER parser
739        // recurses once per level of constructed nesting with no bound, and a stack overflow
740        // is a `SIGABRT` that takes the whole NetGet process with it. A client is if anything
741        // more exposed — the bytes come from whatever the operator pointed it at, and UDP means
742        // an off-path attacker can beat the real agent to the reply. Screen before decoding.
743        if let Err(reason) = crate::server::snmp::check_ber_structure(
744            response_data,
745            crate::server::snmp::MAX_BER_DEPTH,
746        ) {
747            return Err(anyhow::anyhow!(
748                "Rejected malformed SNMP response ({} bytes): {}",
749                response_data.len(),
750                reason
751            ));
752        }
753
754        // SNMPv3: nothing in the reply is believed until the USM has checked it.
755        let mut usm_report: Option<Value> = None;
756        let (variables, error_status) = if let Some(manager) = &config.usm {
757            match manager.unwrap_reply(response_data)? {
758                usm::Reply::Pdu(pdus) => Self::extract_v2c_response(&v2c::Message {
759                    version: Integer::Primitive(1),
760                    community: Vec::new().into(),
761                    data: pdus,
762                })?,
763                usm::Reply::Report(kind, count) => {
764                    usm_report = Some(serde_json::json!({"report": kind.name(), "count": count}));
765                    (Vec::new(), 0)
766                }
767            }
768        } else if let Ok(msg) = ber::decode::<v2c::Message<v2::Pdus>>(response_data) {
769            Self::extract_v2c_response(&msg)?
770        } else if let Ok(msg) = ber::decode::<v1::Message<v1::Pdus>>(response_data) {
771            Self::extract_v1_response(&msg)?
772        } else {
773            return Err(anyhow::anyhow!("Failed to parse SNMP response"));
774        };
775        if let Some(report) = &usm_report {
776            info!("SNMP client {} got a USM report: {}", client_id, report);
777        }
778
779        debug!(
780            "SNMP client {} received response with {} variables, error_status={}",
781            client_id,
782            variables.len(),
783            error_status
784        );
785
786        // Call LLM with response
787        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
788            let mut data = serde_json::json!({
789                "request_type": request_type,
790                "variables": variables,
791                "error_status": error_status,
792            });
793            if let Some(report) = usm_report {
794                data["usm_report"] = report;
795            }
796            let event = Event::new(&SNMP_CLIENT_RESPONSE_RECEIVED_EVENT, data);
797
798            let memory = app_state
799                .get_memory_for_client(client_id)
800                .await
801                .unwrap_or_default();
802
803            match call_llm_for_client(
804                llm_client,
805                app_state,
806                client_id.to_string(),
807                &instruction,
808                &memory,
809                Some(&event),
810                protocol.as_ref(),
811                status_tx,
812            )
813            .await
814            {
815                Ok(ClientLlmResult {
816                    actions,
817                    memory_updates,
818                }) => {
819                    // Update memory
820                    if let Some(mem) = memory_updates {
821                        app_state.set_memory_for_client(client_id, mem).await;
822                    }
823
824                    // Execute follow-up actions (boxed to avoid infinite recursion)
825                    Box::pin(Self::execute_actions(
826                        actions, protocol, socket, client_id, config, llm_client, app_state,
827                        status_tx,
828                    ))
829                    .await;
830                }
831                Err(e) => {
832                    error!("LLM error for SNMP client {}: {}", client_id, e);
833                }
834            }
835        }
836
837        Ok(())
838    }
839
840    /// Extract variables from v2c response
841    fn extract_v2c_response(msg: &v2c::Message<v2::Pdus>) -> Result<(Vec<Value>, i32)> {
842        let (var_binds, error_status) = match &msg.data {
843            v2::Pdus::Response(resp) => (&resp.0.variable_bindings, resp.0.error_status as i32),
844            _ => return Err(anyhow::anyhow!("Unexpected PDU type in response")),
845        };
846
847        let variables: Vec<Value> = var_binds
848            .iter()
849            .map(|vb| {
850                serde_json::json!({
851                    "oid": vb.name.to_string(),
852                    "value": Self::format_v2_value(&vb.value),
853                })
854            })
855            .collect();
856
857        Ok((variables, error_status))
858    }
859
860    /// Extract variables from v1 response
861    fn extract_v1_response(msg: &v1::Message<v1::Pdus>) -> Result<(Vec<Value>, i32)> {
862        let (var_binds, error_status) = match &msg.data {
863            v1::Pdus::GetResponse(resp) => {
864                let err_status = match &resp.0.error_status {
865                    Integer::Primitive(v) => *v as i32,
866                    Integer::Variable(big) => big.to_string().parse().unwrap_or(0),
867                };
868                (&resp.0.variable_bindings, err_status)
869            }
870            _ => return Err(anyhow::anyhow!("Unexpected PDU type in response")),
871        };
872
873        let variables: Vec<Value> = var_binds
874            .iter()
875            .map(|vb| {
876                serde_json::json!({
877                    "oid": vb.name.to_string(),
878                    "value": Self::format_v1_value(&vb.value),
879                })
880            })
881            .collect();
882
883        Ok((variables, error_status))
884    }
885
886    /// Format v2 value for JSON
887    fn format_v2_value(value: &v2::VarBindValue) -> Value {
888        use v2::VarBindValue;
889        match value {
890            VarBindValue::Unspecified => serde_json::json!(null),
891            VarBindValue::NoSuchObject => serde_json::json!("(no such object)"),
892            VarBindValue::NoSuchInstance => serde_json::json!("(no such instance)"),
893            VarBindValue::EndOfMibView => serde_json::json!("(end of MIB view)"),
894            VarBindValue::Value(obj_syntax) => Self::format_object_syntax_v2(obj_syntax),
895        }
896    }
897
898    fn format_object_syntax_v2(syntax: &V2ObjectSyntax) -> Value {
899        match syntax {
900            V2ObjectSyntax::Simple(V2SimpleSyntax::Integer(n)) => match n {
901                Integer::Primitive(val) => serde_json::json!(val),
902                Integer::Variable(val) => serde_json::json!(val.to_string()),
903            },
904            V2ObjectSyntax::Simple(V2SimpleSyntax::String(s)) => {
905                serde_json::json!(String::from_utf8_lossy(s))
906            }
907            V2ObjectSyntax::Simple(V2SimpleSyntax::ObjectId(_)) => serde_json::json!("(object-id)"),
908            V2ObjectSyntax::ApplicationWide(_) => serde_json::json!("(application-wide)"),
909        }
910    }
911
912    /// Format v1 value for JSON
913    fn format_v1_value(value: &V1ObjectSyntax) -> Value {
914        match value {
915            V1ObjectSyntax::Simple(V1SimpleSyntax::Number(n)) => match n {
916                Integer::Primitive(val) => serde_json::json!(val),
917                Integer::Variable(val) => serde_json::json!(val.to_string()),
918            },
919            V1ObjectSyntax::Simple(V1SimpleSyntax::String(s)) => {
920                serde_json::json!(String::from_utf8_lossy(s))
921            }
922            V1ObjectSyntax::Simple(V1SimpleSyntax::Object(_)) => serde_json::json!("(object-id)"),
923            V1ObjectSyntax::Simple(V1SimpleSyntax::Empty) => serde_json::json!(null),
924            V1ObjectSyntax::ApplicationWide(_) => serde_json::json!("(application-wide)"),
925        }
926    }
927
928    // Request builders (simplified - using rasn-snmp encoding)
929
930    fn build_v2c_get_request(oids: &[String], community: &str, request_id: i32) -> Result<Vec<u8>> {
931        let var_binds: Vec<v2::VarBind> = oids
932            .iter()
933            .map(|oid| v2::VarBind {
934                name: parse_oid(oid),
935                value: v2::VarBindValue::Unspecified,
936            })
937            .collect();
938
939        let pdu = v2::Pdus::GetRequest(v2::GetRequest(v2::Pdu {
940            request_id,
941            error_status: 0,
942            error_index: 0,
943            variable_bindings: var_binds,
944        }));
945
946        let message = v2c::Message {
947            version: Integer::Primitive(1), // v2c uses version 1
948            community: community.as_bytes().to_vec().into(),
949            data: pdu,
950        };
951
952        ber::encode(&message)
953            .map_err(|e| anyhow::anyhow!("Failed to encode SNMP v2c GET request: {}", e))
954    }
955
956    fn build_v2c_getnext_request(
957        oids: &[String],
958        community: &str,
959        request_id: i32,
960    ) -> Result<Vec<u8>> {
961        let var_binds: Vec<v2::VarBind> = oids
962            .iter()
963            .map(|oid| v2::VarBind {
964                name: parse_oid(oid),
965                value: v2::VarBindValue::Unspecified,
966            })
967            .collect();
968
969        let pdu = v2::Pdus::GetNextRequest(v2::GetNextRequest(v2::Pdu {
970            request_id,
971            error_status: 0,
972            error_index: 0,
973            variable_bindings: var_binds,
974        }));
975
976        let message = v2c::Message {
977            version: Integer::Primitive(1),
978            community: community.as_bytes().to_vec().into(),
979            data: pdu,
980        };
981
982        ber::encode(&message)
983            .map_err(|e| anyhow::anyhow!("Failed to encode SNMP v2c GETNEXT request: {}", e))
984    }
985
986    fn build_v2c_getbulk_request(
987        oids: &[String],
988        community: &str,
989        request_id: i32,
990        non_repeaters: u32,
991        max_repetitions: u32,
992    ) -> Result<Vec<u8>> {
993        let var_binds: Vec<v2::VarBind> = oids
994            .iter()
995            .map(|oid| v2::VarBind {
996                name: parse_oid(oid),
997                value: v2::VarBindValue::Unspecified,
998            })
999            .collect();
1000
1001        let pdu = v2::Pdus::GetBulkRequest(v2::GetBulkRequest(v2::BulkPdu {
1002            request_id,
1003            non_repeaters,
1004            max_repetitions,
1005            variable_bindings: var_binds,
1006        }));
1007
1008        let message = v2c::Message {
1009            version: Integer::Primitive(1),
1010            community: community.as_bytes().to_vec().into(),
1011            data: pdu,
1012        };
1013
1014        ber::encode(&message)
1015            .map_err(|e| anyhow::anyhow!("Failed to encode SNMP v2c GETBULK request: {}", e))
1016    }
1017
1018    fn build_v2c_set_request(
1019        variables: &[Value],
1020        community: &str,
1021        request_id: i32,
1022    ) -> Result<Vec<u8>> {
1023        let var_binds: Vec<v2::VarBind> = variables
1024            .iter()
1025            .map(|var| {
1026                let oid = var
1027                    .get("oid")
1028                    .and_then(|v| v.as_str())
1029                    .unwrap_or("1.3.6.1.2.1.1.1.0");
1030                let value_type = var.get("type").and_then(|v| v.as_str()).unwrap_or("string");
1031                let value = var.get("value").unwrap_or(&serde_json::json!(null));
1032
1033                let data = match value_type {
1034                    "integer" => {
1035                        let n = value.as_i64().unwrap_or(0);
1036                        v2::VarBindValue::Value(V2ObjectSyntax::Simple(V2SimpleSyntax::Integer(
1037                            Integer::Primitive(n as isize),
1038                        )))
1039                    }
1040                    "string" => {
1041                        let s = value.as_str().unwrap_or("");
1042                        v2::VarBindValue::Value(V2ObjectSyntax::Simple(V2SimpleSyntax::String(
1043                            s.as_bytes().to_vec().into(),
1044                        )))
1045                    }
1046                    _ => v2::VarBindValue::Unspecified,
1047                };
1048
1049                v2::VarBind {
1050                    name: parse_oid(oid),
1051                    value: data,
1052                }
1053            })
1054            .collect();
1055
1056        let pdu = v2::Pdus::SetRequest(v2::SetRequest(v2::Pdu {
1057            request_id,
1058            error_status: 0,
1059            error_index: 0,
1060            variable_bindings: var_binds,
1061        }));
1062
1063        let message = v2c::Message {
1064            version: Integer::Primitive(1),
1065            community: community.as_bytes().to_vec().into(),
1066            data: pdu,
1067        };
1068
1069        ber::encode(&message)
1070            .map_err(|e| anyhow::anyhow!("Failed to encode SNMP v2c SET request: {}", e))
1071    }
1072
1073    fn build_v1_get_request(oids: &[String], community: &str, request_id: i32) -> Result<Vec<u8>> {
1074        let var_binds: Vec<v1::VarBind> = oids
1075            .iter()
1076            .map(|oid| v1::VarBind {
1077                name: parse_oid(oid),
1078                value: V1ObjectSyntax::Simple(V1SimpleSyntax::Empty),
1079            })
1080            .collect();
1081
1082        let pdu = v1::Pdus::GetRequest(v1::GetRequest(v1::Pdu {
1083            request_id: Integer::Primitive(request_id as isize),
1084            error_status: Integer::Primitive(0),
1085            error_index: Integer::Primitive(0),
1086            variable_bindings: var_binds,
1087        }));
1088
1089        let message = v1::Message {
1090            version: Integer::Primitive(0), // v1 uses version 0
1091            community: community.as_bytes().to_vec().into(),
1092            data: pdu,
1093        };
1094
1095        ber::encode(&message)
1096            .map_err(|e| anyhow::anyhow!("Failed to encode SNMP v1 GET request: {}", e))
1097    }
1098
1099    fn build_v1_getnext_request(
1100        oids: &[String],
1101        community: &str,
1102        request_id: i32,
1103    ) -> Result<Vec<u8>> {
1104        let var_binds: Vec<v1::VarBind> = oids
1105            .iter()
1106            .map(|oid| v1::VarBind {
1107                name: parse_oid(oid),
1108                value: V1ObjectSyntax::Simple(V1SimpleSyntax::Empty),
1109            })
1110            .collect();
1111
1112        let pdu = v1::Pdus::GetNextRequest(v1::GetNextRequest(v1::Pdu {
1113            request_id: Integer::Primitive(request_id as isize),
1114            error_status: Integer::Primitive(0),
1115            error_index: Integer::Primitive(0),
1116            variable_bindings: var_binds,
1117        }));
1118
1119        let message = v1::Message {
1120            version: Integer::Primitive(0),
1121            community: community.as_bytes().to_vec().into(),
1122            data: pdu,
1123        };
1124
1125        ber::encode(&message)
1126            .map_err(|e| anyhow::anyhow!("Failed to encode SNMP v1 GETNEXT request: {}", e))
1127    }
1128
1129    fn build_v1_set_request(
1130        variables: &[Value],
1131        community: &str,
1132        request_id: i32,
1133    ) -> Result<Vec<u8>> {
1134        let var_binds: Vec<v1::VarBind> = variables
1135            .iter()
1136            .map(|var| {
1137                let oid = var
1138                    .get("oid")
1139                    .and_then(|v| v.as_str())
1140                    .unwrap_or("1.3.6.1.2.1.1.1.0");
1141                let value_type = var.get("type").and_then(|v| v.as_str()).unwrap_or("string");
1142                let value = var.get("value").unwrap_or(&serde_json::json!(null));
1143
1144                let value_obj = match value_type {
1145                    "integer" => {
1146                        let n = value.as_i64().unwrap_or(0);
1147                        V1ObjectSyntax::Simple(V1SimpleSyntax::Number(Integer::Primitive(
1148                            n as isize,
1149                        )))
1150                    }
1151                    "string" => {
1152                        let s = value.as_str().unwrap_or("");
1153                        V1ObjectSyntax::Simple(V1SimpleSyntax::String(s.as_bytes().to_vec().into()))
1154                    }
1155                    _ => V1ObjectSyntax::Simple(V1SimpleSyntax::Empty),
1156                };
1157
1158                v1::VarBind {
1159                    name: parse_oid(oid),
1160                    value: value_obj,
1161                }
1162            })
1163            .collect();
1164
1165        let pdu = v1::Pdus::SetRequest(v1::SetRequest(v1::Pdu {
1166            request_id: Integer::Primitive(request_id as isize),
1167            error_status: Integer::Primitive(0),
1168            error_index: Integer::Primitive(0),
1169            variable_bindings: var_binds,
1170        }));
1171
1172        let message = v1::Message {
1173            version: Integer::Primitive(0),
1174            community: community.as_bytes().to_vec().into(),
1175            data: pdu,
1176        };
1177
1178        ber::encode(&message)
1179            .map_err(|e| anyhow::anyhow!("Failed to encode SNMP v1 SET request: {}", e))
1180    }
1181}