1use 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
14pub 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
32pub 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
72pub struct SnmpClientProtocol;
74
75impl SnmpClientProtocol {
76 pub fn new() -> Self {
77 Self
78 }
79}
80
81impl 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 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 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 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
379impl 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}