Skip to main content

netget/server/snmp/
notify.rs

1//! SNMP notifications, both ways: traps (v1 Trap-PDU, v2c SNMPv2-Trap) and v2c informs.
2//!
3//! **Received** on the agent's socket, a notification is decoded here into the
4//! `snmp_notification` event — kind, version, community, the v2 `sysUpTime.0` and
5//! `snmpTrapOID.0` (or v1's enterprise, agent address, generic and specific trap and
6//! timestamp), and every variable binding typed — and an inform is acknowledged with a
7//! Response echoing its request-id and bindings only when the handler says so.
8//!
9//! **Sent** by `send_trap`: a v1 or v2c trap, or a v2c inform that is retried until a
10//! Response with its request-id arrives. Encoding and decoding are rasn's; the values the
11//! model writes and reads are `{oid, type, value}`.
12use anyhow::{bail, ensure, Context, Result};
13use rasn::ber;
14use rasn::types::{FixedOctetString, Integer, ObjectIdentifier, OctetString};
15use rasn_smi::{v1 as smi1, v2 as smi2};
16use rasn_snmp::{v1, v2, v2c};
17use serde_json::{json, Value};
18use std::net::{IpAddr, Ipv4Addr, SocketAddr};
19use std::time::Duration;
20
21pub const SYS_UPTIME_OID: &str = "1.3.6.1.2.1.1.3.0";
22pub const SNMP_TRAP_OID: &str = "1.3.6.1.6.3.1.1.4.1.0";
23/// The default trap port (RFC 3417 §3).
24pub const TRAP_PORT: u16 = 162;
25/// Variable bindings a sent notification may carry.
26pub const MAX_VARIABLES: usize = 64;
27/// How long an inform waits for its Response, and how many times it is sent.
28pub const INFORM_TIMEOUT: Duration = Duration::from_secs(2);
29pub const INFORM_ATTEMPTS: usize = 3;
30
31/// A notification that arrived.
32pub struct Received {
33    pub kind: &'static str,
34    /// The `snmp_notification` event's data (without `client_ip`).
35    pub data: Value,
36    /// For an inform: the Response that acknowledges it.
37    pub acknowledgement: Option<Vec<u8>>,
38}
39
40fn integer(i: &Integer) -> i64 {
41    match i {
42        Integer::Primitive(v) => *v as i64,
43        Integer::Variable(big) => big.to_string().parse().unwrap_or(0),
44    }
45}
46
47fn octets(bytes: &[u8]) -> Value {
48    match std::str::from_utf8(bytes) {
49        Ok(s) if !crate::utils::sanitize::has_controls(s) => {
50            json!({"type": "string", "value": s})
51        }
52        _ => json!({"type": "string", "encoding": "hex", "value": hex::encode(bytes)}),
53    }
54}
55
56fn ipv4(a: &smi1::IpAddress) -> String {
57    let b: &[u8] = a.0.as_ref();
58    Ipv4Addr::new(b[0], b[1], b[2], b[3]).to_string()
59}
60
61fn v2_value(v: &v2::VarBindValue) -> Value {
62    use smi2::{ApplicationSyntax as A, ObjectSyntax as O, SimpleSyntax as S};
63    match v {
64        v2::VarBindValue::Value(O::Simple(S::Integer(i))) => {
65            json!({"type": "integer", "value": integer(i)})
66        }
67        v2::VarBindValue::Value(O::Simple(S::String(s))) => octets(s),
68        v2::VarBindValue::Value(O::Simple(S::ObjectId(o))) => {
69            json!({"type": "oid", "value": o.to_string()})
70        }
71        v2::VarBindValue::Value(O::ApplicationWide(A::Address(a))) => {
72            json!({"type": "ipaddress", "value": ipv4(a)})
73        }
74        v2::VarBindValue::Value(O::ApplicationWide(A::Counter(c))) => {
75            json!({"type": "counter", "value": c.0})
76        }
77        v2::VarBindValue::Value(O::ApplicationWide(A::Ticks(t))) => {
78            json!({"type": "timeticks", "value": t.0})
79        }
80        v2::VarBindValue::Value(O::ApplicationWide(A::Unsigned(g))) => {
81            json!({"type": "gauge", "value": g.0})
82        }
83        v2::VarBindValue::Value(O::ApplicationWide(A::BigCounter(c))) => {
84            json!({"type": "counter64", "value": c.0})
85        }
86        v2::VarBindValue::Value(O::ApplicationWide(A::Arbitrary(o))) => {
87            json!({"type": "opaque", "encoding": "hex", "value": hex::encode(o.as_ref())})
88        }
89        v2::VarBindValue::Unspecified => json!({"type": "null", "value": null}),
90        v2::VarBindValue::NoSuchObject => json!({"type": "noSuchObject", "value": null}),
91        v2::VarBindValue::NoSuchInstance => json!({"type": "noSuchInstance", "value": null}),
92        v2::VarBindValue::EndOfMibView => json!({"type": "endOfMibView", "value": null}),
93    }
94}
95
96fn v1_value(v: &smi1::ObjectSyntax) -> Value {
97    use smi1::{ApplicationSyntax as A, NetworkAddress as N, ObjectSyntax as O, SimpleSyntax as S};
98    match v {
99        O::Simple(S::Number(i)) => json!({"type": "integer", "value": integer(i)}),
100        O::Simple(S::String(s)) => octets(s),
101        O::Simple(S::Object(o)) => json!({"type": "oid", "value": o.to_string()}),
102        O::Simple(S::Empty) => json!({"type": "null", "value": null}),
103        O::ApplicationWide(A::Address(N::Internet(a))) => {
104            json!({"type": "ipaddress", "value": ipv4(a)})
105        }
106        O::ApplicationWide(A::Counter(c)) => json!({"type": "counter", "value": c.0}),
107        O::ApplicationWide(A::Gauge(g)) => json!({"type": "gauge", "value": g.0}),
108        O::ApplicationWide(A::Ticks(t)) => json!({"type": "timeticks", "value": t.0}),
109        O::ApplicationWide(A::Arbitrary(o)) => {
110            json!({"type": "opaque", "encoding": "hex", "value": hex::encode(o.as_ref())})
111        }
112    }
113}
114
115fn binding(oid: String, mut typed: Value) -> Value {
116    typed["oid"] = json!(oid);
117    typed
118}
119
120/// Decode a notification; `None` for any other datagram (requests go the usual way).
121pub fn parse(data: &[u8]) -> Option<Received> {
122    // Screened here as well as by the receive loop, so no caller reaches `rasn` without it.
123    if super::check_ber_structure(data, super::MAX_BER_DEPTH).is_err() {
124        return None;
125    }
126    if let Ok(msg) = ber::decode::<v2c::Message<v2::Pdus>>(data) {
127        let (kind, pdu) = match &msg.data {
128            v2::Pdus::Trap(t) => ("trap", &t.0),
129            v2::Pdus::InformRequest(i) => ("inform", &i.0),
130            _ => return None,
131        };
132        let mut variables = Vec::new();
133        let (mut uptime, mut trap_oid) = (Value::Null, Value::Null);
134        for vb in &pdu.variable_bindings {
135            let oid = vb.name.to_string();
136            let typed = v2_value(&vb.value);
137            if oid == SYS_UPTIME_OID {
138                uptime = typed["value"].clone();
139            } else if oid == SNMP_TRAP_OID {
140                trap_oid = typed["value"].clone();
141            } else {
142                variables.push(binding(oid, typed));
143            }
144        }
145        let acknowledgement = (kind == "inform")
146            .then(|| {
147                ber::encode(&v2c::Message {
148                    version: msg.version.clone(),
149                    community: msg.community.clone(),
150                    data: v2::Pdus::Response(v2::Response(v2::Pdu {
151                        request_id: pdu.request_id,
152                        error_status: v2::Pdu::ERROR_STATUS_NO_ERROR,
153                        error_index: 0,
154                        variable_bindings: pdu.variable_bindings.clone(),
155                    })),
156                })
157                .ok()
158            })
159            .flatten();
160        return Some(Received {
161            kind,
162            data: json!({
163                "kind": kind, "version": "v2c",
164                "community": String::from_utf8_lossy(&msg.community),
165                "request_id": pdu.request_id, "uptime": uptime, "trap_oid": trap_oid,
166                "variables": variables,
167            }),
168            acknowledgement,
169        });
170    }
171    let msg = ber::decode::<v1::Message<v1::Pdus>>(data).ok()?;
172    let v1::Pdus::Trap(trap) = &msg.data else {
173        return None;
174    };
175    let smi1::NetworkAddress::Internet(agent) = &trap.agent_addr;
176    Some(Received {
177        kind: "trap",
178        data: json!({
179            "kind": "trap", "version": "v1",
180            "community": String::from_utf8_lossy(&msg.community),
181            "enterprise": trap.enterprise.to_string(),
182            "agent_addr": ipv4(agent),
183            "generic_trap": integer(&trap.generic_trap),
184            "specific_trap": integer(&trap.specific_trap),
185            "uptime": trap.time_stamp.0,
186            "variables": trap.variable_bindings.iter()
187                .map(|vb| binding(vb.name.to_string(), v1_value(&vb.value)))
188                .collect::<Vec<_>>(),
189        }),
190        acknowledgement: None,
191    })
192}
193
194pub fn oid(text: &str) -> Result<ObjectIdentifier> {
195    let arcs: Vec<u32> = text
196        .trim_start_matches('.')
197        .split('.')
198        .map(|a| a.parse::<u32>())
199        .collect::<std::result::Result<_, _>>()
200        .with_context(|| format!("{text:?} is not a dotted-decimal OID"))?;
201    ObjectIdentifier::new(arcs).with_context(|| format!("{text:?} is not a valid OID"))
202}
203
204fn u32_of(v: &Value, what: &str) -> Result<u32> {
205    v.as_u64()
206        .and_then(|n| u32::try_from(n).ok())
207        .with_context(|| format!("{what} is an unsigned 32-bit number"))
208}
209
210fn ip_of(v: &Value) -> Result<smi1::IpAddress> {
211    let ip: Ipv4Addr = v
212        .as_str()
213        .context("an ipaddress value is a dotted IPv4 address")?
214        .parse()
215        .context("an ipaddress value is a dotted IPv4 address")?;
216    Ok(smi1::IpAddress(FixedOctetString::from(ip.octets())))
217}
218
219fn string_of(v: &Value, encoding: Option<&str>) -> Result<OctetString> {
220    let s = v.as_str().context("a string value is text")?;
221    Ok(match encoding {
222        Some("hex") => OctetString::from(hex::decode(s).context("value is not hex")?),
223        None | Some("utf8") => OctetString::from(s.as_bytes().to_vec()),
224        Some(other) => bail!("encoding is utf8 or hex, not {other:?}"),
225    })
226}
227
228/// One `{oid, type, value}` as a v2 binding.
229fn v2_binding(var: &Value) -> Result<v2::VarBind> {
230    use smi2::{ApplicationSyntax as A, ObjectSyntax as O, SimpleSyntax as S};
231    let name = oid(var["oid"].as_str().context("each variable has an oid")?)?;
232    let value = &var["value"];
233    let syntax = match var["type"].as_str().unwrap_or("string") {
234        "integer" => O::Simple(S::Integer(Integer::from(
235            value.as_i64().context("an integer value is a number")?,
236        ))),
237        "string" => O::Simple(S::String(string_of(value, var["encoding"].as_str())?)),
238        "oid" => O::Simple(S::ObjectId(oid(value.as_str().context("an oid value is text")?)?)),
239        "ipaddress" => O::ApplicationWide(A::Address(ip_of(value)?)),
240        "counter" => O::ApplicationWide(A::Counter(smi1::Counter(u32_of(value, "a counter")?))),
241        "gauge" => O::ApplicationWide(A::Unsigned(smi1::Gauge(u32_of(value, "a gauge")?))),
242        "timeticks" => O::ApplicationWide(A::Ticks(smi1::TimeTicks(u32_of(value, "timeticks")?))),
243        "counter64" => O::ApplicationWide(A::BigCounter(smi2::Counter64(
244            value.as_u64().context("a counter64 is an unsigned number")?,
245        ))),
246        "null" => {
247            return Ok(v2::VarBind {
248                name,
249                value: v2::VarBindValue::Unspecified,
250            })
251        }
252        other => bail!("variable type {other:?} is not one of integer, string, oid, ipaddress, counter, gauge, timeticks, counter64, null"),
253    };
254    Ok(v2::VarBind {
255        name,
256        value: v2::VarBindValue::Value(syntax),
257    })
258}
259
260fn v1_binding(var: &Value) -> Result<v1::VarBind> {
261    use smi1::{ApplicationSyntax as A, NetworkAddress as N, ObjectSyntax as O, SimpleSyntax as S};
262    let name = oid(var["oid"].as_str().context("each variable has an oid")?)?;
263    let value = &var["value"];
264    let syntax = match var["type"].as_str().unwrap_or("string") {
265        "integer" => O::Simple(S::Number(Integer::from(
266            value.as_i64().context("an integer value is a number")?,
267        ))),
268        "string" => O::Simple(S::String(string_of(value, var["encoding"].as_str())?)),
269        "oid" => O::Simple(S::Object(oid(value.as_str().context("an oid value is text")?)?)),
270        "null" => O::Simple(S::Empty),
271        "ipaddress" => O::ApplicationWide(A::Address(N::Internet(ip_of(value)?))),
272        "counter" => O::ApplicationWide(A::Counter(smi1::Counter(u32_of(value, "a counter")?))),
273        "gauge" => O::ApplicationWide(A::Gauge(smi1::Gauge(u32_of(value, "a gauge")?))),
274        "timeticks" => O::ApplicationWide(A::Ticks(smi1::TimeTicks(u32_of(value, "timeticks")?))),
275        other => bail!("variable type {other:?} is not one SNMPv1 carries (integer, string, oid, null, ipaddress, counter, gauge, timeticks)"),
276    };
277    Ok(v1::VarBind {
278        name,
279        value: syntax,
280    })
281}
282
283/// A notification ready to send.
284pub struct Outgoing {
285    pub target: String,
286    pub inform: bool,
287    pub request_id: i32,
288    pub packet: Vec<u8>,
289}
290
291/// Process uptime in hundredths of a second, the default `sysUpTime.0`.
292fn uptime_ticks() -> u32 {
293    static START: std::sync::OnceLock<crate::utils::clock::Instant> = std::sync::OnceLock::new();
294    let start = *START.get_or_init(crate::utils::clock::Instant::now);
295    (start.elapsed().as_millis() / 10) as u32
296}
297
298/// Validate and encode a `send_trap` action.
299pub fn build(action: &Value) -> Result<Outgoing> {
300    let target = action["target"]
301        .as_str()
302        .context("Missing 'target' (host or host:port; port 162 when omitted)")?;
303    ensure!(
304        !target.is_empty() && target.len() <= 255 && !target.contains(char::is_whitespace),
305        "target is host or host:port"
306    );
307    let target = if target.parse::<SocketAddr>().is_ok()
308        || target
309            .rsplit_once(':')
310            .is_some_and(|(h, p)| !h.contains(':') && p.parse::<u16>().is_ok())
311    {
312        target.to_string()
313    } else if let Ok(ip) = target.trim_matches(['[', ']']).parse::<IpAddr>() {
314        SocketAddr::new(ip, TRAP_PORT).to_string()
315    } else {
316        format!("{target}:{TRAP_PORT}")
317    };
318    let version = action["version"].as_str().unwrap_or("v2c");
319    let inform = action["inform"].as_bool().unwrap_or(false);
320    let community = action["community"].as_str().unwrap_or("public");
321    ensure!(community.len() <= 255, "community is at most 255 bytes");
322    let variables = match action.get("variables") {
323        None | Some(Value::Null) => Vec::new(),
324        Some(v) => v
325            .as_array()
326            .context("variables is an array of {oid, type, value}")?
327            .clone(),
328    };
329    ensure!(
330        variables.len() <= MAX_VARIABLES,
331        "at most {MAX_VARIABLES} variables"
332    );
333    let uptime = match action.get("uptime") {
334        None | Some(Value::Null) => uptime_ticks(),
335        Some(v) => u32_of(v, "uptime (timeticks)")?,
336    };
337    let request_id = (rand::random::<u32>() & 0x7fff_ffff) as i32;
338    let packet = match version {
339        "v2c" => {
340            let trap_oid = oid(action["trap_oid"]
341                .as_str()
342                .context("a v2c notification names its trap_oid (snmpTrapOID.0), e.g. 1.3.6.1.6.3.1.1.5.3 (linkDown)")?)?;
343            let mut bindings = vec![
344                v2::VarBind {
345                    name: oid(SYS_UPTIME_OID)?,
346                    value: v2::VarBindValue::Value(smi2::ObjectSyntax::ApplicationWide(
347                        smi2::ApplicationSyntax::Ticks(smi1::TimeTicks(uptime)),
348                    )),
349                },
350                v2::VarBind {
351                    name: oid(SNMP_TRAP_OID)?,
352                    value: v2::VarBindValue::Value(smi2::ObjectSyntax::Simple(
353                        smi2::SimpleSyntax::ObjectId(trap_oid),
354                    )),
355                },
356            ];
357            for var in &variables {
358                bindings.push(v2_binding(var)?);
359            }
360            let pdu = v2::Pdu {
361                request_id,
362                error_status: v2::Pdu::ERROR_STATUS_NO_ERROR,
363                error_index: 0,
364                variable_bindings: bindings,
365            };
366            ber::encode(&v2c::Message {
367                version: Integer::from(1),
368                community: OctetString::from(community.as_bytes().to_vec()),
369                data: if inform {
370                    v2::Pdus::InformRequest(v2::InformRequest(pdu))
371                } else {
372                    v2::Pdus::Trap(v2::Trap(pdu))
373                },
374            })
375            .map_err(|e| anyhow::anyhow!("encoding the notification: {e}"))?
376        }
377        "v1" => {
378            ensure!(!inform, "an inform is SNMPv2c; SNMPv1 has only traps");
379            let generic = action["generic_trap"].as_i64().unwrap_or(6);
380            ensure!(
381                (0..=6).contains(&generic),
382                "generic_trap is 0..=6 (6 = enterpriseSpecific)"
383            );
384            let bindings = variables
385                .iter()
386                .map(v1_binding)
387                .collect::<Result<Vec<_>>>()?;
388            let agent = match action.get("agent_addr") {
389                None | Some(Value::Null) => smi1::IpAddress(FixedOctetString::from([0, 0, 0, 0])),
390                Some(v) => ip_of(v)?,
391            };
392            ber::encode(&v1::Message {
393                version: Integer::from(0),
394                community: OctetString::from(community.as_bytes().to_vec()),
395                data: v1::Pdus::Trap(v1::Trap {
396                    enterprise: oid(action["enterprise"]
397                        .as_str()
398                        .unwrap_or("1.3.6.1.4.1.8072.9999"))?,
399                    agent_addr: smi1::NetworkAddress::Internet(agent),
400                    generic_trap: Integer::from(generic),
401                    specific_trap: Integer::from(action["specific_trap"].as_i64().unwrap_or(0)),
402                    time_stamp: smi1::TimeTicks(uptime),
403                    variable_bindings: bindings,
404                }),
405            })
406            .map_err(|e| anyhow::anyhow!("encoding the trap: {e}"))?
407        }
408        other => bail!("version is v1 or v2c (SNMPv3 is not implemented), not {other:?}"),
409    };
410    Ok(Outgoing {
411        target,
412        inform,
413        request_id,
414        packet,
415    })
416}
417
418/// Send a notification. A trap is fire-and-forget; an inform is resent until a Response with
419/// its request-id arrives, and the result says whether one did.
420pub async fn send(out: &Outgoing) -> Result<Value> {
421    let target = tokio::net::lookup_host(&out.target)
422        .await?
423        .next()
424        .with_context(|| format!("{} did not resolve", out.target))?;
425    let bind: SocketAddr = if target.is_ipv4() {
426        "0.0.0.0:0".parse()?
427    } else {
428        "[::]:0".parse()?
429    };
430    let socket = tokio::net::UdpSocket::bind(bind).await?;
431    if !out.inform {
432        socket.send_to(&out.packet, target).await?;
433        return Ok(json!({"sent": true, "target": target.to_string(), "kind": "trap"}));
434    }
435    let mut buf = vec![0u8; 65535];
436    for attempt in 1..=INFORM_ATTEMPTS {
437        socket.send_to(&out.packet, target).await?;
438        let deadline = tokio::time::Instant::now() + INFORM_TIMEOUT;
439        while let Ok(Ok((n, from))) =
440            tokio::time::timeout_at(deadline, socket.recv_from(&mut buf)).await
441        {
442            if from.ip() != target.ip() {
443                continue;
444            }
445            // The acknowledgement is the target's bytes: screened for nesting depth before
446            // `rasn`'s unbounded recursion sees them, as every other received datagram is.
447            if super::check_ber_structure(&buf[..n], super::MAX_BER_DEPTH).is_err() {
448                continue;
449            }
450            if let Ok(msg) = ber::decode::<v2c::Message<v2::Pdus>>(&buf[..n]) {
451                if let v2::Pdus::Response(r) = msg.data {
452                    if r.0.request_id == out.request_id {
453                        return Ok(
454                            json!({"sent": true, "target": target.to_string(), "kind": "inform",
455                            "acknowledged": true, "attempts": attempt, "error_status": r.0.error_status}),
456                        );
457                    }
458                }
459            }
460        }
461    }
462    Ok(
463        json!({"sent": true, "target": target.to_string(), "kind": "inform", "acknowledged": false, "attempts": INFORM_ATTEMPTS}),
464    )
465}