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