snmpv3 first stab

This commit is contained in:
Graham McIntire 2026-02-03 16:46:19 -06:00
parent 4719bb8249
commit 0f19518065
No known key found for this signature in database
7 changed files with 452 additions and 178 deletions

163
Cargo.lock generated
View file

@ -129,6 +129,44 @@ dependencies = [
"password-hash", "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]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@ -503,6 +541,17 @@ dependencies = [
"subtle", "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]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@ -653,6 +702,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 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]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@ -1048,6 +1112,12 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -1087,6 +1157,16 @@ dependencies = [
"libc", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -1171,6 +1251,44 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 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]] [[package]]
name = "p256" name = "p256"
version = "0.13.2" version = "0.13.2"
@ -1317,6 +1435,12 @@ dependencies = [
"spki", "spki",
] ]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@ -1673,6 +1797,15 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.3" version = "1.1.3"
@ -1887,10 +2020,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "snmp" name = "snmp2"
version = "0.2.2" version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2a575449a5c487091e541c0cb4ccd83620167fd52363f816fe28f6f357fc00" checksum = "49dc4ff9dc6c2b03d818a4c2abb8ffcef9e16357113ddc7ce1631c03195fde64"
dependencies = [
"asn1-rs",
"openssl",
"tokio",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
@ -1970,6 +2108,17 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.24.0" version = "3.24.0"
@ -2099,7 +2248,7 @@ dependencies = [
"russh", "russh",
"serde", "serde",
"serde_json", "serde_json",
"snmp", "snmp2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@ -2240,6 +2389,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [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"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "signal", "io-util"] }
thiserror = "2" thiserror = "2"
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }

View file

@ -10,7 +10,7 @@ ARG VERSION=0.1.0-unknown
WORKDIR /app WORKDIR /app
# Install build dependencies # 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 # Determine Rust target based on platform and add it
RUN case "$TARGETPLATFORM" in \ RUN case "$TARGETPLATFORM" in \
@ -56,7 +56,7 @@ FROM alpine:3.19
# Install runtime dependencies # Install runtime dependencies
# iputils provides ping with setuid root (doesn't require CAP_NET_RAW) # 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 binary from builder
COPY --from=builder /tmp/towerops-agent /usr/local/bin/towerops-agent COPY --from=builder /tmp/towerops-agent /usr/local/bin/towerops-agent

View file

