Skip to main content

netget/server/snmp/
actions.rs

1//! SNMP protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
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 protocol action handler
15pub struct SnmpProtocol;
16
17impl SnmpProtocol {
18    pub fn new() -> Self {
19        Self
20    }
21}
22
23// Implement Protocol trait (common functionality)
24impl Protocol for SnmpProtocol {
25    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
26        // SNMP has async action for sending traps
27        vec![send_trap_action()]
28    }
29    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
30        vec![
31            send_snmp_response_action(),
32            send_snmp_error_action(),
33            ignore_request_action(),
34            acknowledge_notification_action(),
35            ignore_notification_action(),
36        ]
37    }
38    fn protocol_name(&self) -> &'static str {
39        "SNMP"
40    }
41    fn get_event_types(&self) -> Vec<EventType> {
42        get_snmp_event_types()
43    }
44    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
45        use crate::llm::actions::ParameterDefinition;
46        vec![
47            ParameterDefinition {
48                name: "v3_users".into(),
49                type_hint: "array".into(),
50                description: "SNMPv3 USM users: [{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. A user is answered only at the level it is configured for (authPriv users never get an unencrypted answer); others get the USM's reports. Absent means SNMPv3 is not served and v3 messages are dropped".into(),
51                required: false,
52                example: json!([{"name": "monitor", "auth_protocol": "sha256", "auth_password": "monitor-auth-pass", "priv_protocol": "aes", "priv_password": "monitor-priv-pass"}]),
53                default: None,
54            },
55            ParameterDefinition {
56                name: "engine_id".into(),
57                type_hint: "string".into(),
58                description: "The agent's snmpEngineID in hexadecimal, 5 to 32 bytes, as Net-SNMP writes it. Absent means a random RFC 3411 format-5 ID chosen at start".into(),
59                required: false,
60                example: json!("8000000005a1b2c3d4e5f60718"),
61                default: None,
62            },
63            ParameterDefinition {
64                name: "engine_boots".into(),
65                type_hint: "number".into(),
66                description: "The agent's snmpEngineBoots, 1 to 2147483646. Absent means the number of wall-clock seconds since 2024-01-01, so each restart reports more boots than the last and an authenticated message captured in one run is refused in the next even with a fixed engine_id. Set it only where the wall clock cannot be trusted, and then raise it on every restart".into(),
67                required: false,
68                example: json!(2),
69                // Derived from the wall clock at start (`usm::default_engine_boots`); no single
70                // number describes it.
71                default: None,
72            },
73        ]
74    }
75    fn stack_name(&self) -> &'static str {
76        "ETH>IP>UDP>SNMP"
77    }
78    fn keywords(&self) -> Vec<&'static str> {
79        vec!["snmp", "snmp agent"]
80    }
81    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
82        use crate::protocol::metadata::{
83            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
84        };
85
86        ProtocolMetadataV2::builder()
87            .connectionless()
88            // Answers on failure: a Response PDU with error-status genErr for the request's own id.
89            .answers_on_failure()
90            .state(DevelopmentState::Beta)
91            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(161))
92            .well_known_udp_port(161)
93            .implementation("rasn-snmp v0.18 for parsing + manual BER encoding; SNMPv3 USM in usm.rs (RustCrypto hmac/md-5/sha1/sha2/des/aes)")
94            .llm_control("OID responses (sysDescr, ifTable, custom MIBs)")
95            .e2e_testing("Net-SNMP's own snmpget/snmpgetnext (tests/server/snmp/test.rs), run with -On -Oe so the assertions pin the decoded type tag and value rather than the local MIB set. Not ignored, and it hard-fails rather than skipping when the binaries are absent. Not the `snmp` Rust crate, whatever the older notes said. Notifications (tests/server/snmp/notification_test.rs): Net-SNMP's snmptrap (v1 and v2c) and snmpinform send to NetGet, and snmptrapd receives NetGet's v2c trap, v2c inform (acknowledged) and v1 trap. SNMPv3 (tests/server/snmp/usm_test.rs): Net-SNMP's snmpget/getnext/bulkget/set and pysnmp 7.1.22 at every level, with MD5, SHA-1, SHA-224/256/384/512 and DES/AES-128, plus the USM's refusals and RFC 3414's key vectors")
96            .notes("SNMPv1/v2c with a community; SNMPv3 with the USM when v3_users is set (RFC 3414 procedure and reports, HMAC-MD5/SHA-1/SHA-2 authentication, DES-CBC and AES-128-CFB privacy; a user is served only at its configured level; no VACM - the event names the user and level - no AES-192/256, no v3 notifications). request-id and community are echoed from the request automatically. Notifications both ways: send_trap sends v1/v2c traps and v2c informs (resent until acknowledged), and received traps and informs are snmp_notification events, an inform acknowledged only when the handler says so. Incoming BER is depth- and length-screened before rasn decodes it, decrypted v3 PDUs included: rasn 0.18 recurses without a bound on constructed OCTET STRINGs, and a 60 KB datagram was enough to stack-overflow the whole process")
97            .build()
98    }
99    fn description(&self) -> &'static str {
100        "SNMP agent for network monitoring"
101    }
102    fn example_prompt(&self) -> &'static str {
103        "SNMP Port 8161 serve OID 1.3.6.1.2.1.1.1.0 (sysDescr) return 'NetGet SNMP Server v0.1'"
104    }
105    fn group_name(&self) -> &'static str {
106        "Core"
107    }
108    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
109        use crate::llm::actions::StartupExamples;
110        StartupExamples::new(
111            // LLM-driven example
112            json!({
113                "type": "open_server",
114                "port": 161,
115                "base_stack": "snmp",
116                "instruction": "SNMP agent serving OID 1.3.6.1.2.1.1.1.0 (sysDescr) as 'NetGet SNMP Server v1.0'"
117            }),
118            // Script-based example
119            json!({
120                "type": "open_server",
121                "port": 161,
122                "base_stack": "snmp",
123                "event_handlers": [{
124                    "event_pattern": "snmp_request",
125                    "handler": {
126                        "type": "script",
127                        "language": "python",
128                        "code": "# Handle SNMP GET request\noids = event.get('oids', [])\nvariables = [{'oid': oid, 'type': 'string', 'value': 'NetGet SNMP Server'} for oid in oids]\nrespond([{'type': 'send_snmp_response', 'variables': variables}])"
129                    }
130                }]
131            }),
132            // Static handler example
133            json!({
134                "type": "open_server",
135                "port": 161,
136                "base_stack": "snmp",
137                "event_handlers": [{
138                    "event_pattern": "snmp_request",
139                    "handler": {
140                        "type": "static",
141                        "actions": [{
142                            "type": "send_snmp_response",
143                            "variables": [{
144                                "oid": "1.3.6.1.2.1.1.1.0",
145                                "type": "string",
146                                "value": "NetGet SNMP Server v1.0"
147                            }]
148                        }]
149                    }
150                }]
151            }),
152        )
153    }
154}
155
156// Implement Server trait (server-specific functionality)
157impl Server for SnmpProtocol {
158    fn spawn(
159        &self,
160        ctx: crate::protocol::SpawnContext,
161    ) -> std::pin::Pin<
162        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
163    > {
164        Box::pin(async move {
165            use crate::server::snmp::SnmpServer;
166            let usm = super::usm::agent_from_params(ctx.startup_params.as_ref())?;
167            SnmpServer::spawn_with_llm_actions(
168                ctx.legacy_listen_addr(),
169                ctx.llm_client,
170                ctx.state,
171                ctx.status_tx,
172                ctx.server_id,
173                usm,
174            )
175            .await
176        })
177    }
178    fn execute_action_with_state<'a>(
179        &'a self,
180        action: serde_json::Value,
181        state: AppState,
182        server_id: Option<crate::state::ServerId>,
183    ) -> std::pin::Pin<
184        Box<dyn std::future::Future<Output = anyhow::Result<ActionResult>> + Send + 'a>,
185    > {
186        if action.get("type").and_then(|v| v.as_str()) != Some("send_trap") {
187            let result = self.execute_action(action);
188            return Box::pin(async move { result });
189        }
190        Box::pin(async move {
191            let out = super::notify::build(&action)?;
192            let kind = if out.inform { "inform" } else { "trap" };
193            let target = out.target.clone();
194            let log_state = state.clone();
195            let send = async move {
196                let outcome = super::notify::send(&out).await;
197                if let Some(id) = server_id {
198                    log_state
199                        .record_access_log(
200                            crate::state::AccessLogOwner::Server(id.as_u32()),
201                            "SNMP",
202                            None,
203                            "snmp_notification_sent",
204                            json!({"kind": kind, "target": out.target}),
205                            vec![match &outcome {
206                                Ok(result) => result.clone(),
207                                Err(e) => json!({"sent": false, "error": format!("{e:#}")}),
208                            }],
209                        )
210                        .await;
211                }
212                match outcome {
213                    Ok(result) => {
214                        let acknowledged = result["acknowledged"].as_bool();
215                        tracing::info!(
216                            "SNMP {} to {} sent{} ({})",
217                            kind,
218                            out.target,
219                            match acknowledged {
220                                Some(true) => ", acknowledged",
221                                Some(false) => ", never acknowledged",
222                                None => "",
223                            },
224                            result
225                        );
226                    }
227                    Err(e) => tracing::warn!("SNMP {} to {} failed: {:#}", kind, out.target, e),
228                }
229            };
230            // An inform waits for its Response; run it beside the request that asked for it
231            // rather than in front of that request's own answer.
232            match server_id {
233                Some(id) => {
234                    state.spawn_server_task(id, send).await;
235                }
236                None => send.await,
237            }
238            Ok(ActionResult::Custom {
239                name: "send_trap".into(),
240                data: json!({"kind": kind, "target": target}),
241            })
242        })
243    }
244
245    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
246        let action_type = action
247            .get("type")
248            .and_then(|v| v.as_str())
249            .context("Missing 'type' field in action")?;
250
251        match action_type {
252            "send_trap" => self.execute_send_trap(action),
253            "send_snmp_response" => self.execute_send_snmp_response(action),
254            "send_snmp_error" => self.execute_send_snmp_error(action),
255            "ignore_request" => Ok(ActionResult::NoAction),
256            "acknowledge_notification" => Ok(ActionResult::Custom {
257                name: "acknowledge_notification".into(),
258                data: json!({}),
259            }),
260            "ignore_notification" => Ok(ActionResult::NoAction),
261            _ => Err(anyhow::anyhow!("Unknown SNMP action: {}", action_type)),
262        }
263    }
264}
265
266impl SnmpProtocol {
267    /// `send_trap` without a running server only validates and encodes: the datagram goes out
268    /// from `execute_action_with_state`, which every executor path calls.
269    fn execute_send_trap(&self, action: serde_json::Value) -> Result<ActionResult> {
270        super::notify::build(&action)?;
271        Ok(ActionResult::NoAction)
272    }
273
274    /// Execute send_snmp_response sync action
275    fn execute_send_snmp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
276        let variables = action
277            .get("variables")
278            .and_then(|v| v.as_array())
279            .context("Missing 'variables' parameter")?;
280
281        // Encode response data as JSON
282        // The caller will convert this to actual SNMP response format
283        let response_data = json!({
284            "variables": variables,
285            "error": false
286        });
287
288        Ok(ActionResult::Output(
289            serde_json::to_vec(&response_data).context("Failed to serialize SNMP response")?,
290        ))
291    }
292
293    /// Execute send_snmp_error sync action
294    fn execute_send_snmp_error(&self, action: serde_json::Value) -> Result<ActionResult> {
295        let error_message = action
296            .get("error_message")
297            .and_then(|v| v.as_str())
298            .context("Missing 'error_message' parameter")?;
299
300        // SNMP puts the reason on the wire as a number, not text: map the optional
301        // 'error_status' (name or code) onto it, defaulting to genErr.
302        let error_status = match action.get("error_status") {
303            None => 5,
304            Some(v) if v.is_null() => 5,
305            Some(v) => {
306                if let Some(n) = v.as_u64() {
307                    if n > 5 {
308                        return Err(anyhow::anyhow!(
309                            "Invalid 'error_status' {n}: SNMPv1 defines 0-5 \
310                             (0 noError, 1 tooBig, 2 noSuchName, 3 badValue, 4 readOnly, 5 genErr)"
311                        ));
312                    }
313                    n as u8
314                } else if let Some(s) = v.as_str() {
315                    match s.trim().to_ascii_lowercase().as_str() {
316                        "noerror" => 0,
317                        "toobig" => 1,
318                        "nosuchname" => 2,
319                        "badvalue" => 3,
320                        "readonly" => 4,
321                        "generr" | "genericerror" => 5,
322                        other => {
323                            return Err(anyhow::anyhow!(
324                                "Invalid 'error_status' {other:?}: expected one of noError, \
325                                 tooBig, noSuchName, badValue, readOnly, genErr, or the \
326                                 matching code 0-5"
327                            ))
328                        }
329                    }
330                } else {
331                    return Err(anyhow::anyhow!(
332                        "Invalid 'error_status': expected a name or a code 0-5, got {v}"
333                    ));
334                }
335            }
336        };
337
338        let error_index = action
339            .get("error_index")
340            .and_then(|v| v.as_u64())
341            .unwrap_or(0);
342
343        // Encode error response as JSON
344        let response_data = json!({
345            "error": true,
346            "error_message": error_message,
347            "error_status": error_status,
348            "error_index": error_index
349        });
350
351        Ok(ActionResult::Output(
352            serde_json::to_vec(&response_data).context("Failed to serialize SNMP error")?,
353        ))
354    }
355}
356
357fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
358    Parameter {
359        name: name.to_string(),
360        type_hint: type_hint.to_string(),
361        description: description.to_string(),
362        required,
363    }
364}
365
366/// Action definition for send_trap: a v1 or v2c trap, or a v2c inform.
367fn send_trap_action() -> ActionDefinition {
368    ActionDefinition {
369        name: "send_trap".to_string(),
370        description: "Send an SNMP notification to a manager: a v2c trap (default), a v2c inform that is resent until the manager acknowledges it, or a v1 trap. sysUpTime.0 and snmpTrapOID.0 are added for v2c; the result is logged".to_string(),
371        parameters: vec![
372            param("target", "string", "The manager: host or host:port (port 162 when omitted)", true),
373            param("trap_oid", "string", "v2c: the notification's OID (snmpTrapOID.0), e.g. 1.3.6.1.6.3.1.1.5.3 linkDown, 1.3.6.1.6.3.1.1.5.4 linkUp", false),
374            param("variables", "array", "Variable bindings [{oid, type, value}]; type is integer, string (encoding hex for binary), oid, ipaddress, counter, gauge, timeticks, counter64 (v2c only) or null", false),
375            param("version", "string", "v2c (default) or v1", false),
376            param("inform", "boolean", "v2c: send an InformRequest and wait for the manager's acknowledgement (default false: a trap)", false),
377            param("community", "string", "Community string (default public)", false),
378            param("uptime", "number", "sysUpTime / time-stamp in hundredths of a second (default: time since NetGet started)", false),
379            param("enterprise", "string", "v1: the enterprise OID (default 1.3.6.1.4.1.8072.9999)", false),
380            param("generic_trap", "number", "v1: 0 coldStart, 1 warmStart, 2 linkDown, 3 linkUp, 4 authenticationFailure, 5 egpNeighborLoss, 6 enterpriseSpecific (default)", false),
381            param("specific_trap", "number", "v1: the enterprise-specific trap number (default 0)", false),
382            param("agent_addr", "string", "v1: the agent's IPv4 address in the trap (default 0.0.0.0)", false),
383        ],
384        example: json!({
385            "type": "send_trap",
386            "target": "127.0.0.1:162",
387            "trap_oid": "1.3.6.1.6.3.1.1.5.3",
388            "variables": [
389                {"oid": "1.3.6.1.2.1.2.2.1.1.2", "type": "integer", "value": 2}
390            ]
391        }),
392        log_template: Some(
393            LogTemplate::new()
394                .with_info("-> SNMP notification to {target}")
395                .with_debug("SNMP send_trap: target={target}, {variables_len} vars"),
396        ),
397    }
398}
399
400fn acknowledge_notification_action() -> ActionDefinition {
401    ActionDefinition {
402        name: "acknowledge_notification".to_string(),
403        description: "Accept the notification. For an inform this sends the Response the sender is waiting for; for a trap nothing goes on the wire".to_string(),
404        parameters: vec![],
405        example: json!({"type": "acknowledge_notification"}),
406        log_template: Some(
407            LogTemplate::new()
408                .with_info("SNMP notification acknowledged")
409                .with_debug("SNMP acknowledge_notification"),
410        ),
411    }
412}
413
414fn ignore_notification_action() -> ActionDefinition {
415    ActionDefinition {
416        name: "ignore_notification".to_string(),
417        description:
418            "Do not accept it: an inform gets no Response, so its sender retries and then gives up"
419                .to_string(),
420        parameters: vec![],
421        example: json!({"type": "ignore_notification"}),
422        log_template: Some(
423            LogTemplate::new()
424                .with_info("SNMP notification ignored")
425                .with_debug("SNMP ignore_notification"),
426        ),
427    }
428}
429
430pub static SNMP_NOTIFICATION_EVENT: LazyLock<EventType> =
431    LazyLock::new(|| {
432        EventType::new(
433            "snmp_notification",
434            "A trap or inform arrived from an agent",
435            json!({"type": "acknowledge_notification"}),
436        )
437        .with_parameters(vec![
438        param(
439            "kind",
440            "string",
441            "trap or inform (an inform waits for acknowledgement)",
442            true,
443        ),
444        param("version", "string", "The SNMP version the notification used: v1 or v2c", true),
445        param(
446            "community",
447            "string",
448            "Community string the sender used",
449            true,
450        ),
451        param(
452            "trap_oid",
453            "string",
454            "v2c: snmpTrapOID.0, the notification's identity (e.g. 1.3.6.1.6.3.1.1.5.3 linkDown)",
455            false,
456        ),
457        param(
458            "uptime",
459            "number",
460            "The sender's sysUpTime (v2c) or time-stamp (v1), hundredths of a second",
461            false,
462        ),
463        param("enterprise", "string", "v1: the enterprise OID", false),
464        param(
465            "agent_addr",
466            "string",
467            "v1: the agent address the trap names",
468            false,
469        ),
470        param(
471            "generic_trap",
472            "number",
473            "v1: 0 coldStart … 6 enterpriseSpecific",
474            false,
475        ),
476        param(
477            "specific_trap",
478            "number",
479            "v1: the enterprise-specific trap number",
480            false,
481        ),
482        param(
483            "variables",
484            "array",
485            "The other variable bindings [{oid, type, value}] (binary strings carry encoding hex)",
486            true,
487        ),
488        param(
489            "request_id",
490            "number",
491            "v2c: the PDU's request id (an inform's acknowledgement echoes it automatically)",
492            false,
493        ),
494        param(
495            "client_ip",
496            "string",
497            "Where the notification came from",
498            true,
499        ),
500    ])
501        .with_actions(vec![
502            acknowledge_notification_action(),
503            ignore_notification_action(),
504            send_trap_action(),
505        ])
506        .with_log_template(
507            LogTemplate::new()
508                .with_info("SNMP {kind} from {client_ip}")
509                .with_debug("SNMP {version} {kind} from {client_ip}: {trap_oid} {variables}")
510                .with_trace("SNMP: {json_pretty(.)}"),
511        )
512    });
513
514/// Action definition for send_snmp_response (sync)
515fn send_snmp_response_action() -> ActionDefinition {
516    ActionDefinition {
517        name: "send_snmp_response".to_string(),
518        description: "Answer the request with a set of OID values. The request-id, community string and SNMP version are copied from the request being answered, so you only supply the values. Return exactly one entry per OID in the event's 'oids' list, in the same order, and no others; each value is the one your instructions give for that OID, word for word. The example's OID and value are placeholders for the shape, never data.".to_string(),
519        parameters: vec![Parameter {
520            name: "variables".to_string(),
521            type_hint: "array".to_string(),
522            description: "Variable bindings, each an object {\"oid\": ..., \"type\": ..., \"value\": ...}. 'oid' is dotted decimal such as \"1.3.6.1.2.1.1.1.0\" (first component 0-2, every component numeric). 'type' is one of: \"string\" (text), \"integer\" (signed 32-bit), \"counter\" (Counter32, monotonically increasing), \"gauge\" (Gauge32, a value that goes up and down), \"timeticks\" (hundredths of a second since start-up, e.g. sysUpTime), \"null\" (no value for this OID). 'value' must match the type: a JSON string for \"string\", a JSON number for the numeric types, omitted for \"null\". An unrecognised type encodes as NULL".to_string(),
523            required: true,
524        }],
525        example: json!({
526            "type": "send_snmp_response",
527            "variables": [
528                {"oid": "1.3.6.1.4.1.99999.1.0", "type": "string", "value": "<the value your instructions give for this OID>"}
529            ]
530        }),
531        log_template: Some(
532            LogTemplate::new()
533                .with_info("-> SNMP response {variables_len} vars")
534                .with_debug("SNMP send_snmp_response: {variables_len} variables"),
535        ),
536    }
537}
538
539/// Action definition for send_snmp_error (sync)
540fn send_snmp_error_action() -> ActionDefinition {
541    ActionDefinition {
542        name: "send_snmp_error".to_string(),
543        description: "Answer the request with an SNMP error instead of values - use it when the requested OID does not exist here, or when a SET is refused. The reply carries the numeric error-status and no variable bindings; the request-id and community are echoed automatically.".to_string(),
544        parameters: vec![
545            Parameter {
546                name: "error_message".to_string(),
547                type_hint: "string".to_string(),
548                description: "Why the request failed. SNMP has no field for text, so this only reaches NetGet's logs and access log - the client sees 'error_status'".to_string(),
549                required: true,
550            },
551            Parameter {
552                name: "error_status".to_string(),
553                type_hint: "string | number".to_string(),
554                description: "Which error the client is told about: \"noSuchName\" (2) for an OID this agent does not serve, \"badValue\" (3) for a SET with an unusable value, \"readOnly\" (4) for a SET of a read-only OID, \"tooBig\" (1) if the answer would not fit, \"genErr\" (5) for anything else. Accepts the name or the number. Default: genErr".to_string(),
555                required: false,
556            },
557            Parameter {
558                name: "error_index".to_string(),
559                type_hint: "number".to_string(),
560                description: "1-based position of the offending OID in the request's list, or 0 when no single OID is to blame. Default 0".to_string(),
561                required: false,
562            },
563        ],
564        example: json!({
565            "type": "send_snmp_error",
566            "error_message": "OID 1.3.6.1.4.1.9999.1.0 is not served by this agent",
567            "error_status": "noSuchName",
568            "error_index": 1
569        }),
570        log_template: Some(
571            LogTemplate::new()
572                .with_info("-> SNMP error: {error_message}")
573                .with_debug("SNMP send_snmp_error: {error_message}"),
574        ),
575    }
576}
577
578/// Action definition for ignore_request (sync)
579fn ignore_request_action() -> ActionDefinition {
580    ActionDefinition {
581        name: "ignore_request".to_string(),
582        description: "Ignore this SNMP request and don't send a response".to_string(),
583        parameters: vec![],
584        example: json!({
585            "type": "ignore_request"
586        }),
587        log_template: Some(
588            LogTemplate::new()
589                .with_info("SNMP request ignored")
590                .with_debug("SNMP ignore_request"),
591        ),
592    }
593}
594
595// ============================================================================
596// SNMP Event Type Constants
597// ============================================================================
598
599pub static SNMP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
600    EventType::new(
601        "snmp_request",
602        "SNMP client sent a GET/GETNEXT/GETBULK request",
603        json!({
604            "type": "send_snmp_response",
605            "variables": [
606                {"oid": "1.3.6.1.4.1.99999.1.0", "type": "string", "value": "<the value your instructions give for this OID>"}
607            ]
608        })
609    )
610    .with_parameters(vec![
611        Parameter {
612            name: "request_type".to_string(),
613            type_hint: "string".to_string(),
614            description: "PDU type of the request, spelled exactly: 'GetRequest', 'GetNextRequest', 'GetBulkRequest' or 'SetRequest'. GetNextRequest asks for the OID that follows the one given (table walking); GetBulkRequest asks for several at once".to_string(),
615            required: true,
616        },
617        Parameter {
618            name: "oids".to_string(),
619            type_hint: "array".to_string(),
620            description: "OIDs the client asked about, in dotted decimal, in request order. Answer with one variable binding per OID in the same order".to_string(),
621            required: true,
622        },
623        Parameter {
624            name: "answer_with".to_string(),
625            type_hint: "string".to_string(),
626            description: "Which answer this request takes: the variables to return, each OID named with its MIB-2 name where it has one (sysDescr, sysName, ...), and when to answer noSuchName instead".to_string(),
627            required: true,
628        },
629        Parameter {
630            name: "community".to_string(),
631            type_hint: "string".to_string(),
632            description: "Community string the client sent - SNMPv1/v2c's only credential, typically 'public'. It is echoed back automatically; check it here if the instruction says to reject unknown communities".to_string(),
633            required: false,
634        },
635        Parameter {
636            name: "request_id".to_string(),
637            type_hint: "number".to_string(),
638            description: "Request id chosen by the client. The response echoes it automatically - it is exposed only for logging".to_string(),
639            required: false,
640        },
641        Parameter {
642            name: "version".to_string(),
643            type_hint: "string".to_string(),
644            description: "'v1', 'v2c' or 'v3'. The reply uses the same version".to_string(),
645            required: false,
646        },
647        Parameter {
648            name: "client_ip".to_string(),
649            type_hint: "string".to_string(),
650            description: "IP address the request came from".to_string(),
651            required: false,
652        },
653        Parameter {
654            name: "user".to_string(),
655            type_hint: "string".to_string(),
656            description: "SNMPv3 only: the USM user the request was authenticated as (its MAC verified and its time checked before you see it). Decide per user what it may read or write".to_string(),
657            required: false,
658        },
659        Parameter {
660            name: "security_level".to_string(),
661            type_hint: "string".to_string(),
662            description: "SNMPv3 only: noAuthNoPriv, authNoPriv or authPriv - always the level the user is configured for. The answer goes back at the same level, encrypted when authPriv".to_string(),
663            required: false,
664        },
665        Parameter {
666            name: "context_name".to_string(),
667            type_hint: "string".to_string(),
668            description: "SNMPv3 only: the contextName the manager asked in, empty for the default context".to_string(),
669            required: false,
670        },
671    ])
672    .with_actions(vec![
673        send_snmp_response_action(),
674        send_snmp_error_action(),
675        ignore_request_action(),
676        send_trap_action(),
677    ])
678    .with_log_template(
679        LogTemplate::new()
680            .with_info("SNMP {request_type} from {client_ip}")
681            .with_debug("SNMP {request_type} from {client_ip} ({version}, community={community}): {oids}")
682            .with_trace("SNMP: {json_pretty(.)}"),
683    )
684});
685
686/// The MIB-2 name and meaning of the OIDs a small model is most often asked for.
687///
688/// A model that has not memorised MIB-2 does not know that 1.3.6.1.2.1.1.1.0 is "the system
689/// description" an instruction talks about, so the request names it.
690fn well_known_oid(oid: &str) -> Option<&'static str> {
691    Some(match oid.trim_start_matches('.') {
692        "1.3.6.1.2.1.1.1.0" => "sysDescr, the system description your instructions give",
693        "1.3.6.1.2.1.1.2.0" => "sysObjectID, the vendor's object identifier",
694        "1.3.6.1.2.1.1.3.0" => "sysUpTime, timeticks since start-up",
695        "1.3.6.1.2.1.1.4.0" => "sysContact, the contact person",
696        "1.3.6.1.2.1.1.5.0" => {
697            "sysName, the device's name exactly as your instructions write it - a hostname, \
698             character for character, never reworded or capitalised"
699        }
700        "1.3.6.1.2.1.1.6.0" => "sysLocation, where the system is",
701        "1.3.6.1.2.1.1.7.0" => "sysServices, an integer",
702        "1.3.6.1.2.1.2.1.0" => "ifNumber, how many interfaces",
703        _ => return None,
704    })
705}
706
707/// The `answer_with` event field: the variables this request takes, OID by OID.
708///
709/// `send_snmp_response`'s example used to carry sysDescr = "System Description" and sysName =
710/// "hostname". Told "your system description is NetGet Eval Switch 1.0", llama3.1:8b answered
711/// with those two example values - and both OIDs, so a one-OID snmpget got two varbinds - ten
712/// runs in ten. The request now names each OID it asks for, what it means, and that nothing
713/// else belongs in the answer.
714pub fn answer_with_for_request(request_type: &str, oids: &[String]) -> String {
715    let named: Vec<String> = oids
716        .iter()
717        .map(|oid| match well_known_oid(oid) {
718            Some(name) => format!("{oid} ({name})"),
719            None => oid.clone(),
720        })
721        .collect();
722    let list = named.join(", ");
723    let n = oids.len();
724    let noun = if n == 1 { "variable" } else { "variables" };
725    match request_type {
726        "GetNextRequest" | "GetBulkRequest" => format!(
727            "send_snmp_response with the OID that follows each of {list} in your agent's tree, \
728             and its value; send_snmp_error with noSuchName when nothing follows"
729        ),
730        "SetRequest" => format!(
731            "send_snmp_response echoing the {n} {noun} set on {list} when your instructions let \
732             them be written; send_snmp_error with readOnly when they do not"
733        ),
734        _ => format!(
735            "send_snmp_response with exactly {n} {noun}, in this order: {list}. Each value is \
736             the one your instructions give for it, word for word; add no other OIDs. If your \
737             instructions give no value for one of them, send_snmp_error with noSuchName and \
738             its 1-based error_index instead"
739        ),
740    }
741}
742
743pub fn get_snmp_event_types() -> Vec<EventType> {
744    vec![SNMP_REQUEST_EVENT.clone(), SNMP_NOTIFICATION_EVENT.clone()]
745}