async-snmp 0.17.0

Modern async-first SNMP client library for Rust
Documentation
//! V3 response building for the SNMP agent.

use bytes::Bytes;
use std::sync::atomic::Ordering;

use crate::error::Result;
use crate::message::MsgGlobalData;
use crate::pdu::Pdu;
use crate::v3::DerivedKeys;
use crate::v3::encode::encode_v3_response;
use crate::v3::{MAX_ENGINE_TIME, UsmSecurityParams};

use super::Agent;

impl Agent {
    /// Build a V3 response message with appropriate security.
    pub(super) fn build_v3_response(
        &self,
        incoming: &MsgGlobalData,
        incoming_usm: &UsmSecurityParams,
        response_pdu: Pdu,
        context_engine_id: Bytes,
        context_name: Bytes,
        derived_keys: Option<&DerivedKeys>,
    ) -> Result<Option<Bytes>> {
        let security_level = incoming.msg_flags.security_level;
        // Handlers are asynchronous and may run long after the receive task's
        // cached time refresh. Derive both fields from one elapsed-time sample
        // at response generation so the authoritative tuple is current and
        // cannot straddle a boots/time rollover.
        let (engine_boots, engine_time) = self.inner.state.authoritative_boots_time()?;

        // RFC 3414 Section 2.3: refuse authenticated messages when boots latched
        if security_level.requires_auth() && engine_boots == MAX_ENGINE_TIME {
            tracing::warn!(target: "async_snmp::agent", "engine boots at maximum, refusing authenticated response");
            self.inner
                .state
                .snmp_silent_drops
                .fetch_add(1, Ordering::Relaxed);
            return Ok(None);
        }

        let response_usm = UsmSecurityParams::new(
            self.inner.state.engine_id.clone(),
            engine_boots,
            engine_time,
            incoming_usm.username.clone(),
        );

        // RFC 3412 Section 6.3: msgMaxSize advertises this agent's own receive
        // capacity, not the requester's echoed value.
        let advertised_max_size =
            i32::try_from(self.inner.state.max_message_size).unwrap_or(i32::MAX);

        encode_v3_response(
            response_pdu,
            incoming.msg_id,
            advertised_max_size,
            security_level,
            response_usm,
            context_engine_id,
            context_name,
            derived_keys,
            &self.inner.salt_counter,
            self.inner.local_addr,
        )
        .map(Some)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::Agent;
    use crate::message::{MsgFlags, SecurityLevel, V3Message};
    use crate::oid;
    use crate::oid::Oid;
    use crate::pdu::PduType;
    use std::sync::Arc;
    use std::sync::atomic::Ordering;

    use crate::handler::{BoxFuture, GetNextResult, GetResult, HandlerResult, MibHandler};

    struct DummyHandler;

    impl MibHandler for DummyHandler {
        fn get<'a>(
            &'a self,
            _ctx: &'a crate::handler::RequestContext,
            _oid: &'a Oid,
        ) -> BoxFuture<'a, HandlerResult<GetResult>> {
            Box::pin(async { Ok(GetResult::NoSuchObject) })
        }

