Skip to main content

async_snmp/notification/
mod.rs

1//! SNMP Notification Receiver (RFC 3413).
2//!
3//! This module provides functionality for receiving SNMP notifications:
4//! - `TrapV1` (SNMP v1 format, different PDU structure)
5//! - `TrapV2`/`SNMPv2-Trap` (SNMP v2c/v3 format)
6//! - `InformRequest` (confirmed notification, requires response)
7//!
8//! # Example
9//!
10//! Receive v1/v2c notifications. A receiver constructed with `bind` has no
11//! USM user table, so v3 notifications are rejected; see below for v3.
12//!
13//! ```rust,no_run
14//! use async_snmp::notification::{NotificationReceiver, Notification};
15//! use std::net::SocketAddr;
16//!
17//! #[tokio::main]
18//! async fn main() -> Result<(), Box<async_snmp::Error>> {
19//!     let receiver = NotificationReceiver::bind("0.0.0.0:162").await?;
20//!
21//!     loop {
22//!         match receiver.recv().await {
23//!             Ok((notification, source)) => {
24//!                 println!("Received notification from {}: {:?}", source, notification);
25//!             }
26//!             Err(e) => {
27//!                 eprintln!("Error receiving notification: {}", e);
28//!             }
29//!         }
30//!     }
31//! }
32//! ```
33//!
34//! # V3 Notifications
35//!
36//! To receive V3 traps and `InformRequests`, configure USM credentials via
37//! the builder. Only notifications from registered usernames are accepted,
38//! at any security level including noAuthNoPriv:
39//!
40//! ```rust,no_run
41//! use async_snmp::notification::NotificationReceiver;
42//! use async_snmp::{AuthProtocol, PrivProtocol};
43//!
44//! # async fn example() -> Result<(), Box<async_snmp::Error>> {
45//! let receiver = NotificationReceiver::builder()
46//!     .bind("0.0.0.0:162")
47//!     .usm_user("informuser", |u| {
48//!         u.auth(AuthProtocol::Sha1, b"authpass123")
49//!          .privacy(PrivProtocol::Aes128, b"privpass123")
50//!     })
51//!     .build()
52//!     .await?;
53//! # Ok(())
54//! # }
55//! ```
56//!
57//! # Mixed Versions on One Port
58//!
59//! A single receiver on one UDP port handles v1, v2c, and v3 concurrently;
60//! each datagram is dispatched by its version field. Community filtering
61//! (v1/v2c) and USM users (v3) are independent and can be configured
62//! together — configuring one does not disable the other:
63//!
64//! ```rust,no_run
65//! use async_snmp::notification::NotificationReceiver;
66//! use async_snmp::{AuthProtocol, PrivProtocol};
67//!
68//! # async fn example() -> Result<(), Box<async_snmp::Error>> {
69//! let receiver = NotificationReceiver::builder()
70//!     .bind("0.0.0.0:162")
71//!     .communities(["public", "monitor"]) // gates v1/v2c
72//!     .usm_user("trapuser", |u| {          // gates v3
73//!         u.auth(AuthProtocol::Sha1, b"authpass123")
74//!          .privacy(PrivProtocol::Aes128, b"privpass123")
75//!     })
76//!     .build()
77//!     .await?;
78//! # Ok(())
79//! # }
80//! ```
81//!
82//! The two mechanisms confer very different trust (a `public` v2c trap versus
83//! an authPriv v3 trap arrive on the same socket). Each [`Notification`]
84//! variant carries how it was authenticated — the community for v1/v2c, the
85//! username and [`security_level`](Notification::security_level) for v3 — so
86//! branch on the variant when `recv` returns to apply per-version policy.
87
88mod handlers;
89mod types;
90mod varbind;
91
92use std::collections::HashMap;
93use std::net::SocketAddr;
94use std::sync::atomic::Ordering;
95use std::sync::{Arc, Mutex};
96use std::time::Instant;
97
98use bytes::Bytes;
99use subtle::ConstantTimeEq;
100use tokio::net::UdpSocket;
101use tracing::instrument;
102
103use crate::error::{Error, Result};
104use crate::message::SecurityLevel;
105use crate::oid::Oid;
106use crate::pdu::TrapV1Pdu;
107use crate::util::bind_udp_socket;
108use crate::v3::process::UsmStats;
109use crate::v3::{EngineState, SaltCounter};
110use crate::varbind::VarBind;
111use crate::version::Version;
112
113// Re-exports
114pub use types::{DerivedKeys, UsmConfig};
115pub use varbind::validate_notification_varbinds;
116
117/// Maximum number of distinct remote authoritative engines whose timeliness
118/// state is retained for trap senders. A peer holding one USM credential can
119/// authenticate under arbitrarily many fabricated engine IDs (keys are
120/// localized per engine ID), so the table is bounded and the
121/// least-recently-updated engine is evicted when full.
122const MAX_REMOTE_ENGINES: usize = 8192;
123
124/// Decide whether a v1/v2c notification carrying `community` is accepted.
125///
126/// An empty `configured` list accepts any community (filtering is opt-in).
127/// Otherwise the community must equal one of the configured strings. The
128/// comparison runs against every configured entry without early-out and uses
129/// constant-time equality (mirroring `Agent::validate_community`) so a timing
130/// side channel cannot be used to recover a valid community byte by byte.
131pub(super) fn community_allowed(configured: &[Vec<u8>], community: &[u8]) -> bool {
132    if configured.is_empty() {
133        return true;
134    }
135    let mut valid = false;
136    for candidate in configured {
137        if candidate.len() == community.len() && bool::from(candidate.as_slice().ct_eq(community)) {
138            valid = true;
139        }
140    }
141    valid
142}
143
144/// Well-known OIDs for notification varbinds.
145pub mod oids {
146    use crate::oid;
147
148    /// sysUpTime.0 - first varbind in v2c/v3 notifications
149    #[must_use]
150    pub fn sys_uptime() -> crate::Oid {
151        oid!(1, 3, 6, 1, 2, 1, 1, 3, 0)
152    }
153
154    /// snmpTrapOID.0 - second varbind in v2c/v3 notifications (contains trap type)
155    #[must_use]
156    pub fn snmp_trap_oid() -> crate::Oid {
157        oid!(1, 3, 6, 1, 6, 3, 1, 1, 4, 1, 0)
158    }
159
160    /// snmpTrapEnterprise.0 - optional, enterprise OID for enterprise-specific traps
161    #[must_use]
162    pub fn snmp_trap_enterprise() -> crate::Oid {
163        oid!(1, 3, 6, 1, 6, 3, 1, 1, 4, 3, 0)
164    }
165
166    /// snmpTrapAddress.0 - agent address from v1 trap (RFC 3584 Section 3)
167    #[must_use]
168    pub fn snmp_trap_address() -> crate::Oid {
169        oid!(1, 3, 6, 1, 6, 3, 18, 1, 3, 0)
170    }
171
172    /// Standard trap OID prefix (snmpTraps)
173    #[must_use]
174    pub fn snmp_traps() -> crate::Oid {
175        oid!(1, 3, 6, 1, 6, 3, 1, 1, 5)
176    }
177
178    /// coldStart trap OID (snmpTraps.1)
179    #[must_use]
180    pub fn cold_start() -> crate::Oid {
181        oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 1)
182    }
183
184    /// warmStart trap OID (snmpTraps.2)
185    #[must_use]
186    pub fn warm_start() -> crate::Oid {
187        oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 2)
188    }
189
190    /// linkDown trap OID (snmpTraps.3)
191    #[must_use]
192    pub fn link_down() -> crate::Oid {
193        oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 3)
194    }
195
196    /// linkUp trap OID (snmpTraps.4)
197    #[must_use]
198    pub fn link_up() -> crate::Oid {
199        oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 4)
200    }
201
202    /// authenticationFailure trap OID (snmpTraps.5)
203    #[must_use]
204    pub fn auth_failure() -> crate::Oid {
205        oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 5)
206    }
207
208    /// egpNeighborLoss trap OID (snmpTraps.6)
209    #[must_use]
210    pub fn egp_neighbor_loss() -> crate::Oid {
211        oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 6)
212    }
213}
214
215/// Builder for `NotificationReceiver`.
216///
217/// Configures the bind address, optional community filtering for v1/v2c, and
218/// USM credentials for v3. Community filtering and USM users are independent
219/// and may be combined; a single receiver then handles all versions on one
220/// port. See the [module docs](crate::notification#mixed-versions-on-one-port).
221pub struct NotificationReceiverBuilder {
222    bind_addr: String,
223    usm_users: HashMap<Bytes, UsmConfig>,
224    communities: Vec<Vec<u8>>,
225    engine_id: Option<Vec<u8>>,
226    engine_boots: u32,
227}
228
229impl NotificationReceiverBuilder {
230    /// Create a new builder with default settings.
231    ///
232    /// Defaults:
233    /// - Bind address: `0.0.0.0:162` (UDP, standard SNMP trap port)
234    /// - No USM users (v3 notifications rejected until users are added)
235    #[must_use]
236    pub fn new() -> Self {
237        Self {
238            bind_addr: "0.0.0.0:162".to_string(),
239            usm_users: HashMap::new(),
240            communities: Vec::new(),
241            engine_id: None,
242            engine_boots: 1,
243        }
244    }
245
246    /// Set the UDP bind address.
247    ///
248    /// Default is `0.0.0.0:162` (UDP, standard SNMP trap port).
249    #[must_use]
250    pub fn bind(mut self, addr: impl Into<String>) -> Self {
251        self.bind_addr = addr.into();
252        self
253    }
254
255    /// Add a USM user for V3 authentication.
256    ///
257    /// # Example
258    ///
259    /// ```rust,no_run
260    /// use async_snmp::notification::NotificationReceiver;
261    /// use async_snmp::{AuthProtocol, PrivProtocol};
262    ///
263    /// # async fn example() -> Result<(), Box<async_snmp::Error>> {
264    /// let receiver = NotificationReceiver::builder()
265    ///     .bind("0.0.0.0:162")
266    ///     .usm_user("trapuser", |u| {
267    ///         u.auth(AuthProtocol::Sha1, b"authpassword")
268    ///          .privacy(PrivProtocol::Aes128, b"privpassword")
269    ///     })
270    ///     .build()
271    ///     .await?;
272    /// # Ok(())
273    /// # }
274    /// ```
275    #[must_use]
276    pub fn usm_user<F>(mut self, username: impl Into<Bytes>, configure: F) -> Self
277    where
278        F: FnOnce(UsmConfig) -> UsmConfig,
279    {
280        let username_bytes: Bytes = username.into();
281        let config = configure(UsmConfig::new(username_bytes.clone()));
282        self.usm_users.insert(username_bytes, config);
283        self
284    }
285
286    /// Restrict accepted v1/v2c notifications to the given community string.
287    ///
288    /// Community filtering is opt-in. With no community configured the
289    /// receiver accepts v1/v2c notifications under any community and surfaces
290    /// the community on the returned [`Notification`] for caller-side policy.
291    /// Once one or more communities are configured, a v1/v2c notification
292    /// whose community matches none of them is dropped and never returned
293    /// from [`NotificationReceiver::recv`]; a dropped inform is not
294    /// acknowledged. Comparison is constant-time. This does not affect v3,
295    /// which is gated by USM.
296    ///
297    /// Call multiple times to accept several communities.
298    ///
299    /// # Example
300    ///
301    /// ```rust,no_run
302    /// use async_snmp::notification::NotificationReceiver;
303    ///
304    /// # async fn example() -> Result<(), Box<async_snmp::Error>> {
305    /// let receiver = NotificationReceiver::builder()
306    ///     .bind("0.0.0.0:162")
307    ///     .community(b"public")
308    ///     .build()
309    ///     .await?;
310    /// # Ok(())
311    /// # }
312    /// ```
313    #[must_use]
314    pub fn community(mut self, community: &[u8]) -> Self {
315        self.communities.push(community.to_vec());
316        self
317    }
318
319    /// Restrict accepted v1/v2c notifications to any of the given communities.
320    ///
321    /// Convenience for calling [`Self::community`] once per entry. See that
322    /// method for the filtering semantics.
323    ///
324    /// # Example
325    ///
326    /// ```rust,no_run
327    /// use async_snmp::notification::NotificationReceiver;
328    ///
329    /// # async fn example() -> Result<(), Box<async_snmp::Error>> {
330    /// let receiver = NotificationReceiver::builder()
331    ///     .bind("0.0.0.0:162")
332    ///     .communities(["public", "monitor"])
333    ///     .build()
334    ///     .await?;
335    /// # Ok(())
336    /// # }
337    /// ```
338    #[must_use]
339    pub fn communities<I, C>(mut self, communities: I) -> Self
340    where
341        I: IntoIterator<Item = C>,
342        C: AsRef<[u8]>,
343    {
344        for c in communities {
345            self.communities.push(c.as_ref().to_vec());
346        }
347        self
348    }
349
350    /// Set the engine ID for `SNMPv3`.
351    ///
352    /// If not set, a default engine ID will be generated based on the
353    /// RFC 3411 format using enterprise number and timestamp.
354    #[must_use]
355    pub fn engine_id(mut self, engine_id: impl Into<Vec<u8>>) -> Self {
356        self.engine_id = Some(engine_id.into());
357        self
358    }
359
360    /// Set the initial engine boots value.
361    ///
362    /// This should be persisted across restarts and incremented each time
363    /// the receiver starts. Default is 1.
364    #[must_use]
365    pub fn engine_boots(mut self, boots: u32) -> Self {
366        self.engine_boots = boots;
367        self
368    }
369
370    /// Build the notification receiver.
371    pub async fn build(self) -> Result<NotificationReceiver> {
372        let bind_addr: SocketAddr = self.bind_addr.parse().map_err(|_| {
373            Error::Config(format!("invalid bind address: {}", self.bind_addr).into())
374        })?;
375
376        let socket = bind_udp_socket(bind_addr, None, None, false)
377            .await
378            .map_err(|e| Error::Network {
379                target: bind_addr,
380                source: e,
381            })?;
382
383        let local_addr = socket.local_addr().map_err(|e| Error::Network {
384            target: bind_addr,
385            source: e,
386        })?;
387
388        let engine_id: Bytes = self.engine_id.map_or_else(
389            || {
390                let mut id = vec![0x80, 0x00, 0x00, 0x00, 0x01];
391                let timestamp = std::time::SystemTime::now()
392                    .duration_since(std::time::UNIX_EPOCH)
393                    .unwrap_or_default()
394                    .as_secs();
395                id.extend_from_slice(&timestamp.to_be_bytes());
396                Bytes::from(id)
397            },
398            Bytes::from,
399        );
400
401        Ok(NotificationReceiver {
402            inner: Arc::new(ReceiverInner {
403                socket,
404                local_addr,
405                usm_users: self.usm_users,
406                communities: self.communities,
407                engine_id,
408                salt_counter: SaltCounter::new(),
409                engine_boots_base: self.engine_boots,
410                engine_start: Instant::now(),
411                usm_stats: UsmStats::default(),
412                remote_engines: Mutex::new(HashMap::new()),
413            }),
414        })
415    }
416}
417
418impl Default for NotificationReceiverBuilder {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424/// Received SNMP notification.
425///
426/// This enum represents all types of SNMP notifications that can be received:
427/// - `SNMPv1` Trap (different PDU structure)
428/// - SNMPv2c/v3 Trap (standard PDU with sysUpTime.0 and snmpTrapOID.0)
429/// - `InformRequest` (confirmed notification, response will be sent automatically)
430#[derive(Debug, Clone)]
431pub enum Notification {
432    /// `SNMPv1` Trap with unique PDU structure.
433    TrapV1 {
434        /// Community string used for authentication
435        community: Bytes,
436        /// The trap PDU
437        trap: TrapV1Pdu,
438    },
439
440    /// `SNMPv2c` Trap (unconfirmed notification).
441    TrapV2c {
442        /// Community string used for authentication
443        community: Bytes,
444        /// sysUpTime.0 value (hundredths of seconds since agent init)
445        uptime: u32,
446        /// snmpTrapOID.0 value (trap type identifier)
447        trap_oid: Oid,
448        /// Additional variable bindings
449        varbinds: Vec<VarBind>,
450        /// Original request ID (for logging/correlation)
451        request_id: i32,
452    },
453
454    /// `SNMPv3` Trap (unconfirmed notification).
455    TrapV3 {
456        /// Username from USM
457        username: Bytes,
458        /// Context engine ID
459        context_engine_id: Bytes,
460        /// Context name
461        context_name: Bytes,
462        /// Security level the message was received at. A `NoAuthNoPriv`
463        /// notification is unauthenticated: its username is an unverified
464        /// claim. Callers requiring authentication must check this.
465        security_level: SecurityLevel,
466        /// sysUpTime.0 value
467        uptime: u32,
468        /// snmpTrapOID.0 value
469        trap_oid: Oid,
470        /// Additional variable bindings
471        varbinds: Vec<VarBind>,
472        /// Original request ID
473        request_id: i32,
474    },
475
476    /// `InformRequest` (confirmed notification) - v2c.
477    ///
478    /// A response is automatically sent when this notification is received.
479    InformV2c {
480        /// Community string
481        community: Bytes,
482        /// sysUpTime.0 value
483        uptime: u32,
484        /// snmpTrapOID.0 value
485        trap_oid: Oid,
486        /// Additional variable bindings
487        varbinds: Vec<VarBind>,
488        /// Request ID (used in response)
489        request_id: i32,
490    },
491
492    /// `InformRequest` (confirmed notification) - v3.
493    ///
494    /// A response is automatically sent when this notification is received.
495    InformV3 {
496        /// Username from USM
497        username: Bytes,
498        /// Context engine ID
499        context_engine_id: Bytes,
500        /// Context name
501        context_name: Bytes,
502        /// Security level the message was received at. A `NoAuthNoPriv`
503        /// notification is unauthenticated: its username is an unverified
504        /// claim. Callers requiring authentication must check this.
505        security_level: SecurityLevel,
506        /// sysUpTime.0 value
507        uptime: u32,
508        /// snmpTrapOID.0 value
509        trap_oid: Oid,
510        /// Additional variable bindings
511        varbinds: Vec<VarBind>,
512        /// Request ID
513        request_id: i32,
514    },
515}
516
517impl Notification {
518    /// Get the trap/notification OID.
519    ///
520    /// For `TrapV1`, this is derived from enterprise + generic/specific trap.
521    /// For v2c/v3, this is the snmpTrapOID.0 value.
522    pub fn trap_oid(&self) -> Result<Oid> {
523        match self {
524            Notification::TrapV1 { trap, .. } => trap.v2_trap_oid(),
525            Notification::TrapV2c { trap_oid, .. }
526            | Notification::TrapV3 { trap_oid, .. }
527            | Notification::InformV2c { trap_oid, .. }
528            | Notification::InformV3 { trap_oid, .. } => Ok(trap_oid.clone()),
529        }
530    }
531
532    /// Get the uptime value (sysUpTime.0 or `time_stamp` for v1).
533    pub fn uptime(&self) -> u32 {
534        match self {
535            Notification::TrapV1 { trap, .. } => trap.time_stamp,
536            Notification::TrapV2c { uptime, .. }
537            | Notification::TrapV3 { uptime, .. }
538            | Notification::InformV2c { uptime, .. }
539            | Notification::InformV3 { uptime, .. } => *uptime,
540        }
541    }
542
543    /// Get the variable bindings.
544    pub fn varbinds(&self) -> &[VarBind] {
545        match self {
546            Notification::TrapV1 { trap, .. } => &trap.varbinds,
547            Notification::TrapV2c { varbinds, .. }
548            | Notification::TrapV3 { varbinds, .. }
549            | Notification::InformV2c { varbinds, .. }
550            | Notification::InformV3 { varbinds, .. } => varbinds,
551        }
552    }
553
554    /// Get the security level the notification was received at.
555    ///
556    /// Returns `None` for v1/v2c notifications (community-based, no USM
557    /// security level). For v3 notifications, `NoAuthNoPriv` means the
558    /// message was not authenticated and its username is an unverified
559    /// claim.
560    pub fn security_level(&self) -> Option<SecurityLevel> {
561        match self {
562            Notification::TrapV1 { .. }
563            | Notification::TrapV2c { .. }
564            | Notification::InformV2c { .. } => None,
565            Notification::TrapV3 { security_level, .. }
566            | Notification::InformV3 { security_level, .. } => Some(*security_level),
567        }
568    }
569
570    /// Check if this is a confirmed notification (`InformRequest`).
571    pub fn is_confirmed(&self) -> bool {
572        matches!(
573            self,
574            Notification::InformV2c { .. } | Notification::InformV3 { .. }
575        )
576    }
577
578    /// Get the SNMP version of this notification.
579    pub fn version(&self) -> Version {
580        match self {
581            Notification::TrapV1 { .. } => Version::V1,
582            Notification::TrapV2c { .. } | Notification::InformV2c { .. } => Version::V2c,
583            Notification::TrapV3 { .. } | Notification::InformV3 { .. } => Version::V3,
584        }
585    }
586}
587
588/// SNMP Notification Receiver.
589///
590/// Listens for incoming SNMP notifications (traps and informs) on a UDP socket.
591/// For `InformRequest` notifications, automatically sends a Response-PDU.
592///
593/// # V3 Authentication
594///
595/// To receive authenticated V3 notifications, use the builder pattern to configure
596/// USM credentials:
597///
598/// ```rust,no_run
599/// use async_snmp::notification::NotificationReceiver;
600/// use async_snmp::{AuthProtocol, PrivProtocol};
601///
602/// # async fn example() -> Result<(), Box<async_snmp::Error>> {
603/// let receiver = NotificationReceiver::builder()
604///     .bind("0.0.0.0:162")
605///     .usm_user("trapuser", |u| {
606///         u.auth(AuthProtocol::Sha1, b"authpassword")
607///     })
608///     .build()
609///     .await?;
610/// # Ok(())
611/// # }
612/// ```
613pub struct NotificationReceiver {
614    inner: Arc<ReceiverInner>,
615}
616
617struct ReceiverInner {
618    socket: UdpSocket,
619    local_addr: SocketAddr,
620    /// Configured USM users for V3 authentication
621    usm_users: HashMap<Bytes, UsmConfig>,
622    /// Accepted v1/v2c community strings. Empty means accept any community
623    /// (community filtering is opt-in); otherwise a v1/v2c notification whose
624    /// community matches none of these is dropped.
625    communities: Vec<Vec<u8>>,
626    /// Engine ID for V3 discovery responses
627    engine_id: Bytes,
628    /// Salt counter for privacy operations
629    salt_counter: SaltCounter,
630    /// Initial engine boots value at startup, used to compute overflow-adjusted boots.
631    engine_boots_base: u32,
632    /// Time when the receiver was started, used to compute engine time.
633    engine_start: Instant,
634    /// RFC 3414 usmStats counters
635    usm_stats: UsmStats,
636    /// Timeliness state for remote authoritative engines (trap senders),
637    /// keyed by engine ID (RFC 3414 Section 2.3). Seeded from the first
638    /// authenticated message from each engine, so only holders of configured
639    /// credentials can add entries. Bounded to `MAX_REMOTE_ENGINES` with
640    /// least-recently-updated eviction so a credential holder cannot grow it
641    /// without limit by fabricating engine IDs.
642    remote_engines: Mutex<HashMap<Bytes, EngineState>>,
643}
644
645impl NotificationReceiver {
646    /// Create a builder for configuring the notification receiver.
647    ///
648    /// Use this to configure USM credentials for V3 authentication.
649    #[must_use]
650    pub fn builder() -> NotificationReceiverBuilder {
651        NotificationReceiverBuilder::new()
652    }
653
654    /// Bind to a local address.
655    ///
656    /// The standard SNMP notification port is 162.
657    ///
658    /// A receiver constructed this way handles v1 and v2c notifications
659    /// only: it has no USM user table, so every v3 notification (including
660    /// noAuthNoPriv) is rejected with `usmStatsUnknownUserNames` (RFC 3414
661    /// Section 3.2 Step 4). To receive v3 notifications, use
662    /// [`NotificationReceiver::builder()`] and register users with
663    /// `usm_user`.
664    ///
665    /// # Example
666    ///
667    /// ```rust,no_run
668    /// use async_snmp::notification::NotificationReceiver;
669    ///
670    /// # async fn example() -> Result<(), Box<async_snmp::Error>> {
671    /// // Bind to the standard trap port (requires root/admin on most systems)
672    /// let receiver = NotificationReceiver::bind("0.0.0.0:162").await?;
673    ///
674    /// // Or use an unprivileged port for testing
675    /// let receiver = NotificationReceiver::bind("0.0.0.0:1162").await?;
676    /// # Ok(())
677    /// # }
678    /// ```
679    pub async fn bind(addr: impl AsRef<str>) -> Result<Self> {
680        let addr_str = addr.as_ref();
681        let bind_addr: SocketAddr = addr_str
682            .parse()
683            .map_err(|_| Error::Config(format!("invalid bind address: {addr_str}").into()))?;
684
685        let socket = bind_udp_socket(bind_addr, None, None, false)
686            .await
687            .map_err(|e| Error::Network {
688                target: bind_addr,
689                source: e,
690            })?;
691
692        let local_addr = socket.local_addr().map_err(|e| Error::Network {
693            target: bind_addr,
694            source: e,
695        })?;
696
697        let engine_id: Bytes = {
698            let mut id = vec![0x80, 0x00, 0x00, 0x00, 0x01];
699            let timestamp = std::time::SystemTime::now()
700                .duration_since(std::time::UNIX_EPOCH)
701                .unwrap_or_default()
702                .as_secs();
703            id.extend_from_slice(&timestamp.to_be_bytes());
704            Bytes::from(id)
705        };
706
707        Ok(Self {
708            inner: Arc::new(ReceiverInner {
709                socket,
710                local_addr,
711                usm_users: HashMap::new(),
712                communities: Vec::new(),
713                engine_id,
714                salt_counter: SaltCounter::new(),
715                engine_boots_base: 1,
716                engine_start: Instant::now(),
717                usm_stats: UsmStats::default(),
718                remote_engines: Mutex::new(HashMap::new()),
719            }),
720        })
721    }
722
723    /// Get the local address this receiver is bound to.
724    #[must_use]
725    pub fn local_addr(&self) -> SocketAddr {
726        self.inner.local_addr
727    }
728
729    /// Get the engine ID.
730    #[must_use]
731    pub fn engine_id(&self) -> &[u8] {
732        &self.inner.engine_id
733    }
734
735    /// Get the usmStatsUnknownEngineIDs counter value.
736    #[must_use]
737    pub fn usm_unknown_engine_ids(&self) -> u32 {
738        self.inner
739            .usm_stats
740            .unknown_engine_ids
741            .load(Ordering::Relaxed)
742    }
743
744    /// Get the usmStatsUnknownUserNames counter value.
745    #[must_use]
746    pub fn usm_unknown_usernames(&self) -> u32 {
747        self.inner
748            .usm_stats
749            .unknown_usernames
750            .load(Ordering::Relaxed)
751    }
752
753    /// Get the usmStatsWrongDigests counter value.
754    #[must_use]
755    pub fn usm_wrong_digests(&self) -> u32 {
756        self.inner.usm_stats.wrong_digests.load(Ordering::Relaxed)
757    }
758
759    /// Get the usmStatsNotInTimeWindows counter value.
760    #[must_use]
761    pub fn usm_not_in_time_windows(&self) -> u32 {
762        self.inner
763            .usm_stats
764            .not_in_time_windows
765            .load(Ordering::Relaxed)
766    }
767
768    /// Get the usmStatsUnsupportedSecLevels counter value.
769    #[must_use]
770    pub fn usm_unsupported_sec_levels(&self) -> u32 {
771        self.inner
772            .usm_stats
773            .unsupported_sec_levels
774            .load(Ordering::Relaxed)
775    }
776
777    /// Get the usmStatsDecryptionErrors counter value.
778    #[must_use]
779    pub fn usm_decryption_errors(&self) -> u32 {
780        self.inner
781            .usm_stats
782            .decryption_errors
783            .load(Ordering::Relaxed)
784    }
785
786    /// Receive a notification.
787    ///
788    /// This method blocks until a notification is received. For `InformRequest`
789    /// notifications, a Response-PDU is automatically sent back to the sender.
790    ///
791    /// Returns the notification and the source address.
792    #[instrument(skip(self), err, fields(snmp.local_addr = %self.local_addr()))]
793    pub async fn recv(&self) -> Result<(Notification, SocketAddr)> {
794        let mut buf = vec![0u8; 65535];
795
796        loop {
797            let (len, source) =
798                self.inner
799                    .socket
800                    .recv_from(&mut buf)
801                    .await
802                    .map_err(|e| Error::Network {
803                        target: self.inner.local_addr,
804                        source: e,
805                    })?;
806
807            let data = Bytes::copy_from_slice(&buf[..len]);
808
809            match self.parse_and_respond(data, source).await {
810                Ok(Some(notification)) => return Ok((notification, source)),
811                Ok(None) => {} // Not a notification PDU, ignore
812                Err(e) => {
813                    // Log parsing error but continue receiving
814                    tracing::warn!(target: "async_snmp::notification", { snmp.source = %source, error = %e }, "failed to parse notification");
815                }
816            }
817        }
818    }
819
820    /// Parse received data and send response if needed.
821    ///
822    /// Returns `None` if the message is not a notification PDU.
823    async fn parse_and_respond(
824        &self,
825        data: Bytes,
826        source: SocketAddr,
827    ) -> Result<Option<Notification>> {
828        match crate::message::peek_version(data.clone(), source)? {
829            Version::V1 => self.handle_v1(data, source).await,
830            Version::V2c => self.handle_v2c(data, source).await,
831            Version::V3 => self.handle_v3(data, source).await,
832        }
833    }
834}
835
836impl Clone for NotificationReceiver {
837    fn clone(&self) -> Self {
838        Self {
839            inner: Arc::clone(&self.inner),
840        }
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::message::SecurityLevel;
848    use crate::oid;
849    use crate::pdu::GenericTrap;
850    use crate::v3::AuthProtocol;
851
852    #[test]
853    fn test_notification_trap_v1() {
854        let trap = TrapV1Pdu::new(
855            oid!(1, 3, 6, 1, 4, 1, 9999),
856            [192, 168, 1, 1],
857            GenericTrap::LinkDown,
858            0,
859            12345,
860            vec![],
861        );
862
863        let notification = Notification::TrapV1 {
864            community: Bytes::from_static(b"public"),
865            trap,
866        };
867
868        assert!(!notification.is_confirmed());
869        assert_eq!(notification.version(), Version::V1);
870        assert_eq!(notification.uptime(), 12345);
871        assert_eq!(notification.trap_oid().unwrap(), oids::link_down());
872    }
873
874    #[test]
875    fn test_notification_trap_v2c() {
876        let notification = Notification::TrapV2c {
877            community: Bytes::from_static(b"public"),
878            uptime: 54321,
879            trap_oid: oids::link_up(),
880            varbinds: vec![],
881            request_id: 1,
882        };
883
884        assert!(!notification.is_confirmed());
885        assert_eq!(notification.version(), Version::V2c);
886        assert_eq!(notification.uptime(), 54321);
887        assert_eq!(notification.trap_oid().unwrap(), oids::link_up());
888    }
889
890    #[test]
891    fn test_notification_inform() {
892        let notification = Notification::InformV2c {
893            community: Bytes::from_static(b"public"),
894            uptime: 11111,
895            trap_oid: oids::cold_start(),
896            varbinds: vec![],
897            request_id: 42,
898        };
899
900        assert!(notification.is_confirmed());
901        assert_eq!(notification.version(), Version::V2c);
902    }
903
904    #[test]
905    fn test_notification_receiver_builder_default() {
906        let builder = NotificationReceiverBuilder::new();
907        assert_eq!(builder.bind_addr, "0.0.0.0:162");
908        assert!(builder.usm_users.is_empty());
909    }
910
911    #[test]
912    fn test_notification_receiver_builder_with_user() {
913        let builder = NotificationReceiverBuilder::new()
914            .bind("0.0.0.0:1162")
915            .usm_user("trapuser", |u| u.auth(AuthProtocol::Sha1, b"authpass"));
916
917        assert_eq!(builder.bind_addr, "0.0.0.0:1162");
918        assert_eq!(builder.usm_users.len(), 1);
919
920        let user = builder
921            .usm_users
922            .get(&Bytes::from_static(b"trapuser"))
923            .unwrap();
924        assert_eq!(user.security_level(), SecurityLevel::AuthNoPriv);
925    }
926
927    #[test]
928    fn test_notification_v3_inform() {
929        let notification = Notification::InformV3 {
930            username: Bytes::from_static(b"testuser"),
931            context_engine_id: Bytes::from_static(b"engine123"),
932            context_name: Bytes::new(),
933            security_level: SecurityLevel::AuthNoPriv,
934            uptime: 99999,
935            trap_oid: oids::warm_start(),
936            varbinds: vec![],
937            request_id: 100,
938        };
939
940        assert!(notification.is_confirmed());
941        assert_eq!(notification.version(), Version::V3);
942        assert_eq!(notification.uptime(), 99999);
943        assert_eq!(notification.trap_oid().unwrap(), oids::warm_start());
944    }
945
946    #[test]
947    fn test_notification_security_level_accessor() {
948        let trap_v3 = Notification::TrapV3 {
949            username: Bytes::from_static(b"testuser"),
950            context_engine_id: Bytes::from_static(b"engine123"),
951            context_name: Bytes::new(),
952            security_level: SecurityLevel::AuthPriv,
953            uptime: 1,
954            trap_oid: oids::cold_start(),
955            varbinds: vec![],
956            request_id: 1,
957        };
958        assert_eq!(trap_v3.security_level(), Some(SecurityLevel::AuthPriv));
959
960        let inform_v3 = Notification::InformV3 {
961            username: Bytes::from_static(b"testuser"),
962            context_engine_id: Bytes::from_static(b"engine123"),
963            context_name: Bytes::new(),
964            security_level: SecurityLevel::NoAuthNoPriv,
965            uptime: 1,
966            trap_oid: oids::cold_start(),
967            varbinds: vec![],
968            request_id: 1,
969        };
970        assert_eq!(
971            inform_v3.security_level(),
972            Some(SecurityLevel::NoAuthNoPriv)
973        );
974
975        let trap_v2c = Notification::TrapV2c {
976            community: Bytes::from_static(b"public"),
977            uptime: 1,
978            trap_oid: oids::cold_start(),
979            varbinds: vec![],
980            request_id: 1,
981        };
982        assert_eq!(trap_v2c.security_level(), None);
983    }
984
985    #[test]
986    fn test_notification_trap_v1_enterprise_specific_oid() {
987        let trap = TrapV1Pdu::new(
988            oid!(1, 3, 6, 1, 4, 1, 9999, 1, 2),
989            [192, 168, 1, 1],
990            GenericTrap::EnterpriseSpecific,
991            42,
992            12345,
993            vec![],
994        );
995
996        let notification = Notification::TrapV1 {
997            community: Bytes::from_static(b"public"),
998            trap,
999        };
1000
1001        assert_eq!(
1002            notification.trap_oid().unwrap(),
1003            oid!(1, 3, 6, 1, 4, 1, 9999, 1, 2, 0, 42)
1004        );
1005    }
1006
1007    #[test]
1008    fn test_compute_engine_boots_time_basic() {
1009        let (boots, time) = crate::v3::compute_engine_boots_time(1, 1000);
1010        assert_eq!(boots, 1);
1011        assert_eq!(time, 1000);
1012    }
1013
1014    #[test]
1015    fn test_compute_engine_boots_time_zero_elapsed() {
1016        let (boots, time) = crate::v3::compute_engine_boots_time(1, 0);
1017        assert_eq!(boots, 1);
1018        assert_eq!(time, 0);
1019    }
1020
1021    #[test]
1022    fn test_builder_engine_boots_default() {
1023        let builder = NotificationReceiverBuilder::new();
1024        assert_eq!(builder.engine_boots, 1);
1025    }
1026
1027    #[test]
1028    fn test_builder_engine_boots_custom() {
1029        let builder = NotificationReceiverBuilder::new().engine_boots(5);
1030        assert_eq!(builder.engine_boots, 5);
1031    }
1032
1033    /// Build a V3 notification message of the given PDU type with the given
1034    /// `engine_boots` and `engine_time` in the USM parameters. With
1035    /// `auth: Some((password, protocol))` the message is AuthNoPriv with a
1036    /// valid HMAC; with `None` it is noAuthNoPriv.
1037    fn build_v3_notification(
1038        pdu_type: crate::pdu::PduType,
1039        engine_id: &[u8],
1040        engine_boots: u32,
1041        engine_time: u32,
1042        username: &[u8],
1043        auth: Option<(&[u8], AuthProtocol)>,
1044    ) -> Bytes {
1045        use crate::message::{MsgFlags, MsgGlobalData, ScopedPdu, V3Message};
1046        use crate::pdu::Pdu;
1047        use crate::v3::auth::authenticate_message;
1048        use crate::v3::{LocalizedKey, UsmSecurityParams};
1049        use crate::value::Value;
1050
1051        let auth_key = auth.map(|(password, protocol)| {
1052            LocalizedKey::from_password(protocol, password, engine_id).unwrap()
1053        });
1054
1055        // Build a notification PDU with sysUpTime.0 and snmpTrapOID.0
1056        let pdu = Pdu {
1057            pdu_type,
1058            request_id: 1,
1059            error_status: 0,
1060            error_index: 0,
1061            varbinds: vec![
1062                VarBind::new(oids::sys_uptime(), Value::TimeTicks(1000)),
1063                VarBind::new(
1064                    oids::snmp_trap_oid(),
1065                    Value::ObjectIdentifier(oids::cold_start()),
1066                ),
1067            ],
1068        };
1069
1070        let level = if auth_key.is_some() {
1071            SecurityLevel::AuthNoPriv
1072        } else {
1073            SecurityLevel::NoAuthNoPriv
1074        };
1075        // Informs are Confirmed Class and are sent with the reportableFlag
1076        // set; traps are Unconfirmed Class and are not (RFC 3412 Section 6.4).
1077        let reportable = pdu_type == crate::pdu::PduType::InformRequest;
1078        let global = MsgGlobalData::new(1, 65507, MsgFlags::new(level, reportable));
1079
1080        let mut usm_params = UsmSecurityParams::new(
1081            Bytes::copy_from_slice(engine_id),
1082            engine_boots,
1083            engine_time,
1084            Bytes::copy_from_slice(username),
1085        );
1086        if let Some(key) = &auth_key {
1087            usm_params = usm_params.with_auth_placeholder(key.mac_len());
1088        }
1089
1090        let scoped = ScopedPdu::new(Bytes::copy_from_slice(engine_id), Bytes::new(), pdu);
1091        let msg = V3Message::new(global, usm_params.encode(), scoped);
1092        let mut msg_bytes = msg.encode().to_vec();
1093
1094        // Compute and insert HMAC
1095        if let Some(key) = &auth_key {
1096            let (auth_offset, auth_len) =
1097                UsmSecurityParams::find_auth_params_offset(&msg_bytes).unwrap();
1098            authenticate_message(key, &mut msg_bytes, auth_offset, auth_len).unwrap();
1099        }
1100
1101        Bytes::from(msg_bytes)
1102    }
1103
1104    /// Build an authenticated V3 `InformRequest` message with the given
1105    /// `engine_boots` and `engine_time` in the USM parameters.
1106    fn build_authed_v3_inform(
1107        engine_id: &[u8],
1108        engine_boots: u32,
1109        engine_time: u32,
1110        username: &[u8],
1111        auth_password: &[u8],
1112        auth_protocol: AuthProtocol,
1113    ) -> Bytes {
1114        build_v3_notification(
1115            crate::pdu::PduType::InformRequest,
1116            engine_id,
1117            engine_boots,
1118            engine_time,
1119            username,
1120            Some((auth_password, auth_protocol)),
1121        )
1122    }
1123
1124    /// Build an authenticated V3 `SNMPv2-Trap` message with the given
1125    /// `engine_boots` and `engine_time` in the USM parameters.
1126    fn build_authed_v3_trap(engine_id: &[u8], engine_boots: u32, engine_time: u32) -> Bytes {
1127        build_v3_notification(
1128            crate::pdu::PduType::TrapV2,
1129            engine_id,
1130            engine_boots,
1131            engine_time,
1132            b"trapuser",
1133            Some((b"authpass12345678", AuthProtocol::Sha1)),
1134        )
1135    }
1136
1137    /// Build an unauthenticated (noAuthNoPriv) V3 `SNMPv2-Trap` message.
1138    fn build_noauth_v3_trap(engine_id: &[u8], username: &[u8]) -> Bytes {
1139        build_v3_notification(crate::pdu::PduType::TrapV2, engine_id, 0, 0, username, None)
1140    }
1141
1142    /// Build a receiver with its own engine ID and a `trapuser` configured,
1143    /// for tests exercising traps sent under a remote sender's engine ID.
1144    async fn remote_trap_receiver() -> NotificationReceiver {
1145        NotificationReceiver::builder()
1146            .bind("127.0.0.1:0")
1147            .engine_id(b"my-receiver-engine".to_vec())
1148            .engine_boots(1)
1149            .usm_user("trapuser", |u| {
1150                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1151            })
1152            .build()
1153            .await
1154            .unwrap()
1155    }
1156
1157    /// For traps the SENDER is the authoritative engine (RFC 3414 Section
1158    /// 1.5.1): a real remote agent sends under its own engine ID with its
1159    /// own boots/time. The receiver must accept it without being configured
1160    /// with the sender's engine ID or clock, and the delivered notification
1161    /// reports the security level it was received at (RFC 3411 Section
1162    /// 3.4.3: securityLevel accompanies every message up to the
1163    /// application).
1164    #[tokio::test]
1165    async fn test_v3_trap_from_remote_sender_engine_accepted() {
1166        let receiver = remote_trap_receiver().await;
1167        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1168
1169        // Sender's own engine ID, arbitrary boots and time
1170        let msg = build_authed_v3_trap(b"remote-sender-engine", 7, 123_456);
1171
1172        let result = receiver.handle_v3(msg, source).await.unwrap();
1173        match result {
1174            Some(Notification::TrapV3 {
1175                username,
1176                security_level,
1177                ..
1178            }) => {
1179                assert_eq!(username.as_ref(), b"trapuser");
1180                assert_eq!(security_level, SecurityLevel::AuthNoPriv);
1181            }
1182            other => panic!("expected TrapV3, got {other:?}"),
1183        }
1184    }
1185
1186    /// A noAuthNoPriv V3 trap from a configured user is delivered (no
1187    /// per-user minimum is enforced here) but must be distinguishable from
1188    /// an authenticated one via its security level.
1189    #[tokio::test]
1190    async fn test_v3_noauth_trap_carries_security_level() {
1191        let receiver = remote_trap_receiver().await;
1192        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1193
1194        let msg = build_noauth_v3_trap(b"remote-sender-engine", b"trapuser");
1195        match receiver.handle_v3(msg, source).await.unwrap() {
1196            Some(Notification::TrapV3 {
1197                security_level,
1198                username,
1199                ..
1200            }) => {
1201                assert_eq!(security_level, SecurityLevel::NoAuthNoPriv);
1202                assert_eq!(username.as_ref(), b"trapuser");
1203            }
1204            other => panic!("expected TrapV3, got {other:?}"),
1205        }
1206    }
1207
1208    /// RFC 3414 Section 3.2 Step 4 is unconditional: the user must exist in
1209    /// the local configuration regardless of security level, so a
1210    /// noAuthNoPriv message from an unknown user is dropped and counted,
1211    /// not delivered.
1212    #[tokio::test]
1213    async fn test_v3_noauth_trap_unknown_user_rejected_and_counted() {
1214        let receiver = remote_trap_receiver().await;
1215        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1216
1217        let msg = build_noauth_v3_trap(b"remote-sender-engine", b"nosuchuser");
1218        let result = receiver.handle_v3(msg, source).await.unwrap();
1219        assert!(result.is_none(), "unknown user must not be delivered");
1220        assert_eq!(receiver.usm_unknown_usernames(), 1);
1221    }
1222
1223    /// Each remote engine gets independent timeliness state: traps from
1224    /// multiple senders with unrelated boots/time are all accepted.
1225    #[tokio::test]
1226    async fn test_v3_traps_from_multiple_remote_engines_accepted() {
1227        let receiver = remote_trap_receiver().await;
1228        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1229
1230        let msg_a = build_authed_v3_trap(b"sender-engine-a", 7, 123_456);
1231        let msg_b = build_authed_v3_trap(b"sender-engine-b", 2, 42);
1232
1233        assert!(
1234            receiver.handle_v3(msg_a, source).await.unwrap().is_some(),
1235            "trap from first remote engine should be accepted"
1236        );
1237        assert!(
1238            receiver.handle_v3(msg_b, source).await.unwrap().is_some(),
1239            "trap from second remote engine should be accepted"
1240        );
1241    }
1242
1243    /// The remote-engine table is bounded: once `MAX_REMOTE_ENGINES` entries
1244    /// exist, an authenticated trap under a new engine ID evicts an old entry
1245    /// rather than growing the map, so a credential holder cannot exhaust
1246    /// memory by fabricating engine IDs.
1247    #[tokio::test]
1248    async fn test_v3_remote_engines_table_bounded() {
1249        let receiver = remote_trap_receiver().await;
1250        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1251
1252        // Pre-fill the table to capacity with cheap dummy entries.
1253        {
1254            let mut engines = receiver.inner.remote_engines.lock().unwrap();
1255            for i in 0..MAX_REMOTE_ENGINES {
1256                let id = Bytes::from(format!("dummy-engine-{i}"));
1257                engines.insert(id.clone(), EngineState::new(id, 1, 1));
1258            }
1259            assert_eq!(engines.len(), MAX_REMOTE_ENGINES);
1260        }
1261
1262        // An authenticated trap under a not-yet-seen engine ID is accepted.
1263        let msg = build_authed_v3_trap(b"fresh-remote-engine", 7, 123_456);
1264        assert!(receiver.handle_v3(msg, source).await.unwrap().is_some());
1265
1266        // The table stayed at capacity (an old entry was evicted) and the new
1267        // engine is now tracked.
1268        let engines = receiver.inner.remote_engines.lock().unwrap();
1269        assert_eq!(engines.len(), MAX_REMOTE_ENGINES);
1270        assert!(engines.contains_key(&Bytes::from_static(b"fresh-remote-engine")));
1271    }
1272
1273    /// A replayed (stale) trap from a known remote engine is rejected:
1274    /// its engine time is more than 150 seconds behind the local notion
1275    /// established by an earlier authentic message (RFC 3414 Section 3.2
1276    /// Step 7b).
1277    #[tokio::test]
1278    async fn test_v3_trap_remote_engine_stale_time_rejected() {
1279        let receiver = remote_trap_receiver().await;
1280        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1281
1282        let fresh = build_authed_v3_trap(b"remote-sender-engine", 7, 10_000);
1283        assert!(receiver.handle_v3(fresh, source).await.unwrap().is_some());
1284
1285        // Same boots, time far behind the notion just established
1286        let stale = build_authed_v3_trap(b"remote-sender-engine", 7, 5_000);
1287        assert!(
1288            receiver.handle_v3(stale, source).await.is_err(),
1289            "stale engine time should be rejected as outside the time window"
1290        );
1291    }
1292
1293    /// A trap claiming an older boot cycle than previously seen is rejected.
1294    #[tokio::test]
1295    async fn test_v3_trap_remote_engine_old_boots_rejected() {
1296        let receiver = remote_trap_receiver().await;
1297        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1298
1299        let fresh = build_authed_v3_trap(b"remote-sender-engine", 7, 10_000);
1300        assert!(receiver.handle_v3(fresh, source).await.unwrap().is_some());
1301
1302        let old_boots = build_authed_v3_trap(b"remote-sender-engine", 6, 99_999);
1303        assert!(
1304            receiver.handle_v3(old_boots, source).await.is_err(),
1305            "older boot cycle should be rejected"
1306        );
1307    }
1308
1309    /// A sender reboot (higher boots, low time) is tolerated and updates
1310    /// the local notion; the previous boot cycle is then rejected.
1311    #[tokio::test]
1312    async fn test_v3_trap_remote_engine_reboot_accepted() {
1313        let receiver = remote_trap_receiver().await;
1314        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1315
1316        let before = build_authed_v3_trap(b"remote-sender-engine", 7, 10_000);
1317        assert!(receiver.handle_v3(before, source).await.unwrap().is_some());
1318
1319        let after_reboot = build_authed_v3_trap(b"remote-sender-engine", 8, 5);
1320        assert!(
1321            receiver
1322                .handle_v3(after_reboot, source)
1323                .await
1324                .unwrap()
1325                .is_some(),
1326            "trap after sender reboot should be accepted"
1327        );
1328
1329        let from_old_cycle = build_authed_v3_trap(b"remote-sender-engine", 7, 20_000);
1330        assert!(
1331            receiver.handle_v3(from_old_cycle, source).await.is_err(),
1332            "trap from superseded boot cycle should be rejected"
1333        );
1334    }
1335
1336    /// A trap with a bad HMAC from an unknown remote engine must not seed
1337    /// timeliness state or be accepted.
1338    #[tokio::test]
1339    async fn test_v3_trap_remote_engine_bad_auth_rejected() {
1340        let receiver = remote_trap_receiver().await;
1341        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1342
1343        let msg = build_v3_notification(
1344            crate::pdu::PduType::TrapV2,
1345            b"remote-sender-engine",
1346            7,
1347            123_456,
1348            b"trapuser",
1349            Some((b"wrong-password-1234", AuthProtocol::Sha1)),
1350        );
1351        assert!(
1352            receiver.handle_v3(msg, source).await.is_err(),
1353            "trap with wrong auth key should be rejected"
1354        );
1355
1356        // A correctly authenticated trap still works afterwards
1357        let good = build_authed_v3_trap(b"remote-sender-engine", 7, 123_456);
1358        assert!(receiver.handle_v3(good, source).await.unwrap().is_some());
1359    }
1360
1361    #[tokio::test]
1362    async fn test_v3_inform_outside_time_window_rejected() {
1363        let receiver = NotificationReceiver::builder()
1364            .bind("127.0.0.1:0")
1365            .engine_id(b"test-engine".to_vec())
1366            .engine_boots(1)
1367            .usm_user("informuser", |u| {
1368                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1369            })
1370            .build()
1371            .await
1372            .unwrap();
1373
1374        let engine_id = b"test-engine";
1375        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1376
1377        // Engine time far in the future (5000 seconds, well beyond 150-second window)
1378        let msg = build_authed_v3_inform(
1379            engine_id,
1380            1,    // correct boots
1381            5000, // way outside time window (receiver started ~0 seconds ago)
1382            b"informuser",
1383            b"authpass12345678",
1384            AuthProtocol::Sha1,
1385        );
1386
1387        let result = receiver.handle_v3(msg, source).await;
1388        assert!(
1389            result.is_err(),
1390            "message with engine_time=5000 should be rejected (outside 150s window)"
1391        );
1392    }
1393
1394    #[tokio::test]
1395    async fn test_v3_inform_wrong_boots_rejected() {
1396        let receiver = NotificationReceiver::builder()
1397            .bind("127.0.0.1:0")
1398            .engine_id(b"test-engine".to_vec())
1399            .engine_boots(1)
1400            .usm_user("informuser", |u| {
1401                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1402            })
1403            .build()
1404            .await
1405            .unwrap();
1406
1407        let engine_id = b"test-engine";
1408        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1409
1410        // Wrong engine boots (receiver has boots=1)
1411        let msg = build_authed_v3_inform(
1412            engine_id,
1413            2, // wrong boots
1414            0, // time is fine
1415            b"informuser",
1416            b"authpass12345678",
1417            AuthProtocol::Sha1,
1418        );
1419
1420        let result = receiver.handle_v3(msg, source).await;
1421        assert!(
1422            result.is_err(),
1423            "message with wrong engine_boots should be rejected"
1424        );
1425    }
1426
1427    #[tokio::test]
1428    async fn test_v3_inform_within_time_window_accepted() {
1429        let receiver = NotificationReceiver::builder()
1430            .bind("127.0.0.1:0")
1431            .engine_id(b"test-engine".to_vec())
1432            .engine_boots(1)
1433            .usm_user("informuser", |u| {
1434                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1435            })
1436            .build()
1437            .await
1438            .unwrap();
1439
1440        let engine_id = b"test-engine";
1441        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1442
1443        // Engine time within the window (receiver started ~0 seconds ago, engine_time=0 is fine)
1444        let msg = build_authed_v3_inform(
1445            engine_id,
1446            1, // correct boots
1447            0, // within window
1448            b"informuser",
1449            b"authpass12345678",
1450            AuthProtocol::Sha1,
1451        );
1452
1453        let result = receiver.handle_v3(msg, source).await;
1454        // Should succeed (or at least not fail due to time window).
1455        // The Inform response send will fail since source is fake, but
1456        // the time window check itself should pass. The error if any
1457        // should be a network error from trying to send the response,
1458        // not an Auth error.
1459        match result {
1460            Ok(Some(_)) => {} // unexpected but ok (socket might succeed on loopback)
1461            Err(e) => {
1462                let err_str = format!("{e}");
1463                assert!(
1464                    !err_str.contains("Auth"),
1465                    "should not be an auth error for valid time window, got: {err_str}"
1466                );
1467            }
1468            Ok(None) => panic!("should not return None for a valid InformRequest"),
1469        }
1470    }
1471
1472    /// Build a V3 discovery request message (empty engine ID, noAuthNoPriv).
1473    fn build_v3_discovery_request(msg_id: i32, reportable: bool) -> Bytes {
1474        use crate::message::{MsgFlags, MsgGlobalData, ScopedPdu, V3Message};
1475        use crate::pdu::{Pdu, PduType};
1476        use crate::v3::UsmSecurityParams;
1477
1478        let pdu = Pdu {
1479            pdu_type: PduType::GetRequest,
1480            request_id: 0,
1481            error_status: 0,
1482            error_index: 0,
1483            varbinds: vec![],
1484        };
1485
1486        let global = MsgGlobalData::new(
1487            msg_id,
1488            65507,
1489            MsgFlags::new(SecurityLevel::NoAuthNoPriv, reportable),
1490        );
1491
1492        let usm_params = UsmSecurityParams::new(
1493            Bytes::new(), // empty engine ID = discovery
1494            0,
1495            0,
1496            Bytes::new(), // empty username
1497        );
1498
1499        let scoped = ScopedPdu::new(Bytes::new(), Bytes::new(), pdu);
1500        let msg = V3Message::new(global, usm_params.encode(), scoped);
1501        msg.encode()
1502    }
1503
1504    #[tokio::test]
1505    async fn test_v3_discovery_gets_response() {
1506        use crate::message::V3Message;
1507        use crate::v3::UsmSecurityParams;
1508        use crate::value::Value;
1509
1510        let receiver = NotificationReceiver::builder()
1511            .bind("127.0.0.1:0")
1512            .engine_id(b"test-discovery-engine".to_vec())
1513            .build()
1514            .await
1515            .unwrap();
1516
1517        // Bind a separate socket to receive the Report; handle_v3 is called
1518        // directly with this socket's address as source.
1519        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1520        let client_addr = client.local_addr().unwrap();
1521
1522        let discovery_msg = build_v3_discovery_request(42, true);
1523        let result = receiver.handle_v3(discovery_msg, client_addr).await;
1524
1525        // Discovery should return Ok(None) - not a notification
1526        assert!(result.is_ok());
1527        assert!(result.unwrap().is_none());
1528
1529        // Counter should be incremented
1530        assert_eq!(receiver.usm_unknown_engine_ids(), 1);
1531
1532        // The Report must carry usmStatsUnknownEngineIDs with the counter
1533        // value and the receiver's engine ID (RFC 3414 Section 4).
1534        let mut buf = vec![0u8; 4096];
1535        let (len, _) = tokio::time::timeout(
1536            std::time::Duration::from_secs(1),
1537            client.recv_from(&mut buf),
1538        )
1539        .await
1540        .expect("expected a discovery Report")
1541        .unwrap();
1542
1543        let report = V3Message::decode(Bytes::copy_from_slice(&buf[..len])).unwrap();
1544        assert_eq!(
1545            report.global_data.msg_flags.security_level,
1546            SecurityLevel::NoAuthNoPriv
1547        );
1548        let report_usm = UsmSecurityParams::decode(report.security_params.clone()).unwrap();
1549        assert_eq!(report_usm.engine_id.as_ref(), b"test-discovery-engine");
1550        let scoped = report.scoped_pdu().expect("report should be plaintext");
1551        assert_eq!(scoped.pdu.pdu_type, crate::pdu::PduType::Report);
1552        assert_eq!(
1553            scoped.pdu.varbinds[0].oid,
1554            crate::v3::report_oids::unknown_engine_ids()
1555        );
1556        assert_eq!(scoped.pdu.varbinds[0].value, Value::Counter32(1));
1557    }
1558
1559    #[tokio::test]
1560    async fn test_v3_discovery_non_reportable_ignored() {
1561        let receiver = NotificationReceiver::builder()
1562            .bind("127.0.0.1:0")
1563            .engine_id(b"test-discovery-engine".to_vec())
1564            .build()
1565            .await
1566            .unwrap();
1567
1568        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1569        let discovery_msg = build_v3_discovery_request(42, false);
1570
1571        let result = receiver.handle_v3(discovery_msg, source).await;
1572
1573        // A non-reportable message with an unknown (empty) engine ID gets no
1574        // response, but the counter tracks the occurrence like every other
1575        // usmStats counter.
1576        assert!(result.is_ok());
1577        assert!(result.unwrap().is_none());
1578        assert_eq!(receiver.usm_unknown_engine_ids(), 1);
1579    }
1580
1581    /// A message under an engine ID other than the receiver's own is treated
1582    /// as coming from a remote authoritative engine (RFC 3414 Section 3.2
1583    /// Step 7b): authentication uses keys localized to that engine ID and
1584    /// timeliness uses per-engine state, so it is accepted rather than
1585    /// requiring the receiver to be configured with the sender's engine ID.
1586    #[tokio::test]
1587    async fn test_v3_inform_under_remote_engine_id_accepted() {
1588        let receiver = NotificationReceiver::builder()
1589            .bind("127.0.0.1:0")
1590            .engine_id(b"my-receiver-engine".to_vec())
1591            .engine_boots(1)
1592            .usm_user("informuser", |u| {
1593                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1594            })
1595            .build()
1596            .await
1597            .unwrap();
1598
1599        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1600
1601        // Build a message with a DIFFERENT engine ID
1602        let msg = build_authed_v3_inform(
1603            b"remote-engine-id",
1604            1,
1605            0,
1606            b"informuser",
1607            b"authpass12345678",
1608            AuthProtocol::Sha1,
1609        );
1610
1611        let result = receiver.handle_v3(msg, source).await.unwrap();
1612        assert!(
1613            matches!(result, Some(Notification::InformV3 { .. })),
1614            "authenticated inform under a remote engine ID should be accepted, got {result:?}"
1615        );
1616    }
1617
1618    #[test]
1619    fn test_auto_generated_engine_id_non_empty() {
1620        let builder = NotificationReceiverBuilder::new();
1621        // engine_id field should be None (auto-generate on build)
1622        assert!(builder.engine_id.is_none());
1623    }
1624
1625    #[tokio::test]
1626    async fn test_bind_generates_engine_id() {
1627        let receiver = NotificationReceiver::bind("127.0.0.1:0").await.unwrap();
1628        assert!(!receiver.engine_id().is_empty());
1629        // RFC 3411 format: starts with 0x80 enterprise indicator
1630        assert_eq!(receiver.engine_id()[0], 0x80);
1631    }
1632
1633    #[tokio::test]
1634    async fn test_builder_generates_engine_id() {
1635        let receiver = NotificationReceiver::builder()
1636            .bind("127.0.0.1:0")
1637            .build()
1638            .await
1639            .unwrap();
1640        assert!(!receiver.engine_id().is_empty());
1641        assert_eq!(receiver.engine_id()[0], 0x80);
1642    }
1643
1644    #[tokio::test]
1645    async fn test_builder_custom_engine_id() {
1646        let receiver = NotificationReceiver::builder()
1647            .bind("127.0.0.1:0")
1648            .engine_id(b"custom-engine".to_vec())
1649            .build()
1650            .await
1651            .unwrap();
1652        assert_eq!(receiver.engine_id(), b"custom-engine");
1653    }
1654
1655    #[tokio::test]
1656    async fn test_usm_counter_accessors_default_zero() {
1657        let receiver = remote_trap_receiver().await;
1658        assert_eq!(receiver.usm_unknown_engine_ids(), 0);
1659        assert_eq!(receiver.usm_unknown_usernames(), 0);
1660        assert_eq!(receiver.usm_wrong_digests(), 0);
1661        assert_eq!(receiver.usm_not_in_time_windows(), 0);
1662        assert_eq!(receiver.usm_unsupported_sec_levels(), 0);
1663        assert_eq!(receiver.usm_decryption_errors(), 0);
1664    }
1665
1666    /// RFC 3414 Section 3.2 Step 6: a failed HMAC increments
1667    /// usmStatsWrongDigests.
1668    #[tokio::test]
1669    async fn test_v3_trap_wrong_digest_increments_counter() {
1670        let receiver = remote_trap_receiver().await;
1671        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1672
1673        let msg = build_v3_notification(
1674            crate::pdu::PduType::TrapV2,
1675            b"remote-sender-engine",
1676            7,
1677            123_456,
1678            b"trapuser",
1679            Some((b"wrong-password-1234", AuthProtocol::Sha1)),
1680        );
1681        assert!(receiver.handle_v3(msg, source).await.is_err());
1682        assert_eq!(receiver.usm_wrong_digests(), 1);
1683    }
1684
1685    /// RFC 3414 Section 3.2 Step 4: an authenticated message for a user not
1686    /// in the local configuration increments usmStatsUnknownUserNames.
1687    #[tokio::test]
1688    async fn test_v3_trap_unknown_user_increments_counter() {
1689        let receiver = remote_trap_receiver().await;
1690        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1691
1692        let msg = build_v3_notification(
1693            crate::pdu::PduType::TrapV2,
1694            b"remote-sender-engine",
1695            7,
1696            123_456,
1697            b"nosuchuser",
1698            Some((b"authpass12345678", AuthProtocol::Sha1)),
1699        );
1700        let result = receiver.handle_v3(msg, source).await.unwrap();
1701        assert!(result.is_none(), "unknown user must not be delivered");
1702        assert_eq!(receiver.usm_unknown_usernames(), 1);
1703        assert_eq!(receiver.usm_wrong_digests(), 0);
1704    }
1705
1706    /// RFC 3414 Section 3.2 Step 5: an authenticated message for a user
1707    /// configured without an auth key increments
1708    /// usmStatsUnsupportedSecLevels.
1709    #[tokio::test]
1710    async fn test_v3_trap_user_without_auth_key_increments_counter() {
1711        let receiver = NotificationReceiver::builder()
1712            .bind("127.0.0.1:0")
1713            .engine_id(b"my-receiver-engine".to_vec())
1714            .usm_user("plainuser", |u| u)
1715            .build()
1716            .await
1717            .unwrap();
1718        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1719
1720        let msg = build_v3_notification(
1721            crate::pdu::PduType::TrapV2,
1722            b"remote-sender-engine",
1723            7,
1724            123_456,
1725            b"plainuser",
1726            Some((b"authpass12345678", AuthProtocol::Sha1)),
1727        );
1728        let result = receiver.handle_v3(msg, source).await.unwrap();
1729        assert!(result.is_none());
1730        assert_eq!(receiver.usm_unsupported_sec_levels(), 1);
1731        assert_eq!(receiver.usm_unknown_usernames(), 0);
1732    }
1733
1734    /// RFC 3414 Section 3.2 Step 7a: an inform under the receiver's engine ID
1735    /// outside the time window increments usmStatsNotInTimeWindows.
1736    #[tokio::test]
1737    async fn test_v3_inform_time_window_failure_increments_counter() {
1738        let receiver = NotificationReceiver::builder()
1739            .bind("127.0.0.1:0")
1740            .engine_id(b"test-engine".to_vec())
1741            .engine_boots(1)
1742            .usm_user("informuser", |u| {
1743                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1744            })
1745            .build()
1746            .await
1747            .unwrap();
1748        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1749
1750        let msg = build_authed_v3_inform(
1751            b"test-engine",
1752            1,
1753            5000,
1754            b"informuser",
1755            b"authpass12345678",
1756            AuthProtocol::Sha1,
1757        );
1758        assert!(receiver.handle_v3(msg, source).await.is_err());
1759        assert_eq!(receiver.usm_not_in_time_windows(), 1);
1760    }
1761
1762    /// RFC 3414 Section 3.2 Step 7b: when the sender is the authoritative
1763    /// engine, a timeliness failure is a bare error indication.
1764    /// usmStatsNotInTimeWindows and its Report belong to the authoritative
1765    /// case (Step 7a) only, matching net-snmp's
1766    /// usm_check_and_update_timeliness.
1767    #[tokio::test]
1768    async fn test_v3_trap_remote_stale_not_counted() {
1769        let receiver = remote_trap_receiver().await;
1770        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1771
1772        let fresh = build_authed_v3_trap(b"remote-sender-engine", 7, 10_000);
1773        assert!(receiver.handle_v3(fresh, source).await.unwrap().is_some());
1774
1775        let stale = build_authed_v3_trap(b"remote-sender-engine", 7, 5_000);
1776        assert!(receiver.handle_v3(stale, source).await.is_err());
1777        assert_eq!(receiver.usm_not_in_time_windows(), 0);
1778    }
1779
1780    /// A stale inform under a remote sender's engine ID (Step 7b) gets no
1781    /// notInTimeWindows Report even though its reportableFlag is set: the
1782    /// receiver is not authoritative for that engine's clock.
1783    #[tokio::test]
1784    async fn test_v3_inform_remote_stale_gets_no_report() {
1785        let receiver = remote_trap_receiver().await;
1786
1787        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1788        let client_addr = client.local_addr().unwrap();
1789
1790        let fresh = build_v3_notification(
1791            crate::pdu::PduType::InformRequest,
1792            b"remote-sender-engine",
1793            7,
1794            10_000,
1795            b"trapuser",
1796            Some((b"authpass12345678", AuthProtocol::Sha1)),
1797        );
1798        assert!(
1799            receiver
1800                .handle_v3(fresh, client_addr)
1801                .await
1802                .unwrap()
1803                .is_some()
1804        );
1805
1806        // Drain the inform acknowledgement.
1807        let mut buf = vec![0u8; 4096];
1808        tokio::time::timeout(
1809            std::time::Duration::from_secs(1),
1810            client.recv_from(&mut buf),
1811        )
1812        .await
1813        .expect("expected the inform response")
1814        .unwrap();
1815
1816        let stale = build_v3_notification(
1817            crate::pdu::PduType::InformRequest,
1818            b"remote-sender-engine",
1819            7,
1820            5_000,
1821            b"trapuser",
1822            Some((b"authpass12345678", AuthProtocol::Sha1)),
1823        );
1824        assert!(receiver.handle_v3(stale, client_addr).await.is_err());
1825        assert_eq!(receiver.usm_not_in_time_windows(), 0);
1826
1827        let result = tokio::time::timeout(
1828            std::time::Duration::from_millis(200),
1829            client.recv_from(&mut buf),
1830        )
1831        .await;
1832        assert!(
1833            result.is_err(),
1834            "no Report may be sent for a Step 7b timeliness failure"
1835        );
1836    }
1837
1838    /// Build an authPriv V3 trap for the given username, HMAC'd with the
1839    /// given password, with undecryptable privacy parameters (wrong salt
1840    /// length) and garbage ciphertext.
1841    fn build_v3_trap_bad_ciphertext(
1842        engine_id: &[u8],
1843        username: &[u8],
1844        auth_password: &[u8],
1845    ) -> Bytes {
1846        use crate::message::{MsgFlags, MsgGlobalData, V3Message};
1847        use crate::v3::auth::authenticate_message;
1848        use crate::v3::{LocalizedKey, UsmSecurityParams};
1849
1850        let auth_key =
1851            LocalizedKey::from_password(AuthProtocol::Sha1, auth_password, engine_id).unwrap();
1852
1853        let global = MsgGlobalData::new(1, 65507, MsgFlags::new(SecurityLevel::AuthPriv, false));
1854        let usm_params = UsmSecurityParams::new(
1855            Bytes::copy_from_slice(engine_id),
1856            7,
1857            123_456,
1858            Bytes::copy_from_slice(username),
1859        )
1860        .with_auth_placeholder(auth_key.mac_len())
1861        .with_priv_params(Bytes::from_static(b"bad"));
1862
1863        let msg = V3Message::new_encrypted(
1864            global,
1865            usm_params.encode(),
1866            Bytes::from_static(b"not-a-valid-ciphertext"),
1867        );
1868        let mut msg_bytes = msg.encode().to_vec();
1869        let (auth_offset, auth_len) =
1870            UsmSecurityParams::find_auth_params_offset(&msg_bytes).unwrap();
1871        authenticate_message(&auth_key, &mut msg_bytes, auth_offset, auth_len).unwrap();
1872        Bytes::from(msg_bytes)
1873    }
1874
1875    /// RFC 3414 Section 3.2 Step 8: a decryption failure increments
1876    /// usmStatsDecryptionErrors.
1877    #[tokio::test]
1878    async fn test_v3_decryption_error_increments_counter() {
1879        let receiver = NotificationReceiver::builder()
1880            .bind("127.0.0.1:0")
1881            .engine_id(b"my-receiver-engine".to_vec())
1882            .usm_user("privuser", |u| {
1883                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1884                    .privacy(crate::v3::PrivProtocol::Aes128, b"privpass12345678")
1885            })
1886            .build()
1887            .await
1888            .unwrap();
1889        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1890
1891        let msg =
1892            build_v3_trap_bad_ciphertext(b"remote-sender-engine", b"privuser", b"authpass12345678");
1893        assert!(receiver.handle_v3(msg, source).await.is_err());
1894        assert_eq!(receiver.usm_decryption_errors(), 1);
1895    }
1896
1897    /// RFC 3414 Section 3.2 Step 5 precedes Step 6: an authPriv message for
1898    /// a user configured without privacy increments
1899    /// usmStatsUnsupportedSecLevels even when its HMAC is invalid, not
1900    /// usmStatsWrongDigests.
1901    #[tokio::test]
1902    async fn test_v3_authpriv_for_auth_only_user_counts_unsupported_sec_level() {
1903        let receiver = remote_trap_receiver().await;
1904        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
1905
1906        let msg = build_v3_trap_bad_ciphertext(
1907            b"remote-sender-engine",
1908            b"trapuser",
1909            b"wrong-password-1234",
1910        );
1911        let result = receiver.handle_v3(msg, source).await.unwrap();
1912        assert!(result.is_none());
1913        assert_eq!(receiver.usm_unsupported_sec_levels(), 1);
1914        assert_eq!(receiver.usm_wrong_digests(), 0);
1915    }
1916
1917    /// A USM-failed inform (Confirmed Class, reportableFlag set) gets a
1918    /// Report back (RFC 3412 Section 7.1 Step 3). The notInTimeWindows
1919    /// report carries the receiver's engine ID/boots/time for time
1920    /// resynchronization and is authenticated at authNoPriv
1921    /// (RFC 3414 Section 3.2 Step 7).
1922    #[tokio::test]
1923    async fn test_v3_failed_inform_gets_authenticated_time_window_report() {
1924        use crate::message::V3Message;
1925        use crate::v3::auth::verify_message;
1926        use crate::v3::{LocalizedKey, UsmSecurityParams};
1927
1928        let receiver = NotificationReceiver::builder()
1929            .bind("127.0.0.1:0")
1930            .engine_id(b"test-engine".to_vec())
1931            .engine_boots(1)
1932            .usm_user("informuser", |u| {
1933                u.auth(AuthProtocol::Sha1, b"authpass12345678")
1934            })
1935            .build()
1936            .await
1937            .unwrap();
1938
1939        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1940        let client_addr = client.local_addr().unwrap();
1941
1942        let msg = build_authed_v3_inform(
1943            b"test-engine",
1944            1,
1945            5000, // outside the 150s window
1946            b"informuser",
1947            b"authpass12345678",
1948            AuthProtocol::Sha1,
1949        );
1950        assert!(receiver.handle_v3(msg, client_addr).await.is_err());
1951
1952        let mut buf = vec![0u8; 4096];
1953        let (len, _) = tokio::time::timeout(
1954            std::time::Duration::from_secs(1),
1955            client.recv_from(&mut buf),
1956        )
1957        .await
1958        .expect("expected a Report in response to the failed inform")
1959        .unwrap();
1960        let report_bytes = Bytes::copy_from_slice(&buf[..len]);
1961
1962        let report = V3Message::decode(report_bytes.clone()).unwrap();
1963        assert_eq!(
1964            report.global_data.msg_flags.security_level,
1965            SecurityLevel::AuthNoPriv,
1966            "notInTimeWindows report must be authenticated (authNoPriv)"
1967        );
1968        assert!(!report.global_data.msg_flags.reportable);
1969
1970        let report_usm = UsmSecurityParams::decode(report.security_params.clone()).unwrap();
1971        assert_eq!(report_usm.engine_id.as_ref(), b"test-engine");
1972
1973        // The HMAC must verify with the user's key localized to the
1974        // receiver's engine ID.
1975        let key =
1976            LocalizedKey::from_password(AuthProtocol::Sha1, b"authpass12345678", b"test-engine")
1977                .unwrap();
1978        let (auth_offset, auth_len) =
1979            UsmSecurityParams::find_auth_params_offset(&report_bytes).unwrap();
1980        assert!(verify_message(&key, &report_bytes, auth_offset, auth_len).unwrap());
1981
1982        let scoped = report.scoped_pdu().expect("report should be plaintext");
1983        assert_eq!(scoped.pdu.pdu_type, crate::pdu::PduType::Report);
1984        assert_eq!(
1985            scoped.pdu.varbinds[0].oid,
1986            crate::v3::report_oids::not_in_time_windows()
1987        );
1988    }
1989
1990    /// RFC 3414 Section 3.2 Step 7a lists latched engine boots as a Time
1991    /// Window failure and mandates the report be authenticated at
1992    /// authNoPriv, like the other notInTimeWindows reports.
1993    #[tokio::test]
1994    async fn test_v3_latched_boots_report_is_authenticated() {
1995        use crate::message::V3Message;
1996        use crate::v3::MAX_ENGINE_TIME;
1997        use crate::v3::auth::verify_message;
1998        use crate::v3::{LocalizedKey, UsmSecurityParams};
1999
2000        let receiver = NotificationReceiver::builder()
2001            .bind("127.0.0.1:0")
2002            .engine_id(b"test-engine".to_vec())
2003            .engine_boots(MAX_ENGINE_TIME)
2004            .usm_user("informuser", |u| {
2005                u.auth(AuthProtocol::Sha1, b"authpass12345678")
2006            })
2007            .build()
2008            .await
2009            .unwrap();
2010
2011        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
2012        let client_addr = client.local_addr().unwrap();
2013
2014        let msg = build_authed_v3_inform(
2015            b"test-engine",
2016            MAX_ENGINE_TIME,
2017            0,
2018            b"informuser",
2019            b"authpass12345678",
2020            AuthProtocol::Sha1,
2021        );
2022        assert!(receiver.handle_v3(msg, client_addr).await.is_err());
2023        assert_eq!(receiver.usm_not_in_time_windows(), 1);
2024
2025        let mut buf = vec![0u8; 4096];
2026        let (len, _) = tokio::time::timeout(
2027            std::time::Duration::from_secs(1),
2028            client.recv_from(&mut buf),
2029        )
2030        .await
2031        .expect("expected a Report in response to the failed inform")
2032        .unwrap();
2033        let report_bytes = Bytes::copy_from_slice(&buf[..len]);
2034
2035        let report = V3Message::decode(report_bytes.clone()).unwrap();
2036        assert_eq!(
2037            report.global_data.msg_flags.security_level,
2038            SecurityLevel::AuthNoPriv,
2039            "notInTimeWindows report must be authenticated (authNoPriv)"
2040        );
2041        let key =
2042            LocalizedKey::from_password(AuthProtocol::Sha1, b"authpass12345678", b"test-engine")
2043                .unwrap();
2044        let (auth_offset, auth_len) =
2045            UsmSecurityParams::find_auth_params_offset(&report_bytes).unwrap();
2046        assert!(verify_message(&key, &report_bytes, auth_offset, auth_len).unwrap());
2047
2048        let scoped = report.scoped_pdu().expect("report should be plaintext");
2049        assert_eq!(
2050            scoped.pdu.varbinds[0].oid,
2051            crate::v3::report_oids::not_in_time_windows()
2052        );
2053    }
2054
2055    /// A USM-failed inform for an unknown user gets an unauthenticated
2056    /// Report (no key exists to authenticate it with).
2057    #[tokio::test]
2058    async fn test_v3_failed_inform_unknown_user_gets_noauth_report() {
2059        use crate::message::V3Message;
2060        use crate::v3::UsmSecurityParams;
2061
2062        let receiver = NotificationReceiver::builder()
2063            .bind("127.0.0.1:0")
2064            .engine_id(b"test-engine".to_vec())
2065            .engine_boots(1)
2066            .usm_user("informuser", |u| {
2067                u.auth(AuthProtocol::Sha1, b"authpass12345678")
2068            })
2069            .build()
2070            .await
2071            .unwrap();
2072
2073        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
2074        let client_addr = client.local_addr().unwrap();
2075
2076        let msg = build_authed_v3_inform(
2077            b"test-engine",
2078            1,
2079            0,
2080            b"nosuchuser",
2081            b"authpass12345678",
2082            AuthProtocol::Sha1,
2083        );
2084        let result = receiver.handle_v3(msg, client_addr).await.unwrap();
2085        assert!(result.is_none());
2086        assert_eq!(receiver.usm_unknown_usernames(), 1);
2087
2088        let mut buf = vec![0u8; 4096];
2089        let (len, _) = tokio::time::timeout(
2090            std::time::Duration::from_secs(1),
2091            client.recv_from(&mut buf),
2092        )
2093        .await
2094        .expect("expected a Report in response to the failed inform")
2095        .unwrap();
2096
2097        let report = V3Message::decode(Bytes::copy_from_slice(&buf[..len])).unwrap();
2098        assert_eq!(
2099            report.global_data.msg_flags.security_level,
2100            SecurityLevel::NoAuthNoPriv
2101        );
2102        let report_usm = UsmSecurityParams::decode(report.security_params.clone()).unwrap();
2103        assert_eq!(report_usm.engine_id.as_ref(), b"test-engine");
2104        let scoped = report.scoped_pdu().expect("report should be plaintext");
2105        assert_eq!(scoped.pdu.pdu_type, crate::pdu::PduType::Report);
2106        assert_eq!(
2107            scoped.pdu.varbinds[0].oid,
2108            crate::v3::report_oids::unknown_user_names()
2109        );
2110    }
2111
2112    /// A USM-failed trap must NOT get a Report: traps are Unconfirmed Class
2113    /// and carry reportableFlag=0 (RFC 3412 Sections 6.4 and 7.1 Step 3).
2114    #[tokio::test]
2115    async fn test_v3_failed_trap_gets_no_report() {
2116        let receiver = remote_trap_receiver().await;
2117
2118        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
2119        let client_addr = client.local_addr().unwrap();
2120
2121        let msg = build_v3_notification(
2122            crate::pdu::PduType::TrapV2,
2123            b"remote-sender-engine",
2124            7,
2125            123_456,
2126            b"trapuser",
2127            Some((b"wrong-password-1234", AuthProtocol::Sha1)),
2128        );
2129        assert!(receiver.handle_v3(msg, client_addr).await.is_err());
2130        assert_eq!(receiver.usm_wrong_digests(), 1);
2131
2132        let mut buf = vec![0u8; 4096];
2133        let result = tokio::time::timeout(
2134            std::time::Duration::from_millis(200),
2135            client.recv_from(&mut buf),
2136        )
2137        .await;
2138        assert!(result.is_err(), "no Report may be sent for a failed trap");
2139    }
2140
2141    #[test]
2142    fn test_community_allowed() {
2143        // Empty allowlist accepts any community (opt-in filtering).
2144        assert!(community_allowed(&[], b"public"));
2145        assert!(community_allowed(&[], b""));
2146
2147        let configured = vec![b"public".to_vec(), b"monitor".to_vec()];
2148        assert!(community_allowed(&configured, b"public"));
2149        assert!(community_allowed(&configured, b"monitor"));
2150        // Non-matching, prefix, and length-mismatch are all rejected.
2151        assert!(!community_allowed(&configured, b"private"));
2152        assert!(!community_allowed(&configured, b"pub"));
2153        assert!(!community_allowed(&configured, b"publicx"));
2154        assert!(!community_allowed(&configured, b""));
2155    }
2156
2157    fn build_v2c_trap(community: &[u8]) -> Bytes {
2158        use crate::message::CommunityMessage;
2159        use crate::pdu::Pdu;
2160        let pdu = Pdu::trap_v2(1, 100, &oids::cold_start(), vec![]);
2161        CommunityMessage::v2c(Bytes::copy_from_slice(community), pdu).encode()
2162    }
2163
2164    fn build_v2c_inform(community: &[u8]) -> Bytes {
2165        use crate::message::CommunityMessage;
2166        use crate::pdu::Pdu;
2167        let pdu = Pdu::inform_request(1, 100, &oids::cold_start(), vec![]);
2168        CommunityMessage::v2c(Bytes::copy_from_slice(community), pdu).encode()
2169    }
2170
2171    fn build_v1_trap(community: &[u8]) -> Bytes {
2172        use crate::message::CommunityMessage;
2173        use crate::pdu::GenericTrap;
2174        let trap = TrapV1Pdu::new(
2175            oid!(1, 3, 6, 1, 4, 1, 9999),
2176            [192, 168, 1, 1],
2177            GenericTrap::ColdStart,
2178            0,
2179            12345,
2180            vec![],
2181        );
2182        CommunityMessage::v1_trap(Bytes::copy_from_slice(community), trap).encode()
2183    }
2184
2185    #[tokio::test]
2186    async fn test_v2c_trap_matching_community_accepted() {
2187        let receiver = NotificationReceiver::builder()
2188            .bind("127.0.0.1:0")
2189            .community(b"public")
2190            .build()
2191            .await
2192            .unwrap();
2193        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2194
2195        let result = receiver
2196            .handle_v2c(build_v2c_trap(b"public"), source)
2197            .await
2198            .unwrap();
2199        assert!(matches!(result, Some(Notification::TrapV2c { .. })));
2200    }
2201
2202    #[tokio::test]
2203    async fn test_v2c_trap_wrong_community_dropped() {
2204        let receiver = NotificationReceiver::builder()
2205            .bind("127.0.0.1:0")
2206            .community(b"public")
2207            .build()
2208            .await
2209            .unwrap();
2210        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2211
2212        let result = receiver
2213            .handle_v2c(build_v2c_trap(b"private"), source)
2214            .await
2215            .unwrap();
2216        assert!(result.is_none());
2217    }
2218
2219    #[tokio::test]
2220    async fn test_v2c_trap_no_allowlist_accepts_any_community() {
2221        let receiver = NotificationReceiver::bind("127.0.0.1:0").await.unwrap();
2222        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2223
2224        let result = receiver
2225            .handle_v2c(build_v2c_trap(b"anything"), source)
2226            .await
2227            .unwrap();
2228        assert!(matches!(result, Some(Notification::TrapV2c { .. })));
2229    }
2230
2231    #[tokio::test]
2232    async fn test_v1_trap_wrong_community_dropped() {
2233        let receiver = NotificationReceiver::builder()
2234            .bind("127.0.0.1:0")
2235            .community(b"public")
2236            .build()
2237            .await
2238            .unwrap();
2239        let source: SocketAddr = "127.0.0.1:9999".parse().unwrap();
2240
2241        assert!(
2242            receiver
2243                .handle_v1(build_v1_trap(b"private"), source)
2244                .await
2245                .unwrap()
2246                .is_none()
2247        );
2248        assert!(matches!(
2249            receiver
2250                .handle_v1(build_v1_trap(b"public"), source)
2251                .await
2252                .unwrap(),
2253            Some(Notification::TrapV1 { .. })
2254        ));
2255    }
2256
2257    /// An inform rejected by the community filter is dropped before the ack is
2258    /// built, so no Response datagram is sent to the source.
2259    #[tokio::test]
2260    async fn test_v2c_inform_wrong_community_dropped_without_ack() {
2261        let receiver = NotificationReceiver::builder()
2262            .bind("127.0.0.1:0")
2263            .community(b"public")
2264            .build()
2265            .await
2266            .unwrap();
2267
2268        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
2269        let client_addr = client.local_addr().unwrap();
2270
2271        let result = receiver
2272            .handle_v2c(build_v2c_inform(b"private"), client_addr)
2273            .await
2274            .unwrap();
2275        assert!(result.is_none());
2276
2277        let mut buf = vec![0u8; 4096];
2278        let recv = tokio::time::timeout(
2279            std::time::Duration::from_millis(200),
2280            client.recv_from(&mut buf),
2281        )
2282        .await;
2283        assert!(recv.is_err(), "a filtered inform must not be acknowledged");
2284    }
2285
2286    /// A matching inform is still acknowledged (the filter does not suppress
2287    /// valid acks).
2288    #[tokio::test]
2289    async fn test_v2c_inform_matching_community_acked() {
2290        let receiver = NotificationReceiver::builder()
2291            .bind("127.0.0.1:0")
2292            .community(b"public")
2293            .build()
2294            .await
2295            .unwrap();
2296
2297        let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
2298        let client_addr = client.local_addr().unwrap();
2299
2300        let result = receiver
2301            .handle_v2c(build_v2c_inform(b"public"), client_addr)
2302            .await
2303            .unwrap();
2304        assert!(matches!(result, Some(Notification::InformV2c { .. })));
2305
2306        let mut buf = vec![0u8; 4096];
2307        let (len, _) = tokio::time::timeout(
2308            std::time::Duration::from_secs(1),
2309            client.recv_from(&mut buf),
2310        )
2311        .await
2312        .expect("a matching inform must be acknowledged")
2313        .unwrap();
2314        assert!(len > 0);
2315    }
2316}