Skip to main content

netget/client/snmp/
actions.rs

1//! SNMP client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// SNMP client connected event
15pub static SNMP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "snmp_connected",
18        "SNMP client successfully connected to agent",
19        json!({
20            "type": "send_snmp_get",
21            "oids": ["1.3.6.1.2.1.1.3.0"]
22        }),
23    )
24    .with_parameters(vec![Parameter {
25        name: "remote_addr".to_string(),
26        type_hint: "string".to_string(),
27        description: "SNMP agent address".to_string(),
28        required: true,
29    }])
30});
31
32/// SNMP client response received event
33pub static SNMP_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
34    EventType::new(
35        "snmp_response_received",
36        "Response received from SNMP agent",
37        json!({
38            "type": "send_snmp_getnext",
39            "oids": ["1.3.6.1.2.1.1.1.0"]
40        }),
41    )
42    .with_parameters(vec![
43        Parameter {
44            name: "request_type".to_string(),
45            type_hint: "string".to_string(),
46            description:
47                "Type of SNMP request (GetRequest, GetNextRequest, GetBulkRequest, SetRequest)"
48                    .to_string(),
49            required: true,
50        },
51        Parameter {
52            name: "variables".to_string(),
53            type_hint: "array".to_string(),
54            description: "Array of OID/value pairs in response".to_string(),
55            required: true,
56        },
57        Parameter {
58            name: "error_status".to_string(),
59            type_hint: "integer".to_string(),
60            description: "SNMP error status (0=noError, 2=noSuchName, etc.)".to_string(),
61            required: true,
62        },
63        Parameter {
64            name: "usm_report".to_string(),
65            type_hint: "object".to_string(),
66            description: "SNMPv3 only, present when the agent answered with a USM report instead of a response: {report, count}, report being usmStatsWrongDigests (wrong auth password), usmStatsUnknownUserNames, usmStatsUnsupportedSecLevels, usmStatsDecryptionErrors (wrong privacy password) or usmStatsNotInTimeWindows (the clock was resynchronised from it; resend). variables is then empty".to_string(),
67            required: false,
68        },
69    ])
70});
71
72/// SNMP client protocol action handler
73pub struct SnmpClientProtocol;
74
75impl SnmpClientProtocol {
76    pub fn new() -> Self {
77        Self
78    }
79}
80
81// Implement Protocol trait (common functionality)
82impl Protocol for SnmpClientProtocol {
83    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
84        vec![
85            ActionDefinition {
86                name: "send_snmp_get".to_string(),
87                description: "Send SNMP GET request for one or more OIDs".to_string(),
88                parameters: vec![Parameter {
89                    name: "oids".to_string(),
90                    type_hint: "array".to_string(),
91                    description: "Array of OID strings (e.g., [\"1.3.6.1.2.1.1.1.0\"])".to_string(),
92                    required: true,
93                }],
94                example: json!({
95                    "type": "send_snmp_get",
96                    "oids": ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.5.0"]
97                }),
98                log_template: None,
99            },
100            ActionDefinition {
101                name: "send_snmp_getnext".to_string(),
102                description: "Send SNMP GETNEXT request to walk OID tree".to_string(),
103                parameters: vec![Parameter {
104                    name: "oids".to_string(),
105                    type_hint: "array".to_string(),
106                    description: "Array of starting OID strings".to_string(),
107                    required: true,
108                }],
109                example: json!({
110                    "type": "send_snmp_getnext",
111                    "oids": ["1.3.6.1.2.1.1"]
112                }),
113                log_template: None,
114            },
115            ActionDefinition {
116                name: "send_snmp_getbulk".to_string(),
117                description:
118                    "Send SNMP GETBULK request for efficient bulk retrieval (SNMPv2c only)"
119                        .to_string(),
120                parameters: vec![
121                    Parameter {
122                        name: "oids".to_string(),
123                        type_hint: "array".to_string(),
124                        description: "Array of starting OID strings".to_string(),
125                        required: true,
126                    },
127                    Parameter {
128                        name: "non_repeaters".to_string(),
129                        type_hint: "integer".to_string(),
130                        description: "Number of non-repeating variables (default: 0)".to_string(),
131                        required: false,
132                    },
133                    Parameter {
134                        name: "max_repetitions".to_string(),
135                        type_hint: "integer".to_string(),
136                        description:
137                            "Maximum repetitions for each repeating variable (default: 10)"
138                                .to_string(),
139                        required: false,
140                    },
141                ],
142                example: json!({
143                    "type": "send_snmp_getbulk",
144                    "oids": ["1.3.6.1.2.1.2.2.1"],
145                    "non_repeaters": 0,
146                    "max_repetitions": 10
147                }),
148                log_template: None,
149            },
150            ActionDefinition {
151                name: "send_snmp_set".to_string(),
152                description: "Send SNMP SET request to modify agent values".to_string(),
153                parameters: vec![Parameter {
154                    name: "variables".to_string(),
155                    type_hint: "array".to_string(),
156                    description: "Array of {oid, type, value} objects".to_string(),
157                    required: true,
158                }],
159                example: json!({
160                    "type": "send_snmp_set",
161                    "variables": [
162                        {"oid": "1.3.6.1.2.1.1.5.0", "type": "string", "value": "new-hostname"}
163                    ]
164                }),
165                log_template: None,
166            },
167            ActionDefinition {
168                name: "disconnect".to_string(),
169                description: "Disconnect from the SNMP agent".to_string(),
170                parameters: vec![],
171                example: json!({
172                    "type": "disconnect"
173                }),
174                log_template: None,
175            },
176        ]
177    }
178    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
179        vec![
180            ActionDefinition {
181                name: "send_snmp_get".to_string(),
182                description: "Send follow-up SNMP GET request based on response".to_string(),
183                parameters: vec![Parameter {
184                    name: "oids".to_string(),
185                    type_hint: "array".to_string(),
186                    description: "Array of OID strings".to_string(),
187                    required: true,
188                }],
189                example: json!({
190                    "type": "send_snmp_get",
191                    "oids": ["1.3.6.1.2.1.1.3.0"]
192                }),
193                log_template: None,
194            },
195            ActionDefinition {
196                name: "send_snmp_getnext".to_string(),
197                description: "Continue walking OID tree".to_string(),
198                parameters: vec![Parameter {
199                    name: "oids".to_string(),
200                    type_hint: "array".to_string(),
201                    description: "Array of OID strings from previous response".to_string(),
202                    required: true,
203                }],
204                example: json!({
205                    "type": "send_snmp_getnext",
206                    "oids": ["1.3.6.1.2.1.1.1.0"]
207                }),
208                log_template: None,
209            },
210            ActionDefinition {
211                name: "wait_for_more".to_string(),
212                description: "Wait for more responses (no action)".to_string(),
213                parameters: vec![],
214                example: json!({
215                    "type": "wait_for_more"
216                }),
217                log_template: None,
218            },
219        ]
220    }
221    fn protocol_name(&self) -> &'static str {
222        "SNMP"
223    }
224    fn get_event_types(&self) -> Vec<EventType> {
225        vec![
226            EventType::new(
227                "snmp_connected",
228                "Triggered when SNMP client connects to agent",
229                json!({"type": "placeholder", "event_id": "snmp_connected"}),
230            ),
231            EventType::new(
232                "snmp_response_received",
233                "Triggered when SNMP client receives a response",
234                json!({"type": "placeholder", "event_id": "snmp_response_received"}),
235            ),
236        ]
237    }
238    fn stack_name(&self) -> &'static str {
239        "ETH>IP>UDP>SNMP"
240    }
241    fn keywords(&self) -> Vec<&'static str> {
242        vec![
243            "snmp",
244            "snmp client",
245            "connect to snmp",
246            "network management",
247        ]
248    }
249    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
250        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
251
252        ProtocolMetadataV2::builder()
253            .state(DevelopmentState::Experimental)
254            .implementation("UDP-based with rasn-snmp for BER encoding/decoding; SNMPv3 USM (src/server/snmp/usm.rs, shared with the agent): engine discovery at start, HMAC-MD5/SHA-1/SHA-224/256/384/512 authentication, DES-CBC and AES-128-CFB privacy, every reply's message id, engine, level and MAC checked before it is read")
255            .llm_control("Full control over SNMP operations (GET, GETNEXT, GETBULK, SET); SNMPv3 reports reach the handler as usm_report")
256            .e2e_testing("tests/client/snmp/real_server_test.rs: Net-SNMP's snmpd, unprivileged in a temp dir, with a SHA-256/AES and an MD5/DES user. NetGet discovers its engine, GETs, and a handler SETs sysContact from the reply; the value is read back with Net-SNMP's snmpget as the other user. A wrong password is snmpd's own wrongDigests report. Fails, not skips, without snmpd")
257            .notes("v1/v2c with a community; v3 with one USM user per client. No AES-192/256 (expired drafts with two incompatible key extensions), no VACM on this side, no v3 informs or traps")
258            .build()
259    }
260    fn description(&self) -> &'static str {
261        "SNMP client for network device monitoring and management"
262    }
263    fn example_prompt(&self) -> &'static str {
264        "Connect to SNMP agent at localhost:161 and query system description (OID 1.3.6.1.2.1.1.1.0)"
265    }
266    fn group_name(&self) -> &'static str {
267        "Network Management"
268    }
269    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
270        vec![
271            ParameterDefinition {
272                name: "community".to_string(),
273                description: "SNMP community string (default: 'public')".to_string(),
274                type_hint: "string".to_string(),
275                required: false,
276                example: json!("public"),
277                default: None,
278            },
279            ParameterDefinition {
280                name: "version".to_string(),
281                description: "SNMP version: 'v1', 'v2c' or 'v3' (default: 'v2c'). v3 needs v3_user; the agent's engine is discovered when the client starts, and an agent that never answers discovery fails the start".to_string(),
282                type_hint: "string".to_string(),
283                required: false,
284                example: json!("v2c"),
285                default: None,
286            },
287            ParameterDefinition {
288                name: "v3_user".to_string(),
289                description: "SNMPv3 USM user: {name, auth_protocol: none|md5|sha|sha224|sha256|sha384|sha512, auth_password, priv_protocol: none|des|aes, priv_password}. Passwords are 8 to 256 bytes and never appear in events or logs. The level follows from the protocols: no auth is noAuthNoPriv, auth alone authNoPriv, both authPriv".to_string(),
290                type_hint: "object".to_string(),
291                required: false,
292                example: json!({"name": "monitor", "auth_protocol": "sha256", "auth_password": "monitor-auth-pass", "priv_protocol": "aes", "priv_password": "monitor-priv-pass"}),
293                default: None,
294            },
295            ParameterDefinition {
296                name: "context_name".to_string(),
297                description: "SNMPv3 contextName for every request, at most 32 bytes (default: the empty default context)".to_string(),
298                type_hint: "string".to_string(),
299                required: false,
300                example: json!("vlan-7"),
301                default: None,
302            },
303            ParameterDefinition {
304                name: "timeout_ms".to_string(),
305                description: "Request timeout in milliseconds (default: 5000)".to_string(),
306                type_hint: "integer".to_string(),
307                required: false,
308                example: json!(5000),
309                default: None,
310            },
311            ParameterDefinition {
312                name: "retries".to_string(),
313                description: "Number of retries on timeout (default: 3)".to_string(),
314                type_hint: "integer".to_string(),
315                required: false,
316                example: json!(3),
317                default: None,
318            },
319        ]
320    }
321
322    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
323        use crate::llm::actions::StartupExamples;
324        use serde_json::json;
325
326        StartupExamples::new(
327            // LLM mode: LLM controls SNMP queries
328            json!({
329                "type": "open_client",
330                "remote_addr": "192.168.1.1:161",
331                "base_stack": "snmp",
332                "instruction": "Query the system description (1.3.6.1.2.1.1.1.0) and system uptime (1.3.6.1.2.1.1.3.0)"
333            }),
334            // Script mode: Code-based deterministic responses
335            json!({
336                "type": "open_client",
337                "remote_addr": "192.168.1.1:161",
338                "base_stack": "snmp",
339                "event_handlers": [{
340                    "event_pattern": "snmp_response_received",
341                    "handler": {
342                        "type": "script",
343                        "language": "python",
344                        "code": "<snmp_client_handler>"
345                    }
346                }]
347            }),
348            // Static mode: Fixed SNMP GET on connect
349            json!({
350                "type": "open_client",
351                "remote_addr": "192.168.1.1:161",
352                "base_stack": "snmp",
353                "event_handlers": [
354                    {
355                        "event_pattern": "snmp_connected",
356                        "handler": {
357                            "type": "static",
358                            "actions": [{
359                                "type": "send_snmp_get",
360                                "oids": ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.5.0"]
361                            }]
362                        }
363                    },
364                    {
365                        "event_pattern": "snmp_response_received",
366                        "handler": {
367                            "type": "static",
368                            "actions": [{
369                                "type": "disconnect"
370                            }]
371                        }
372                    }
373                ]
374            }),
375        )
376    }
377}
378
379// Implement Client trait (client-specific functionality)
380impl Client for SnmpClientProtocol {
381    fn connect(
382        &self,
383        ctx: crate::protocol::ConnectContext,
384    ) -> std::pin::Pin<
385        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
386    > {
387        Box::pin(async move {
388            use crate::client::snmp::SnmpClient;
389            SnmpClient::connect_with_llm_actions(
390                ctx.remote_addr,
391                ctx.llm_client,
392                ctx.state,
393                ctx.status_tx,
394                ctx.client_id,
395                ctx.startup_params,
396            )
397            .await
398        })
399    }
400    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
401        let action_type = action
402            .get("type")
403            .and_then(|v| v.as_str())
404            .context("Missing 'type' field in action")?;
405
406        match action_type {
407            "send_snmp_get" => {
408                let oids = action
409                    .get("oids")
410                    .and_then(|v| v.as_array())
411                    .context("Missing 'oids' array")?
412                    .iter()
413                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
414                    .collect::<Vec<_>>();
415
416                if oids.is_empty() {
417                    return Err(anyhow::anyhow!("At least one OID required for GET"));
418                }
419
420                Ok(ClientActionResult::Custom {
421                    name: "snmp_get".to_string(),
422                    data: json!({
423                        "oids": oids,
424                    }),
425                })
426            }
427            "send_snmp_getnext" => {
428                let oids = action
429                    .get("oids")
430                    .and_then(|v| v.as_array())
431                    .context("Missing 'oids' array")?
432                    .iter()
433                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
434                    .collect::<Vec<_>>();
435
436                if oids.is_empty() {
437                    return Err(anyhow::anyhow!("At least one OID required for GETNEXT"));
438                }
439
440                Ok(ClientActionResult::Custom {
441                    name: "snmp_getnext".to_string(),
442                    data: json!({
443                        "oids": oids,
444                    }),
445                })
446            }
447            "send_snmp_getbulk" => {
448                let oids = action
449                    .get("oids")
450                    .and_then(|v| v.as_array())
451                    .context("Missing 'oids' array")?
452                    .iter()
453                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
454                    .collect::<Vec<_>>();
455
456                if oids.is_empty() {
457                    return Err(anyhow::anyhow!("At least one OID required for GETBULK"));
458                }
459
460                let non_repeaters = action
461                    .get("non_repeaters")
462                    .and_then(|v| v.as_i64())
463                    .unwrap_or(0) as i32;
464
465                let max_repetitions = action
466                    .get("max_repetitions")
467                    .and_then(|v| v.as_i64())
468                    .unwrap_or(10) as i32;
469
470                Ok(ClientActionResult::Custom {
471                    name: "snmp_getbulk".to_string(),
472                    data: json!({
473                        "oids": oids,
474                        "non_repeaters": non_repeaters,
475                        "max_repetitions": max_repetitions,
476                    }),
477                })
478            }
479            "send_snmp_set" => {
480                let variables = action
481                    .get("variables")
482                    .and_then(|v| v.as_array())
483                    .context("Missing 'variables' array")?
484                    .clone();
485
486                if variables.is_empty() {
487                    return Err(anyhow::anyhow!("At least one variable required for SET"));
488                }
489
490                Ok(ClientActionResult::Custom {
491                    name: "snmp_set".to_string(),
492                    data: json!({
493                        "variables": variables,
494                    }),
495                })
496            }
497            "disconnect" => Ok(ClientActionResult::Disconnect),
498            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
499            _ => Err(anyhow::anyhow!(
500                "Unknown SNMP client action: {}",
501                action_type
502            )),
503        }
504    }
505}