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