        fn get_next<'a>(
            &'a self,
            _ctx: &'a crate::handler::RequestContext,
            _oid: &'a Oid,
        ) -> BoxFuture<'a, HandlerResult<GetNextResult>> {
            Box::pin(async { Ok(GetNextResult::EndOfMibView) })
        }
    }

    async fn test_agent_with_boots(engine_boots: u32) -> Agent {
        Agent::builder()
            .bind("127.0.0.1:0")
            .community(b"public")
            .engine_boots(engine_boots)
            .handler(oid!(1, 3, 6, 1, 4, 1, 99999), Arc::new(DummyHandler))
            .build()
            .await
            .unwrap()
    }

    async fn test_agent() -> Agent {
        test_agent_with_boots(1).await
    }

    fn dummy_v3_msg(security_level: SecurityLevel) -> MsgGlobalData {
        MsgGlobalData::new(1, 65507, MsgFlags::new(security_level, true))
    }

    fn dummy_usm() -> UsmSecurityParams {
        UsmSecurityParams::new(
            Bytes::from_static(b"engine"),
            1,
            100,
            Bytes::from_static(b"testuser"),
        )
    }

    fn dummy_response_pdu() -> Pdu {
        Pdu {
            pdu_type: PduType::Response,
            request_id: 1,
            error_status: 0,
            error_index: 0,
            varbinds: vec![],
        }
    }

    #[tokio::test]
    async fn test_boots_latched_drops_auth_nopriv_response() {
        let agent = test_agent_with_boots(MAX_ENGINE_TIME).await;

        let msg = dummy_v3_msg(SecurityLevel::AuthNoPriv);
        let usm = dummy_usm();

        let result = agent
            .build_v3_response(
                &msg,
                &usm,
                dummy_response_pdu(),
                Bytes::from_static(b"engine"),
                Bytes::new(),
                None,
            )
            .unwrap();

        assert!(
            result.is_none(),
            "authenticated response should be dropped when boots is latched"
        );
        assert_eq!(
            agent.inner.state.snmp_silent_drops.load(Ordering::Relaxed),
            1,
            "snmpSilentDrops should be incremented"
        );
    }

    #[tokio::test]
    async fn test_boots_latched_drops_auth_priv_response() {
        let agent = test_agent_with_boots(MAX_ENGINE_TIME).await;

        let msg = dummy_v3_msg(SecurityLevel::AuthPriv);
        let usm = dummy_usm();

        let result = agent
            .build_v3_response(
                &msg,
                &usm,
                dummy_response_pdu(),
                Bytes::from_static(b"engine"),
                Bytes::new(),
                None,
            )
            .unwrap();

        assert!(
            result.is_none(),
            "authpriv response should be dropped when boots is latched"
        );
    }

    #[tokio::test]
    async fn test_boots_latched_allows_noauth_response() {
        let agent = test_agent_with_boots(MAX_ENGINE_TIME).await;

        let msg = dummy_v3_msg(SecurityLevel::NoAuthNoPriv);
        let usm = dummy_usm();

        let result = agent
            .build_v3_response(
                &msg,
                &usm,
                dummy_response_pdu(),
                Bytes::from_static(b"engine"),
                Bytes::new(),
                None,
            )
            .unwrap();

        assert!(
            result.is_some(),
            "noAuthNoPriv response should still be sent when boots is latched"
        );
    }

    #[tokio::test]
    async fn test_boots_below_max_allows_auth_response() {
        let agent = test_agent_with_boots(MAX_ENGINE_TIME - 1).await;

        let msg = dummy_v3_msg(SecurityLevel::NoAuthNoPriv);
        let usm = dummy_usm();

        // NoAuthNoPriv should work regardless
        let result = agent
            .build_v3_response(
                &msg,
                &usm,
                dummy_response_pdu(),
                Bytes::from_static(b"engine"),
                Bytes::new(),
                None,
            )
            .unwrap();

        assert!(
            result.is_some(),
            "noAuthNoPriv should work when boots is below max"
        );
    }

    #[tokio::test]
    async fn test_response_uses_current_coherent_authoritative_time() {
        let agent = test_agent().await;

        // Model a response generated after handler dispatch without refreshing
        // the legacy cached fields. A response must not use this stale,
        // internally inconsistent tuple.
        agent.inner.state.engine_boots.store(17, Ordering::Relaxed);
        agent
            .inner
            .state
            .engine_time
            .store(MAX_ENGINE_TIME, Ordering::Relaxed);

        let earliest = agent.inner.state.authoritative_boots_time().unwrap();
        let encoded = agent
            .build_v3_response(
                &dummy_v3_msg(SecurityLevel::NoAuthNoPriv),
                &dummy_usm(),
                dummy_response_pdu(),
                Bytes::from_static(b"engine"),
                Bytes::new(),
                None,
            )
            .unwrap()
            .expect("noAuthNoPriv response should be produced");
        let latest = agent.inner.state.authoritative_boots_time().unwrap();

        let message = V3Message::decode(encoded).unwrap();
        let response_usm = UsmSecurityParams::decode(message.security_params).unwrap();
        let response_pair = (response_usm.engine_boots, response_usm.engine_time);

        assert_ne!(response_pair, (17, MAX_ENGINE_TIME));
        assert_eq!(response_pair.0, 1);
        assert!(
            response_pair.1 >= earliest.1 && response_pair.1 <= latest.1,
            "response pair {response_pair:?} should come from one current elapsed-time sample between {earliest:?} and {latest:?}"
        );
    }

    // RFC 3412 Section 6.3: the advertised msgMaxSize is this agent's own
    // receive capacity, not the requester's echoed value.
    #[tokio::test]
    async fn test_response_advertises_local_max_size() {
        let agent = Agent::builder()
            .bind("127.0.0.1:0")
            .community(b"public")
            .max_message_size(1400)
            .handler(oid!(1, 3, 6, 1, 4, 1, 99999), Arc::new(DummyHandler))
            .build()
            .await
            .unwrap();

        // Incoming message advertises a much larger msgMaxSize (65507).
        let msg = dummy_v3_msg(SecurityLevel::NoAuthNoPriv);
        assert_eq!(msg.msg_max_size, 65507);
        let usm = dummy_usm();

        let result = agent
            .build_v3_response(
                &msg,
                &usm,
                dummy_response_pdu(),
                Bytes::from_static(b"engine"),
                Bytes::new(),
                None,
            )
            .unwrap()
            .expect("noAuthNoPriv response should be produced");

        let decoded = V3Message::decode(result).unwrap();
        assert_eq!(
            decoded.global_data.msg_max_size, 1400,
            "response must advertise the agent's local receive capacity, not the requester's value"
        );
    }
}