@ -136,9 +136,17 @@ message AgentJob {
message SnmpDevice { message SnmpDevice {
string ip = 1; string ip = 1;
string community = 2; string community = 2; // v1/v2c only (deprecated for v3)
string version = 3; string version = 3;
uint32 port = 4; 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 { message SnmpQuery {

View file

@ -1,11 +1,22 @@
use super::types::{SnmpError, SnmpResult, SnmpValue}; use super::types::{SnmpError, SnmpResult, SnmpValue};
use snmp::SyncSession; use snmp2::SyncSession;
use std::time::Duration; use std::time::Duration;
// SNMP timeout in seconds - increased from 5s to 30s to match Phoenix app // SNMP timeout in seconds - increased from 5s to 30s to match Phoenix app
// and reduce timeouts for slow devices or congested networks // and reduce timeouts for slow devices or congested networks
const SNMP_TIMEOUT_SECS: u64 = 30; const SNMP_TIMEOUT_SECS: u64 = 30;
/// SNMPv3 configuration bundle
#[derive(Clone, Debug)]
pub struct V3Config {
pub username: String,
pub auth_password: Option<String>,
pub priv_password: Option<String>,
pub auth_protocol: Option<String>,
pub priv_protocol: Option<String>,
pub security_level: String,
}
/// SNMP client for polling devices /// SNMP client for polling devices
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct SnmpClient; pub struct SnmpClient;
@ -23,9 +34,14 @@ impl SnmpClient {
version: &str, version: &str,
port: u16, port: u16,
oid: &str, oid: &str,
v3_config: Option<V3Config>,
) -> SnmpResult<SnmpValue> { ) -> SnmpResult<SnmpValue> {
// Parse OID string to Vec<u32> use snmp2::Oid;
let oid_parts = parse_oid(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 // Clone data for the blocking task
let addr = format!("{}:{}", ip_address, port); let addr = format!("{}:{}", ip_address, port);
@ -34,18 +50,18 @@ impl SnmpClient {
// Run SNMP operation in blocking thread pool // Run SNMP operation in blocking thread pool
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
// Create session with configurable timeout // Create session based on version
// The snmp crate supports both v1 (0) and v2c (1) let mut session = if version_num == 3 {
let mut session = SyncSession::new( let config = v3_config.ok_or_else(|| {
addr.as_str(), SnmpError::RequestFailed("SNMPv3 config required for version 3".into())
&community, })?;
Some(Duration::from_secs(SNMP_TIMEOUT_SECS)), create_v3_session(&addr, &config)?
version_num, } else {
) create_v1v2c_session(&addr, &community, version_num)?
.map_err(|_| SnmpError::NetworkUnreachable)?; };
// Perform GET request // 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 // Check for error status
if response.error_status != 0 { if response.error_status != 0 {
@ -56,7 +72,7 @@ impl SnmpClient {
} }
// Extract first varbind // Extract first varbind
if let Some((_name, value)) = response.varbinds.next() { for (_name, value) in response.varbinds {
return convert_value(value); return convert_value(value);
} }
@ -77,9 +93,15 @@ impl SnmpClient {
version: &str, version: &str,
port: u16, port: u16,
base_oid: &str, base_oid: &str,
v3_config: Option<V3Config>,
) -> SnmpResult<Vec<(String, SnmpValue)>> { ) -> SnmpResult<Vec<(String, SnmpValue)>> {
// Parse OID string to Vec<u32> use snmp2::Oid;
let base_oid_parts = parse_oid(base_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 // Clone data for the blocking task
let addr = format!("{}:{}", ip_address, port); let addr = format!("{}:{}", ip_address, port);
@ -88,55 +110,63 @@ impl SnmpClient {
// Run SNMP walk in blocking thread pool // Run SNMP walk in blocking thread pool
let results = tokio::task::spawn_blocking(move || { let results = tokio::task::spawn_blocking(move || {
// Create session with configurable timeout // Create session based on version
// The snmp crate supports both v1 (0) and v2c (1) let mut session = if version_num == 3 {
let mut session = SyncSession::new( let config = v3_config.ok_or_else(|| {
addr.as_str(), SnmpError::RequestFailed("SNMPv3 config required for version 3".into())
&community, })?;
Some(Duration::from_secs(SNMP_TIMEOUT_SECS)), create_v3_session(&addr, &config)?
version_num, } else {
) create_v1v2c_session(&addr, &community, version_num)?
.map_err(|_| SnmpError::NetworkUnreachable)?; };
let mut results = Vec::new(); 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 // Perform GETNEXT repeatedly until we leave the base OID tree
loop { loop {
let response = session.getnext(&current_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 // Check for error status
if response.error_status != 0 { if error_status != 0 {
break; break;
} }
// Extract varbind if varbind_data.is_empty() {
for (name, value) in response.varbinds { break;
// Convert ObjectIdentifier to Vec<u32> }
let mut oid_buf = [0u32; 128];
let oid_slice = name
.read_name(&mut oid_buf)
.map_err(|_| SnmpError::InvalidOid("Failed to read OID".into()))?;
// Check if we're still under the base OID // Process the owned varbind data
if !starts_with(oid_slice, &base_oid_parts) { 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); return Ok(results);
} }
// Convert OID to string
let oid_string = format_oid(oid_slice);
// Convert value // Convert value
let converted_value = convert_value(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 // Update current OID for next iteration (parse from string)
current_oid = oid_slice.to_vec(); current_oid = Oid::from_str(&name_str).map_err(|_| {
} SnmpError::InvalidOid(format!("Invalid OID from response: {}", name_str))
})?;
if results.is_empty() {
break;
} }
} }
@ -155,52 +185,150 @@ impl Default for SnmpClient {
} }
} }
/// Parse SNMP version string to integer for snmp crate /// Parse SNMP version string to integer for snmp2 crate
/// Returns: 0 for SNMPv1, 1 for SNMPv2c /// Returns: 0 for SNMPv1, 1 for SNMPv2c, 3 for SNMPv3
fn parse_snmp_version(version: &str) -> SnmpResult<i32> { fn parse_snmp_version(version: &str) -> SnmpResult<i32> {
let normalized = version.trim().to_lowercase(); let normalized = version.trim().to_lowercase();
match normalized.as_str() { match normalized.as_str() {
"1" | "v1" | "snmpv1" => Ok(0), "1" | "v1" | "snmpv1" => Ok(0),
"2c" | "v2c" | "snmpv2c" | "2" | "v2" => Ok(1), "2c" | "v2c" | "snmpv2c" | "2" | "v2" => Ok(1),
"3" | "v3" | "snmpv3" => Ok(3),
_ => Err(SnmpError::RequestFailed(format!( _ => 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 version
))), ))),
} }
} }
/// Parse OID string like "1.3.6.1.2.1.1.1.0" to Vec<u32> /// Parse authentication protocol string to snmp2 AuthProtocol enum
fn parse_oid(oid: &str) -> SnmpResult<Vec<u32>> { fn parse_auth_protocol(protocol: &str) -> SnmpResult<snmp2::v3::AuthProtocol> {
oid.split('.') use snmp2::v3::AuthProtocol;
.map(|part| {
part.parse::<u32>()
.map_err(|_| SnmpError::InvalidOid(format!("Invalid OID part: {}", part)))
})
.collect()
}
/// Format OID slice as string like "1.3.6.1.2.1.1.1.0" match protocol.trim().to_uppercase().as_str() {
#[allow(dead_code)] // Used by walk() which is not yet called "MD5" => Ok(AuthProtocol::Md5),
fn format_oid(oid: &[u32]) -> String { "SHA" | "SHA1" | "SHA-1" => Ok(AuthProtocol::Sha1),
oid.iter() "SHA-224" | "SHA224" => Ok(AuthProtocol::Sha224),
.map(|n| n.to_string()) "SHA-256" | "SHA256" => Ok(AuthProtocol::Sha256),
.collect::<Vec<_>>() "SHA-384" | "SHA384" => Ok(AuthProtocol::Sha384),
.join(".") "SHA-512" | "SHA512" => Ok(AuthProtocol::Sha512),
} _ => Err(SnmpError::RequestFailed(format!(
"Unsupported auth protocol: '{}'",
/// Check if OID starts with base OID protocol
#[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;
} }
oid[..base.len()] == *base
} }
/// Convert snmp crate's Value to our SnmpValue /// Parse privacy protocol string to snmp2 Cipher enum
fn convert_value(value: snmp::Value) -> SnmpResult<SnmpValue> { fn parse_priv_protocol(protocol: &str) -> SnmpResult<snmp2::v3::Cipher> {
use snmp::Value as V; 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<SyncSession> {
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<SyncSession> {
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<SnmpValue> {
use snmp2::Value as V;
match value { match value {
V::Integer(i) => Ok(SnmpValue::Integer(i)), V::Integer(i) => Ok(SnmpValue::Integer(i)),
@ -220,14 +348,15 @@ fn convert_value(value: snmp::Value) -> SnmpResult<SnmpValue> {
} }
} }
/// Map snmp crate errors to our SnmpError /// Map snmp2 crate errors to our SnmpError
fn map_snmp_error(err: snmp::SnmpError) -> SnmpError { fn map_snmp_error(err: snmp2::Error) -> SnmpError {
use snmp::SnmpError as SE; use snmp2::Error;
match err { match err {
SE::SendError => SnmpError::NetworkUnreachable, Error::Send => SnmpError::NetworkUnreachable,
SE::ReceiveError => SnmpError::Timeout, Error::Receive => SnmpError::Timeout,
SE::CommunityMismatch => SnmpError::AuthFailure, Error::CommunityMismatch => SnmpError::AuthFailure,
Error::AuthFailure(_) => SnmpError::AuthFailure,
_ => SnmpError::RequestFailed(format!("{:?}", err)), _ => SnmpError::RequestFailed(format!("{:?}", err)),
} }
} }
@ -267,9 +396,17 @@ mod tests {
assert_eq!(parse_snmp_version("v2").unwrap(), 1); 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] #[test]
fn test_parse_snmp_version_invalid() { fn test_parse_snmp_version_invalid() {
let result = parse_snmp_version("3"); let result = parse_snmp_version("invalid");
assert!(result.is_err()); assert!(result.is_err());
match result { match result {
Err(SnmpError::RequestFailed(msg)) => { Err(SnmpError::RequestFailed(msg)) => {
@ -280,89 +417,31 @@ mod tests {
} }
#[test] #[test]
fn test_parse_oid_valid() { fn test_parse_auth_protocol() {
let result = parse_oid("1.3.6.1.2.1.1.1.0"); use snmp2::v3::AuthProtocol;
assert!(result.is_ok()); assert!(matches!(parse_auth_protocol("MD5"), Ok(AuthProtocol::Md5)));
assert_eq!(result.unwrap(), vec![1, 3, 6, 1, 2, 1, 1, 1, 0]); 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] #[test]
fn test_parse_oid_single() { fn test_parse_priv_protocol() {
let result = parse_oid("1"); use snmp2::v3::Cipher;
assert!(result.is_ok()); assert!(matches!(parse_priv_protocol("DES"), Ok(Cipher::Des)));
assert_eq!(result.unwrap(), vec![1]); assert!(matches!(parse_priv_protocol("AES"), Ok(Cipher::Aes128)));
} assert!(matches!(parse_priv_protocol("AES-256"), Ok(Cipher::Aes256)));
#[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));
} }
#[test] #[test]
fn test_convert_value_integer() { fn test_convert_value_integer() {
let value = snmp::Value::Integer(42); let value = snmp2::Value::Integer(42);
let result = convert_value(value).unwrap(); let result = convert_value(value).unwrap();
match result { match result {
SnmpValue::Integer(v) => assert_eq!(v, 42), SnmpValue::Integer(v) => assert_eq!(v, 42),
@ -372,7 +451,7 @@ mod tests {
#[test] #[test]
fn test_convert_value_octet_string() { 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(); let result = convert_value(value).unwrap();
match result { match result {
SnmpValue::String(s) => assert_eq!(s, "test"), SnmpValue::String(s) => assert_eq!(s, "test"),
@ -382,7 +461,7 @@ mod tests {
#[test] #[test]
fn test_convert_value_counter32() { fn test_convert_value_counter32() {
let value = snmp::Value::Counter32(12345); let value = snmp2::Value::Counter32(12345);
let result = convert_value(value).unwrap(); let result = convert_value(value).unwrap();
match result { match result {
SnmpValue::Counter32(v) => assert_eq!(v, 12345), SnmpValue::Counter32(v) => assert_eq!(v, 12345),
@ -392,7 +471,7 @@ mod tests {
#[test] #[test]
fn test_convert_value_counter64() { fn test_convert_value_counter64() {
let value = snmp::Value::Counter64(9876543210); let value = snmp2::Value::Counter64(9876543210);
let result = convert_value(value).unwrap(); let result = convert_value(value).unwrap();
match result { match result {
SnmpValue::Counter64(v) => assert_eq!(v, 9876543210), SnmpValue::Counter64(v) => assert_eq!(v, 9876543210),
@ -402,7 +481,7 @@ mod tests {
#[test] #[test]
fn test_convert_value_unsigned32() { fn test_convert_value_unsigned32() {
let value = snmp::Value::Unsigned32(999); let value = snmp2::Value::Unsigned32(999);
let result = convert_value(value).unwrap(); let result = convert_value(value).unwrap();
match result { match result {
SnmpValue::Gauge32(v) => assert_eq!(v, 999), SnmpValue::Gauge32(v) => assert_eq!(v, 999),
@ -412,7 +491,7 @@ mod tests {
#[test] #[test]
fn test_convert_value_timeticks() { fn test_convert_value_timeticks() {
let value = snmp::Value::Timeticks(12345678); let value = snmp2::Value::Timeticks(12345678);
let result = convert_value(value).unwrap(); let result = convert_value(value).unwrap();
match result { match result {
SnmpValue::TimeTicks(v) => assert_eq!(v, 12345678), SnmpValue::TimeTicks(v) => assert_eq!(v, 12345678),
@ -422,7 +501,7 @@ mod tests {
#[test] #[test]
fn test_convert_value_ip_address() { 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(); let result = convert_value(value).unwrap();
match result { match result {
SnmpValue::IpAddress(ip) => assert_eq!(ip, "192.168.1.1"), SnmpValue::IpAddress(ip) => assert_eq!(ip, "192.168.1.1"),
@ -432,7 +511,7 @@ mod tests {
#[test] #[test]
fn test_map_snmp_error_send() { fn test_map_snmp_error_send() {
let err = snmp::SnmpError::SendError; let err = snmp2::Error::Send;
let result = map_snmp_error(err); let result = map_snmp_error(err);
match result { match result {
SnmpError::NetworkUnreachable => {} SnmpError::NetworkUnreachable => {}
@ -442,7 +521,7 @@ mod tests {
#[test] #[test]
fn test_map_snmp_error_receive() { fn test_map_snmp_error_receive() {
let err = snmp::SnmpError::ReceiveError; let err = snmp2::Error::Receive;
let result = map_snmp_error(err); let result = map_snmp_error(err);
match result { match result {
SnmpError::Timeout => {} SnmpError::Timeout => {}
@ -452,7 +531,7 @@ mod tests {
#[test] #[test]
fn test_map_snmp_error_community() { fn test_map_snmp_error_community() {
let err = snmp::SnmpError::CommunityMismatch; let err = snmp2::Error::CommunityMismatch;
let result = map_snmp_error(err); let result = map_snmp_error(err);
match result { match result {
SnmpError::AuthFailure => {} SnmpError::AuthFailure => {}

View file

@ -2,6 +2,6 @@ mod client;
pub mod trap; pub mod trap;
mod types; mod types;
pub use client::SnmpClient; pub use client::{SnmpClient, V3Config};
pub use trap::{SnmpTrap, TrapListener, DEFAULT_TRAP_PORT}; pub use trap::{SnmpTrap, TrapListener, DEFAULT_TRAP_PORT};
pub use types::SnmpValue; pub use types::SnmpValue;

View file

@ -358,6 +358,36 @@ async fn execute_snmp_job(
) -> Result<()> { ) -> Result<()> {
let snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?; 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) // Log SNMP connection parameters for debugging (mask community for security)
let community_masked = redact_community(&snmp_device.community); let community_masked = redact_community(&snmp_device.community);
@ -387,6 +417,7 @@ async fn execute_snmp_job(
&snmp_device.version, &snmp_device.version,
snmp_device.port as u16, snmp_device.port as u16,
oid, oid,
v3_config.clone(),
) )
.await .await
{ {
@ -418,6 +449,7 @@ async fn execute_snmp_job(
&snmp_device.version, &snmp_device.version,
snmp_device.port as u16, snmp_device.port as u16,
base_oid, base_oid,
v3_config.clone(),
) )
.await .await
{ {