diff --git a/Cargo.lock b/Cargo.lock index b3aa57e..c8d789a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,44 @@ dependencies = [ "password-hash", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -503,6 +541,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" @@ -653,6 +702,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "fs_extra" version = "1.3.0" @@ -1048,6 +1112,12 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1087,6 +1157,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1171,6 +1251,44 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "p256" version = "0.13.2" @@ -1317,6 +1435,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "poly1305" version = "0.8.0" @@ -1673,6 +1797,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1887,10 +2020,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "snmp" -version = "0.2.2" +name = "snmp2" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2a575449a5c487091e541c0cb4ccd83620167fd52363f816fe28f6f357fc00" +checksum = "49dc4ff9dc6c2b03d818a4c2abb8ffcef9e16357113ddc7ce1631c03195fde64" +dependencies = [ + "asn1-rs", + "openssl", + "tokio", +] [[package]] name = "socket2" @@ -1970,6 +2108,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -2099,7 +2248,7 @@ dependencies = [ "russh", "serde", "serde_json", - "snmp", + "snmp2", "thiserror 2.0.18", "tokio", "tokio-rustls", @@ -2240,6 +2389,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 304cce1..004882a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -snmp = "0.2" +snmp2 = { version = "0.4", features = ["v3", "tokio"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "signal", "io-util"] } thiserror = "2" tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } diff --git a/Dockerfile b/Dockerfile index 5fc0ccc..383abff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ ARG VERSION=0.1.0-unknown WORKDIR /app # Install build dependencies -RUN apk add --no-cache musl-dev protobuf-dev +RUN apk add --no-cache musl-dev protobuf-dev openssl-dev # Determine Rust target based on platform and add it RUN case "$TARGETPLATFORM" in \ @@ -56,7 +56,7 @@ FROM alpine:3.19 # Install runtime dependencies # iputils provides ping with setuid root (doesn't require CAP_NET_RAW) -RUN apk add --no-cache ca-certificates iputils +RUN apk add --no-cache ca-certificates iputils openssl # Copy binary from builder COPY --from=builder /tmp/towerops-agent /usr/local/bin/towerops-agent diff --git a/proto/agent.proto b/proto/agent.proto index 24a133c..75cafef 100644 --- a/proto/agent.proto +++ b/proto/agent.proto @@ -136,9 +136,17 @@ message AgentJob { message SnmpDevice { string ip = 1; - string community = 2; + string community = 2; // v1/v2c only (deprecated for v3) string version = 3; uint32 port = 4; + + // SNMPv3 credentials (optional, backward compatible) + string v3_security_level = 5; // "noAuthNoPriv" | "authNoPriv" | "authPriv" + string v3_username = 6; + string v3_auth_protocol = 7; // "MD5" | "SHA" | "SHA-256" | etc. + string v3_auth_password = 8; // Decrypted before sending + string v3_priv_protocol = 9; // "DES" | "AES" | "AES-256" | etc. + string v3_priv_password = 10; // Decrypted before sending } message SnmpQuery { diff --git a/src/snmp/client.rs b/src/snmp/client.rs index dee70e1..4f12100 100644 --- a/src/snmp/client.rs +++ b/src/snmp/client.rs @@ -1,11 +1,22 @@ use super::types::{SnmpError, SnmpResult, SnmpValue}; -use snmp::SyncSession; +use snmp2::SyncSession; use std::time::Duration; // SNMP timeout in seconds - increased from 5s to 30s to match Phoenix app // and reduce timeouts for slow devices or congested networks const SNMP_TIMEOUT_SECS: u64 = 30; +/// SNMPv3 configuration bundle +#[derive(Clone, Debug)] +pub struct V3Config { + pub username: String, + pub auth_password: Option, + pub priv_password: Option, + pub auth_protocol: Option, + pub priv_protocol: Option, + pub security_level: String, +} + /// SNMP client for polling devices #[derive(Debug, Clone, Copy)] pub struct SnmpClient; @@ -23,9 +34,14 @@ impl SnmpClient { version: &str, port: u16, oid: &str, + v3_config: Option, ) -> SnmpResult { - // Parse OID string to Vec - let oid_parts = parse_oid(oid)?; + use snmp2::Oid; + use std::str::FromStr; + + // Parse OID string + let oid_parsed = Oid::from_str(oid) + .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", oid)))?; // Clone data for the blocking task let addr = format!("{}:{}", ip_address, port); @@ -34,18 +50,18 @@ impl SnmpClient { // Run SNMP operation in blocking thread pool let result = tokio::task::spawn_blocking(move || { - // Create session with configurable timeout - // The snmp crate supports both v1 (0) and v2c (1) - let mut session = SyncSession::new( - addr.as_str(), - &community, - Some(Duration::from_secs(SNMP_TIMEOUT_SECS)), - version_num, - ) - .map_err(|_| SnmpError::NetworkUnreachable)?; + // Create session based on version + let mut session = if version_num == 3 { + let config = v3_config.ok_or_else(|| { + SnmpError::RequestFailed("SNMPv3 config required for version 3".into()) + })?; + create_v3_session(&addr, &config)? + } else { + create_v1v2c_session(&addr, &community, version_num)? + }; // Perform GET request - let mut response = session.get(&oid_parts).map_err(map_snmp_error)?; + let response = session.get(&oid_parsed).map_err(map_snmp_error)?; // Check for error status if response.error_status != 0 { @@ -56,7 +72,7 @@ impl SnmpClient { } // Extract first varbind - if let Some((_name, value)) = response.varbinds.next() { + for (_name, value) in response.varbinds { return convert_value(value); } @@ -77,9 +93,15 @@ impl SnmpClient { version: &str, port: u16, base_oid: &str, + v3_config: Option, ) -> SnmpResult> { - // Parse OID string to Vec - let base_oid_parts = parse_oid(base_oid)?; + use snmp2::Oid; + use std::str::FromStr; + + // Parse base OID string and clone it for moving into async block + let base_oid_parsed = Oid::from_str(base_oid) + .map_err(|_| SnmpError::InvalidOid(format!("Invalid base OID: {}", base_oid)))?; + let base_oid_string = base_oid.to_string(); // Clone data for the blocking task let addr = format!("{}:{}", ip_address, port); @@ -88,55 +110,63 @@ impl SnmpClient { // Run SNMP walk in blocking thread pool let results = tokio::task::spawn_blocking(move || { - // Create session with configurable timeout - // The snmp crate supports both v1 (0) and v2c (1) - let mut session = SyncSession::new( - addr.as_str(), - &community, - Some(Duration::from_secs(SNMP_TIMEOUT_SECS)), - version_num, - ) - .map_err(|_| SnmpError::NetworkUnreachable)?; + // Create session based on version + let mut session = if version_num == 3 { + let config = v3_config.ok_or_else(|| { + SnmpError::RequestFailed("SNMPv3 config required for version 3".into()) + })?; + create_v3_session(&addr, &config)? + } else { + create_v1v2c_session(&addr, &community, version_num)? + }; let mut results = Vec::new(); - let mut current_oid = base_oid_parts.clone(); + let mut current_oid = base_oid_parsed; // Perform GETNEXT repeatedly until we leave the base OID tree loop { - let response = session.getnext(¤t_oid).map_err(map_snmp_error)?; + let oid_to_query = current_oid.clone(); + + // Extract data we need from the response immediately + let (error_status, varbind_data) = { + let response = session.getnext(&oid_to_query).map_err(map_snmp_error)?; + let status = response.error_status; + + // Collect all varbind data as owned strings/values immediately + let data: Vec<(String, snmp2::Value)> = response + .varbinds + .map(|(name, value)| (name.to_string(), value)) + .collect(); + + (status, data) + // response is dropped here, ending the mutable borrow of session + }; // Check for error status - if response.error_status != 0 { + if error_status != 0 { break; } - // Extract varbind - for (name, value) in response.varbinds { - // Convert ObjectIdentifier to Vec - let mut oid_buf = [0u32; 128]; - let oid_slice = name - .read_name(&mut oid_buf) - .map_err(|_| SnmpError::InvalidOid("Failed to read OID".into()))?; + if varbind_data.is_empty() { + break; + } - // Check if we're still under the base OID - if !starts_with(oid_slice, &base_oid_parts) { + // Process the owned varbind data + for (name_str, value) in varbind_data { + // Check if the returned OID starts with our base OID + if !name_str.starts_with(&base_oid_string) { return Ok(results); } - // Convert OID to string - let oid_string = format_oid(oid_slice); - // Convert value let converted_value = convert_value(value)?; - results.push((oid_string, converted_value)); + results.push((name_str.clone(), converted_value)); - // Update current OID for next iteration - current_oid = oid_slice.to_vec(); - } - - if results.is_empty() { - break; + // Update current OID for next iteration (parse from string) + current_oid = Oid::from_str(&name_str).map_err(|_| { + SnmpError::InvalidOid(format!("Invalid OID from response: {}", name_str)) + })?; } } @@ -155,52 +185,150 @@ impl Default for SnmpClient { } } -/// Parse SNMP version string to integer for snmp crate -/// Returns: 0 for SNMPv1, 1 for SNMPv2c +/// Parse SNMP version string to integer for snmp2 crate +/// Returns: 0 for SNMPv1, 1 for SNMPv2c, 3 for SNMPv3 fn parse_snmp_version(version: &str) -> SnmpResult { let normalized = version.trim().to_lowercase(); match normalized.as_str() { "1" | "v1" | "snmpv1" => Ok(0), "2c" | "v2c" | "snmpv2c" | "2" | "v2" => Ok(1), + "3" | "v3" | "snmpv3" => Ok(3), _ => Err(SnmpError::RequestFailed(format!( - "Unsupported SNMP version: '{}'. Supported versions: 1, v1, 2c, v2c", + "Unsupported SNMP version: '{}'. Supported versions: 1, v1, 2c, v2c, 3, v3", version ))), } } -/// Parse OID string like "1.3.6.1.2.1.1.1.0" to Vec -fn parse_oid(oid: &str) -> SnmpResult> { - oid.split('.') - .map(|part| { - part.parse::() - .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID part: {}", part))) - }) - .collect() -} +/// Parse authentication protocol string to snmp2 AuthProtocol enum +fn parse_auth_protocol(protocol: &str) -> SnmpResult { + use snmp2::v3::AuthProtocol; -/// Format OID slice as string like "1.3.6.1.2.1.1.1.0" -#[allow(dead_code)] // Used by walk() which is not yet called -fn format_oid(oid: &[u32]) -> String { - oid.iter() - .map(|n| n.to_string()) - .collect::>() - .join(".") -} - -/// Check if OID starts with base OID -#[allow(dead_code)] // Used by walk() which is not yet called -fn starts_with(oid: &[u32], base: &[u32]) -> bool { - if oid.len() < base.len() { - return false; + match protocol.trim().to_uppercase().as_str() { + "MD5" => Ok(AuthProtocol::Md5), + "SHA" | "SHA1" | "SHA-1" => Ok(AuthProtocol::Sha1), + "SHA-224" | "SHA224" => Ok(AuthProtocol::Sha224), + "SHA-256" | "SHA256" => Ok(AuthProtocol::Sha256), + "SHA-384" | "SHA384" => Ok(AuthProtocol::Sha384), + "SHA-512" | "SHA512" => Ok(AuthProtocol::Sha512), + _ => Err(SnmpError::RequestFailed(format!( + "Unsupported auth protocol: '{}'", + protocol + ))), } - oid[..base.len()] == *base } -/// Convert snmp crate's Value to our SnmpValue -fn convert_value(value: snmp::Value) -> SnmpResult { - use snmp::Value as V; +/// Parse privacy protocol string to snmp2 Cipher enum +fn parse_priv_protocol(protocol: &str) -> SnmpResult { + use snmp2::v3::Cipher; + + match protocol.trim().to_uppercase().as_str() { + "DES" => Ok(Cipher::Des), + "AES" | "AES-128" | "AES128" => Ok(Cipher::Aes128), + "AES-192" | "AES192" => Ok(Cipher::Aes192), + "AES-256" | "AES256" | "AES-256-C" | "AES256C" => Ok(Cipher::Aes256), + _ => Err(SnmpError::RequestFailed(format!( + "Unsupported priv protocol: '{}'", + protocol + ))), + } +} + +/// Create SNMPv1/v2c session +fn create_v1v2c_session(addr: &str, community: &[u8], version: i32) -> SnmpResult { + let timeout = Some(Duration::from_secs(SNMP_TIMEOUT_SECS)); + let req_id = 1; + + if version == 0 { + SyncSession::new_v1(addr, community, timeout, req_id) + .map_err(|_| SnmpError::NetworkUnreachable) + } else { + SyncSession::new_v2c(addr, community, timeout, req_id) + .map_err(|_| SnmpError::NetworkUnreachable) + } +} + +/// Create SNMPv3 session with authentication and/or privacy +fn create_v3_session(addr: &str, config: &V3Config) -> SnmpResult { + use snmp2::v3::{Auth, Security}; + + let username = config.username.as_bytes(); + let security_level = config.security_level.trim().to_lowercase(); + + // Build the Auth enum based on security level + let auth = match security_level.as_str() { + "noauthnopriv" | "" => Auth::NoAuthNoPriv, + + "authnopriv" => { + // Requires auth protocol and password + let _auth_proto = + parse_auth_protocol(config.auth_protocol.as_deref().ok_or_else(|| { + SnmpError::RequestFailed("Auth protocol required for authNoPriv".into()) + })?)?; + let _auth_pass = config.auth_password.as_deref().ok_or_else(|| { + SnmpError::RequestFailed("Auth password required for authNoPriv".into()) + })?; + Auth::AuthNoPriv + } + + "authpriv" => { + // Requires auth and priv protocol/passwords + let _auth_proto = + parse_auth_protocol(config.auth_protocol.as_deref().ok_or_else(|| { + SnmpError::RequestFailed("Auth protocol required for authPriv".into()) + })?)?; + let _auth_pass = config.auth_password.as_deref().ok_or_else(|| { + SnmpError::RequestFailed("Auth password required for authPriv".into()) + })?; + let cipher = + parse_priv_protocol(config.priv_protocol.as_deref().ok_or_else(|| { + SnmpError::RequestFailed("Priv protocol required for authPriv".into()) + })?)?; + let priv_pass = config.priv_password.as_deref().ok_or_else(|| { + SnmpError::RequestFailed("Priv password required for authPriv".into()) + })?; + + Auth::AuthPriv { + cipher, + privacy_password: priv_pass.as_bytes().to_vec(), + } + } + + _ => { + return Err(SnmpError::RequestFailed(format!( + "Unsupported security level: '{}'", + config.security_level + ))) + } + }; + + // Get auth password (empty for noAuthNoPriv) + let auth_password = config.auth_password.as_deref().unwrap_or("").as_bytes(); + + // Determine if we need auth protocol (before auth is moved) + let needs_auth_protocol = !matches!(auth, Auth::NoAuthNoPriv); + + // Build Security object + let mut security = Security::new(username, auth_password).with_auth(auth); + + // Set auth protocol if authentication is required + if needs_auth_protocol { + let auth_proto = parse_auth_protocol(config.auth_protocol.as_deref().unwrap())?; + security = security.with_auth_protocol(auth_proto); + } + + // Create v3 session + let timeout = Some(Duration::from_secs(SNMP_TIMEOUT_SECS)); + let req_id = 1; + + SyncSession::new_v3(addr, timeout, req_id, security) + .map_err(|e| SnmpError::RequestFailed(format!("SNMPv3 session creation failed: {:?}", e))) +} + +/// Convert snmp2 crate's Value to our SnmpValue +fn convert_value(value: snmp2::Value) -> SnmpResult { + use snmp2::Value as V; match value { V::Integer(i) => Ok(SnmpValue::Integer(i)), @@ -220,14 +348,15 @@ fn convert_value(value: snmp::Value) -> SnmpResult { } } -/// Map snmp crate errors to our SnmpError -fn map_snmp_error(err: snmp::SnmpError) -> SnmpError { - use snmp::SnmpError as SE; +/// Map snmp2 crate errors to our SnmpError +fn map_snmp_error(err: snmp2::Error) -> SnmpError { + use snmp2::Error; match err { - SE::SendError => SnmpError::NetworkUnreachable, - SE::ReceiveError => SnmpError::Timeout, - SE::CommunityMismatch => SnmpError::AuthFailure, + Error::Send => SnmpError::NetworkUnreachable, + Error::Receive => SnmpError::Timeout, + Error::CommunityMismatch => SnmpError::AuthFailure, + Error::AuthFailure(_) => SnmpError::AuthFailure, _ => SnmpError::RequestFailed(format!("{:?}", err)), } } @@ -267,9 +396,17 @@ mod tests { assert_eq!(parse_snmp_version("v2").unwrap(), 1); } + #[test] + fn test_parse_snmp_version_v3() { + assert_eq!(parse_snmp_version("3").unwrap(), 3); + assert_eq!(parse_snmp_version("v3").unwrap(), 3); + assert_eq!(parse_snmp_version("V3").unwrap(), 3); + assert_eq!(parse_snmp_version("snmpv3").unwrap(), 3); + } + #[test] fn test_parse_snmp_version_invalid() { - let result = parse_snmp_version("3"); + let result = parse_snmp_version("invalid"); assert!(result.is_err()); match result { Err(SnmpError::RequestFailed(msg)) => { @@ -280,89 +417,31 @@ mod tests { } #[test] - fn test_parse_oid_valid() { - let result = parse_oid("1.3.6.1.2.1.1.1.0"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec![1, 3, 6, 1, 2, 1, 1, 1, 0]); + fn test_parse_auth_protocol() { + use snmp2::v3::AuthProtocol; + assert!(matches!(parse_auth_protocol("MD5"), Ok(AuthProtocol::Md5))); + assert!(matches!(parse_auth_protocol("SHA"), Ok(AuthProtocol::Sha1))); + assert!(matches!( + parse_auth_protocol("SHA-256"), + Ok(AuthProtocol::Sha256) + )); + assert!(matches!( + parse_auth_protocol("SHA-512"), + Ok(AuthProtocol::Sha512) + )); } #[test] - fn test_parse_oid_single() { - let result = parse_oid("1"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec![1]); - } - - #[test] - fn test_parse_oid_invalid() { - let result = parse_oid("1.3.6.abc.2.1"); - assert!(result.is_err()); - match result { - Err(SnmpError::InvalidOid(msg)) => { - assert!(msg.contains("abc")); - } - _ => panic!("Expected InvalidOid error"), - } - } - - #[test] - fn test_parse_oid_empty() { - let result = parse_oid(""); - assert!(result.is_err()); - } - - #[test] - fn test_format_oid() { - let oid = vec![1, 3, 6, 1, 2, 1, 1, 1, 0]; - let result = format_oid(&oid); - assert_eq!(result, "1.3.6.1.2.1.1.1.0"); - } - - #[test] - fn test_format_oid_single() { - let oid = vec![42]; - let result = format_oid(&oid); - assert_eq!(result, "42"); - } - - #[test] - fn test_format_oid_empty() { - let oid = vec![]; - let result = format_oid(&oid); - assert_eq!(result, ""); - } - - #[test] - fn test_starts_with_true() { - let oid = vec![1, 3, 6, 1, 2, 1, 1, 1, 0]; - let base = vec![1, 3, 6, 1]; - assert!(starts_with(&oid, &base)); - } - - #[test] - fn test_starts_with_exact_match() { - let oid = vec![1, 3, 6, 1]; - let base = vec![1, 3, 6, 1]; - assert!(starts_with(&oid, &base)); - } - - #[test] - fn test_starts_with_false() { - let oid = vec![1, 3, 6, 1, 2, 1]; - let base = vec![1, 3, 7]; - assert!(!starts_with(&oid, &base)); - } - - #[test] - fn test_starts_with_oid_too_short() { - let oid = vec![1, 3]; - let base = vec![1, 3, 6, 1]; - assert!(!starts_with(&oid, &base)); + fn test_parse_priv_protocol() { + use snmp2::v3::Cipher; + assert!(matches!(parse_priv_protocol("DES"), Ok(Cipher::Des))); + assert!(matches!(parse_priv_protocol("AES"), Ok(Cipher::Aes128))); + assert!(matches!(parse_priv_protocol("AES-256"), Ok(Cipher::Aes256))); } #[test] fn test_convert_value_integer() { - let value = snmp::Value::Integer(42); + let value = snmp2::Value::Integer(42); let result = convert_value(value).unwrap(); match result { SnmpValue::Integer(v) => assert_eq!(v, 42), @@ -372,7 +451,7 @@ mod tests { #[test] fn test_convert_value_octet_string() { - let value = snmp::Value::OctetString(b"test".as_slice()); + let value = snmp2::Value::OctetString(b"test".as_slice()); let result = convert_value(value).unwrap(); match result { SnmpValue::String(s) => assert_eq!(s, "test"), @@ -382,7 +461,7 @@ mod tests { #[test] fn test_convert_value_counter32() { - let value = snmp::Value::Counter32(12345); + let value = snmp2::Value::Counter32(12345); let result = convert_value(value).unwrap(); match result { SnmpValue::Counter32(v) => assert_eq!(v, 12345), @@ -392,7 +471,7 @@ mod tests { #[test] fn test_convert_value_counter64() { - let value = snmp::Value::Counter64(9876543210); + let value = snmp2::Value::Counter64(9876543210); let result = convert_value(value).unwrap(); match result { SnmpValue::Counter64(v) => assert_eq!(v, 9876543210), @@ -402,7 +481,7 @@ mod tests { #[test] fn test_convert_value_unsigned32() { - let value = snmp::Value::Unsigned32(999); + let value = snmp2::Value::Unsigned32(999); let result = convert_value(value).unwrap(); match result { SnmpValue::Gauge32(v) => assert_eq!(v, 999), @@ -412,7 +491,7 @@ mod tests { #[test] fn test_convert_value_timeticks() { - let value = snmp::Value::Timeticks(12345678); + let value = snmp2::Value::Timeticks(12345678); let result = convert_value(value).unwrap(); match result { SnmpValue::TimeTicks(v) => assert_eq!(v, 12345678), @@ -422,7 +501,7 @@ mod tests { #[test] fn test_convert_value_ip_address() { - let value = snmp::Value::IpAddress([192, 168, 1, 1]); + let value = snmp2::Value::IpAddress([192, 168, 1, 1]); let result = convert_value(value).unwrap(); match result { SnmpValue::IpAddress(ip) => assert_eq!(ip, "192.168.1.1"), @@ -432,7 +511,7 @@ mod tests { #[test] fn test_map_snmp_error_send() { - let err = snmp::SnmpError::SendError; + let err = snmp2::Error::Send; let result = map_snmp_error(err); match result { SnmpError::NetworkUnreachable => {} @@ -442,7 +521,7 @@ mod tests { #[test] fn test_map_snmp_error_receive() { - let err = snmp::SnmpError::ReceiveError; + let err = snmp2::Error::Receive; let result = map_snmp_error(err); match result { SnmpError::Timeout => {} @@ -452,7 +531,7 @@ mod tests { #[test] fn test_map_snmp_error_community() { - let err = snmp::SnmpError::CommunityMismatch; + let err = snmp2::Error::CommunityMismatch; let result = map_snmp_error(err); match result { SnmpError::AuthFailure => {} diff --git a/src/snmp/mod.rs b/src/snmp/mod.rs index be71baf..31d4807 100644 --- a/src/snmp/mod.rs +++ b/src/snmp/mod.rs @@ -2,6 +2,6 @@ mod client; pub mod trap; mod types; -pub use client::SnmpClient; +pub use client::{SnmpClient, V3Config}; pub use trap::{SnmpTrap, TrapListener, DEFAULT_TRAP_PORT}; pub use types::SnmpValue; diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 5c30c52..a09afe8 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -358,6 +358,36 @@ async fn execute_snmp_job( ) -> Result<()> { let snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?; + // Build v3 config if version is "3" + let v3_config = if snmp_device.version == "3" { + Some(crate::snmp::V3Config { + username: snmp_device.v3_username.clone(), + auth_password: if !snmp_device.v3_auth_password.is_empty() { + Some(snmp_device.v3_auth_password.clone()) + } else { + None + }, + priv_password: if !snmp_device.v3_priv_password.is_empty() { + Some(snmp_device.v3_priv_password.clone()) + } else { + None + }, + auth_protocol: if !snmp_device.v3_auth_protocol.is_empty() { + Some(snmp_device.v3_auth_protocol.clone()) + } else { + None + }, + priv_protocol: if !snmp_device.v3_priv_protocol.is_empty() { + Some(snmp_device.v3_priv_protocol.clone()) + } else { + None + }, + security_level: snmp_device.v3_security_level.clone(), + }) + } else { + None + }; + // Log SNMP connection parameters for debugging (mask community for security) let community_masked = redact_community(&snmp_device.community); @@ -387,6 +417,7 @@ async fn execute_snmp_job( &snmp_device.version, snmp_device.port as u16, oid, + v3_config.clone(), ) .await { @@ -418,6 +449,7 @@ async fn execute_snmp_job( &snmp_device.version, snmp_device.port as u16, base_oid, + v3_config.clone(), ) .await {