diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..9f09532 --- /dev/null +++ b/.envrc @@ -0,0 +1,7 @@ +use flake + +# OpenSSL configuration for Homebrew on macOS +export OPENSSL_DIR=/opt/homebrew/opt/openssl@3 +export OPENSSL_LIB_DIR=/opt/homebrew/opt/openssl@3/lib +export PKG_CONFIG_PATH=/opt/homebrew/opt/openssl@3/lib/pkgconfig +export RUSTFLAGS="-L /opt/homebrew/opt/openssl@3/lib" diff --git a/.gitignore b/.gitignore index 3364e96..e6050a9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,9 @@ data/ .DS_Store Thumbs.db +# Nix +.direnv/ +result + # Local scripts monitor-deploy.sh diff --git a/Cargo.lock b/Cargo.lock index c2e7b0e..5d74a33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,44 +135,6 @@ 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 2.0.114", - "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 2.0.114", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -847,17 +809,6 @@ dependencies = [ "crypto-common 0.2.0-rc.15", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "document-features" version = "0.2.12" @@ -1071,21 +1022,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[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" @@ -1772,6 +1708,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "netsnmp-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631b53a54f07e7f8e390ba494a5fa4cac4c92abed56506025714c62a237c27c3" +dependencies = [ + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -1910,44 +1855,6 @@ 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 2.10.0", - "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 2.0.114", -] - -[[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 = "ordered-float" version = "4.6.0" @@ -2247,12 +2154,6 @@ dependencies = [ "spki 0.8.0-rc.4", ] -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "pnet_base" version = "0.34.0" @@ -2782,15 +2683,6 @@ 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 = "0.38.44" @@ -3098,17 +2990,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "snmp2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49dc4ff9dc6c2b03d818a4c2abb8ffcef9e16357113ddc7ce1631c03195fde64" -dependencies = [ - "asn1-rs", - "openssl", - "tokio", -] - [[package]] name = "socket2" version = "0.6.2" @@ -3251,17 +3132,6 @@ 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 2.0.114", -] - [[package]] name = "tempfile" version = "3.24.0" @@ -3488,11 +3358,14 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "cc", "clap", "crossterm 0.28.1", "futures", "home", "hostname", + "libc", + "netsnmp-sys", "prost", "prost-build", "prost-types", @@ -3501,7 +3374,6 @@ dependencies = [ "russh", "serde", "serde_json", - "snmp2", "surge-ping", "thiserror 2.0.18", "tokio", @@ -3685,12 +3557,6 @@ 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 f087b7c..e5b53cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] -snmp2 = { version = "0.4", features = ["v3", "tokio"] } +netsnmp-sys = "0.1" +libc = "0.2" 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"] } @@ -36,6 +37,7 @@ tui = ["ratatui", "crossterm"] [build-dependencies] prost-build = "0.14" +cc = "1.0" [profile.release] opt-level = "z" diff --git a/Dockerfile b/Dockerfile index c5f9d65..b659d1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,8 @@ ARG VERSION=0.1.0-unknown WORKDIR /app -# Install build dependencies -RUN apk add --no-cache musl-dev protobuf-dev openssl-dev openssl-libs-static cmake perl g++ +# Install build dependencies (net-snmp-dev required for C FFI) +RUN apk add --no-cache musl-dev protobuf-dev openssl-dev openssl-libs-static net-snmp-dev cmake perl g++ # Determine Rust target based on platform and add it RUN case "$TARGETPLATFORM" in \ @@ -56,7 +56,8 @@ 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 openssl +# net-snmp-libs required for C FFI to libnetsnmp +RUN apk add --no-cache ca-certificates iputils openssl net-snmp-libs # Copy binary from builder COPY --from=builder /tmp/towerops-agent /usr/local/bin/towerops-agent diff --git a/build.rs b/build.rs index 3d17602..f6dfc46 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,15 @@ fn main() { // Compile protobuf definitions prost_build::compile_protos(&["proto/agent.proto"], &["proto/"]).unwrap(); + // Compile C helper for SNMP + cc::Build::new() + .file("native/snmp_helper.c") + .include("native") + .compile("snmp_helper"); + + // Link against netsnmp library + println!("cargo:rustc-link-lib=netsnmp"); + // Inject git-based version if available, otherwise use Cargo.toml version // This ensures the binary version matches the Docker image tag let version = get_version(); diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..570c2a9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770537093, + "narHash": "sha256-pF1quXG5wsgtyuPOHcLfYg/ft/QMr8NnX0i6tW2187s=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770520253, + "narHash": "sha256-6rWuHgSENXKnC6HGGAdRolQrnp/8IzscDn7FQEo1uEQ=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "ebb8a141f60bb0ec33836333e0ca7928a072217f", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ef6064e --- /dev/null +++ b/flake.nix @@ -0,0 +1,53 @@ +{ + description = "towerops-agent - Rust SNMP polling agent"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { inherit system overlays; }; + + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" ]; + }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = [ + rustToolchain + pkgs.protobuf + pkgs.net-snmp + pkgs.openssl + pkgs.pkg-config + pkgs.git + ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + pkgs.apple-sdk_15 + ]; + + env = { + PROTOC = "${pkgs.protobuf}/bin/protoc"; + # Help netsnmp-sys find the library + NET_SNMP_CONFIG = "${pkgs.net-snmp}/bin/net-snmp-config"; + # Help cargo find OpenSSL for linking + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + OPENSSL_DIR = "${pkgs.openssl.dev}"; + OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include"; + }; + + shellHook = '' + # Set RUSTFLAGS to find OpenSSL libraries at link time + export RUSTFLAGS="-L ${pkgs.openssl.out}/lib" + ''; + }; + }); +} diff --git a/native/snmp_helper.c b/native/snmp_helper.c new file mode 100644 index 0000000..7d32334 --- /dev/null +++ b/native/snmp_helper.c @@ -0,0 +1,478 @@ +#include "snmp_helper.h" +#include +#include +#include +#include +#include + +static pthread_once_t init_once = PTHREAD_ONCE_INIT; + +static void init_snmp_once(void) { + // Initialize the SNMP library + init_snmp("towerops-agent"); + + // Configure to output numeric OIDs only (no MIB names) + // This ensures OIDs are in format "1.3.6.1.2.1.1.1.0" not "SNMPv2-MIB::sysDescr.0" + netsnmp_ds_set_int(NETSNMP_DS_LIBRARY_ID, NETSNMP_DS_LIB_OID_OUTPUT_FORMAT, + NETSNMP_OID_OUTPUT_NUMERIC); +} + +int snmp_init_library(void) { + pthread_once(&init_once, init_snmp_once); + return 0; +} + +void* snmp_open_session( + const char* ip_address, + uint16_t port, + const char* community, + int version, + int64_t timeout_us, + int retries, + const snmp_v3_config_t* v3_config, + char* error_buf, + size_t error_buf_len +) { + struct snmp_session session, *sess_handle; + + // Ensure library is initialized + snmp_init_library(); + + // Initialize session structure + snmp_sess_init(&session); + + // Set peer address with port (e.g., "192.168.1.1:161") + // This is the modern way - remote_port field is deprecated + char peername[256]; + snprintf(peername, sizeof(peername), "%s:%u", ip_address, port); + session.peername = strdup(peername); + if (!session.peername) { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Failed to allocate memory for peer address"); + } + return NULL; + } + + // Set SNMP version + switch (version) { + case 1: + session.version = SNMP_VERSION_1; + break; + case 2: + session.version = SNMP_VERSION_2c; + break; + case 3: + session.version = SNMP_VERSION_3; + break; + default: + free(session.peername); + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Unsupported SNMP version: %d", version); + } + return NULL; + } + + // Configure version-specific parameters + if (version == 3) { + // SNMPv3 configuration + if (!v3_config || !v3_config->username) { + free(session.peername); + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "SNMPv3 requires username"); + } + return NULL; + } + + // Set security name (username) + session.securityName = strdup(v3_config->username); + session.securityNameLen = strlen(v3_config->username); + + // Set security level + if (v3_config->security_level) { + if (strcmp(v3_config->security_level, "authPriv") == 0) { + session.securityLevel = SNMP_SEC_LEVEL_AUTHPRIV; + } else if (strcmp(v3_config->security_level, "authNoPriv") == 0) { + session.securityLevel = SNMP_SEC_LEVEL_AUTHNOPRIV; + } else { + session.securityLevel = SNMP_SEC_LEVEL_NOAUTH; + } + } else { + session.securityLevel = SNMP_SEC_LEVEL_NOAUTH; + } + + // Set authentication protocol and password + if (session.securityLevel >= SNMP_SEC_LEVEL_AUTHNOPRIV) { + if (v3_config->auth_password) { + session.securityAuthProto = usmHMACMD5AuthProtocol; + session.securityAuthProtoLen = USM_AUTH_PROTO_MD5_LEN; + + if (v3_config->auth_protocol) { + if (strcmp(v3_config->auth_protocol, "SHA") == 0) { + session.securityAuthProto = usmHMACSHA1AuthProtocol; + session.securityAuthProtoLen = USM_AUTH_PROTO_SHA_LEN; + } + } + + session.securityAuthKeyLen = USM_AUTH_KU_LEN; + if (generate_Ku(session.securityAuthProto, + session.securityAuthProtoLen, + (u_char*)v3_config->auth_password, + strlen(v3_config->auth_password), + session.securityAuthKey, + &session.securityAuthKeyLen) != SNMPERR_SUCCESS) { + free(session.peername); + free((void*)session.securityName); + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Failed to generate auth key"); + } + return NULL; + } + } + } + + // Set privacy protocol and password + if (session.securityLevel >= SNMP_SEC_LEVEL_AUTHPRIV) { + if (v3_config->priv_password) { + session.securityPrivProto = usmDESPrivProtocol; + session.securityPrivProtoLen = USM_PRIV_PROTO_DES_LEN; + + if (v3_config->priv_protocol) { + if (strcmp(v3_config->priv_protocol, "AES") == 0) { + session.securityPrivProto = usmAESPrivProtocol; + session.securityPrivProtoLen = USM_PRIV_PROTO_AES_LEN; + } + } + + session.securityPrivKeyLen = USM_PRIV_KU_LEN; + if (generate_Ku(session.securityAuthProto, + session.securityAuthProtoLen, + (u_char*)v3_config->priv_password, + strlen(v3_config->priv_password), + session.securityPrivKey, + &session.securityPrivKeyLen) != SNMPERR_SUCCESS) { + free(session.peername); + free((void*)session.securityName); + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Failed to generate priv key"); + } + return NULL; + } + } + } + } else { + // v1/v2c: Set community string + if (community && community[0]) { + session.community = (u_char*)strdup(community); + if (!session.community) { + free(session.peername); + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Failed to allocate memory for community string"); + } + return NULL; + } + session.community_len = strlen(community); + } + } + + // Set timeout and retries + session.timeout = timeout_us; + session.retries = retries; + + // Open the session + sess_handle = snmp_sess_open(&session); + + // Clean up temporary allocations + free(session.peername); + if (session.community) { + // Zero out community string before freeing + memset((void*)session.community, 0, session.community_len); + free((void*)session.community); + } + if (session.securityName) { + free((void*)session.securityName); + } + + // Check for errors + if (!sess_handle) { + if (error_buf && error_buf_len > 0) { + // Get error message from library + int liberr, syserr; + char *errstr; + snmp_error(&session, &liberr, &syserr, &errstr); + snprintf(error_buf, error_buf_len, "%s", errstr); + free(errstr); + } + return NULL; + } + + return sess_handle; +} + +void snmp_close_session(void* sess_handle) { + if (sess_handle) { + snmp_sess_close(sess_handle); + } +} + +int snmp_get( + void* sess_handle, + const char* oid_str, + void* value_buf, + size_t value_buf_len, + int* value_type, + char* error_buf, + size_t error_buf_len +) { + if (!sess_handle || !oid_str || !value_buf || !value_type) { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Invalid parameters"); + } + return -1; + } + + oid anOID[MAX_OID_LEN]; + size_t anOID_len = MAX_OID_LEN; + + // Parse OID string + if (!read_objid(oid_str, anOID, &anOID_len)) { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Failed to parse OID: %s", oid_str); + } + return -1; + } + + // Create GET PDU + struct snmp_pdu *pdu = snmp_pdu_create(SNMP_MSG_GET); + if (!pdu) { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Failed to create PDU"); + } + return -1; + } + + // Add OID to PDU + snmp_add_null_var(pdu, anOID, anOID_len); + + // Send request + struct snmp_pdu *response = NULL; + int status = snmp_sess_synch_response(sess_handle, pdu, &response); + + if (status != STAT_SUCCESS || !response) { + if (error_buf && error_buf_len > 0) { + if (status == STAT_TIMEOUT) { + snprintf(error_buf, error_buf_len, "Request timeout"); + } else { + snprintf(error_buf, error_buf_len, "Request failed"); + } + } + if (response) { + snmp_free_pdu(response); + } + return -1; + } + + // Extract value from response + int result = -1; + if (response->variables) { + struct variable_list *var = response->variables; + *value_type = var->type; + + switch (var->type) { + case ASN_OCTET_STR: + case ASN_OPAQUE: + case ASN_IPADDRESS: + if (var->val_len <= value_buf_len) { + memcpy(value_buf, var->val.string, var->val_len); + result = (int)var->val_len; + } else { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Buffer too small"); + } + } + break; + + case ASN_INTEGER: + case ASN_COUNTER: + case ASN_GAUGE: + case ASN_TIMETICKS: + case ASN_UINTEGER: + if (sizeof(long) <= value_buf_len) { + *((long*)value_buf) = *var->val.integer; + result = sizeof(long); + } + break; + + case ASN_COUNTER64: + if (sizeof(struct counter64) <= value_buf_len) { + memcpy(value_buf, var->val.counter64, sizeof(struct counter64)); + result = sizeof(struct counter64); + } + break; + + case ASN_OBJECT_ID: + // Convert OID to string representation + { + char oid_buf[256]; + snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, var->val_len / sizeof(oid)); + size_t oid_str_len = strlen(oid_buf); + if (oid_str_len <= value_buf_len) { + memcpy(value_buf, oid_buf, oid_str_len); + result = (int)oid_str_len; + } else { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Buffer too small for OID string"); + } + } + } + break; + + case ASN_NULL: + // NULL values are valid but contain no data + result = 0; + break; + + default: + // Unknown type + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Unsupported type: %d", var->type); + } + break; + } + } + + snmp_free_pdu(response); + return result; +} + +int snmp_walk( + void* sess_handle, + const char* oid_str, + snmp_walk_result_t* results, + size_t max_results, + size_t* num_results, + char* error_buf, + size_t error_buf_len +) { + if (!sess_handle || !oid_str || !results || !num_results) { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Invalid parameters"); + } + return -1; + } + + oid root[MAX_OID_LEN]; + size_t rootlen = MAX_OID_LEN; + + // Parse starting OID + if (!read_objid(oid_str, root, &rootlen)) { + if (error_buf && error_buf_len > 0) { + snprintf(error_buf, error_buf_len, "Failed to parse OID: %s", oid_str); + } + return -1; + } + + oid name[MAX_OID_LEN]; + size_t name_length = rootlen; + memcpy(name, root, rootlen * sizeof(oid)); + + *num_results = 0; + int running = 1; + + while (running && *num_results < max_results) { + // Create GETNEXT PDU + struct snmp_pdu *pdu = snmp_pdu_create(SNMP_MSG_GETNEXT); + if (!pdu) { + break; + } + + snmp_add_null_var(pdu, name, name_length); + + // Send request + struct snmp_pdu *response = NULL; + int status = snmp_sess_synch_response(sess_handle, pdu, &response); + + if (status != STAT_SUCCESS || !response || !response->variables) { + if (response) { + snmp_free_pdu(response); + } + break; + } + + struct variable_list *var = response->variables; + + // Check if we've walked past the root OID + if (var->name_length < rootlen || + snmp_oid_ncompare(var->name, var->name_length, root, rootlen, rootlen) != 0) { + snmp_free_pdu(response); + break; + } + + // Store result + snmp_walk_result_t *res = &results[*num_results]; + + // Convert OID to string + snprint_objid(res->oid, sizeof(res->oid), var->name, var->name_length); + + // Store value + res->value_type = var->type; + res->value_len = 0; + + switch (var->type) { + case ASN_OCTET_STR: + case ASN_OPAQUE: + case ASN_IPADDRESS: + if (var->val_len <= sizeof(res->value)) { + memcpy(res->value, var->val.string, var->val_len); + res->value_len = var->val_len; + } + break; + + case ASN_OBJECT_ID: + // Convert OID to string representation + { + char oid_buf[256]; + snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, var->val_len / sizeof(oid)); + size_t oid_str_len = strlen(oid_buf); + if (oid_str_len < sizeof(res->value)) { + memcpy(res->value, oid_buf, oid_str_len); + res->value_len = oid_str_len; + } + } + break; + + case ASN_INTEGER: + case ASN_COUNTER: + case ASN_GAUGE: + case ASN_TIMETICKS: + case ASN_UINTEGER: + if (sizeof(long) <= sizeof(res->value)) { + *((long*)res->value) = *var->val.integer; + res->value_len = sizeof(long); + } + break; + + case ASN_COUNTER64: + if (sizeof(struct counter64) <= sizeof(res->value)) { + memcpy(res->value, var->val.counter64, sizeof(struct counter64)); + res->value_len = sizeof(struct counter64); + } + break; + } + + if (res->value_len > 0) { + (*num_results)++; + } + + // Update OID for next iteration + if (var->name_length <= MAX_OID_LEN) { + memcpy(name, var->name, var->name_length * sizeof(oid)); + name_length = var->name_length; + } else { + running = 0; + } + + snmp_free_pdu(response); + } + + return 0; +} diff --git a/native/snmp_helper.h b/native/snmp_helper.h new file mode 100644 index 0000000..cc3f936 --- /dev/null +++ b/native/snmp_helper.h @@ -0,0 +1,111 @@ +#ifndef SNMP_HELPER_H +#define SNMP_HELPER_H + +#include +#include + +/** + * Initialize the SNMP library (call once at startup) + * Returns 0 on success, -1 on failure + */ +int snmp_init_library(void); + +/** + * SNMPv3 configuration + */ +typedef struct { + const char* username; + const char* auth_password; + const char* priv_password; + const char* auth_protocol; // "MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512" + const char* priv_protocol; // "DES", "AES", "AES-192", "AES-256" + const char* security_level; // "noAuthNoPriv", "authNoPriv", "authPriv" +} snmp_v3_config_t; + +/** + * Open an SNMP session + * + * @param ip_address IP address of the device + * @param port UDP port (usually 161) + * @param community Community string for SNMPv1/v2c (ignored for v3) + * @param version SNMP version: 1 (SNMPv1), 2 (SNMPv2c), 3 (SNMPv3) + * @param timeout_us Timeout in microseconds + * @param retries Number of retries + * @param v3_config SNMPv3 configuration (NULL for v1/v2c) + * @param error_buf Buffer for error messages (can be NULL) + * @param error_buf_len Length of error buffer + * @return Session handle on success, NULL on failure + */ +void* snmp_open_session( + const char* ip_address, + uint16_t port, + const char* community, + int version, + int64_t timeout_us, + int retries, + const snmp_v3_config_t* v3_config, + char* error_buf, + size_t error_buf_len +); + +/** + * Close an SNMP session + * @param sess_handle Session handle from snmp_open_session + */ +void snmp_close_session(void* sess_handle); + +/** + * Perform SNMP GET operation + * + * @param sess_handle Session handle from snmp_open_session + * @param oid_str OID string (e.g., "1.3.6.1.2.1.1.1.0") + * @param value_buf Buffer for result value + * @param value_buf_len Length of value buffer + * @param value_type Output: type of value (see ASN_* constants) + * @param error_buf Buffer for error messages (can be NULL) + * @param error_buf_len Length of error buffer + * @return 0 on success, -1 on error + */ +int snmp_get( + void* sess_handle, + const char* oid_str, + void* value_buf, + size_t value_buf_len, + int* value_type, + char* error_buf, + size_t error_buf_len +); + +/** + * Result from SNMP WALK operation + */ +typedef struct { + char oid[256]; + uint8_t value[1024]; + size_t value_len; + int value_type; +} snmp_walk_result_t; + +/** + * Perform SNMP WALK operation + * + * @param sess_handle Session handle from snmp_open_session + * @param oid_str Starting OID string + * @param results Buffer for results + * @param max_results Maximum number of results to return + * @param num_results Output: actual number of results + * @param error_buf Buffer for error messages (can be NULL) + * @param error_buf_len Length of error buffer + * @return 0 on success, -1 on error + */ +int snmp_walk( + void* sess_handle, + const char* oid_str, + snmp_walk_result_t* results, + size_t max_results, + size_t* num_results, + char* error_buf, + size_t error_buf_len +); + +#endif // SNMP_HELPER_H diff --git a/src/main.rs b/src/main.rs index 3c41a4a..116be61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -367,7 +367,6 @@ async fn async_main() { /// Run SNMPv3 test async fn run_snmpv3_test(args: &Args) { - use secret::SecretString; use snmp::V3Config; let ip = args.snmpv3_ip.as_ref().expect("--snmpv3-ip required"); @@ -397,12 +396,12 @@ async fn run_snmpv3_test(args: &Args) { let v3_config = V3Config { username: username.clone(), auth_password: if !auth_pass.is_empty() { - Some(SecretString::new(auth_pass.clone())) + Some(zeroize::Zeroizing::new(auth_pass.clone())) } else { None }, priv_password: if !priv_pass.is_empty() { - Some(SecretString::new(priv_pass.clone())) + Some(zeroize::Zeroizing::new(priv_pass.clone())) } else { None }, diff --git a/src/snmp/client.rs b/src/snmp/client.rs index cea85b8..0ab9fa5 100644 --- a/src/snmp/client.rs +++ b/src/snmp/client.rs @@ -1,14 +1,70 @@ use super::types::{SnmpError, SnmpResult, SnmpValue}; -use crate::secret::SecretString; -use snmp2::SyncSession; -use std::time::Duration; +use netsnmp_sys::*; +use std::ffi::{CStr, CString}; +use std::ptr; +use zeroize::{Zeroize, Zeroizing}; -// SNMP timeout in seconds - increased to 120s for SNMPv3 operations -// SNMPv3 has significant encryption/auth overhead, especially for large walks -// Full discovery can take 50+ seconds with complete MikroTik OID tree traversal -const SNMP_TIMEOUT_SECS: u64 = 120; +type SecretString = Zeroizing; -/// SNMPv3 configuration bundle +const SNMP_TIMEOUT_SECS: i64 = 10; +const SNMP_RETRIES: i32 = 2; +const MAX_OID_LEN: usize = 128; + +// C structs and functions +#[repr(C)] +#[derive(Clone)] +struct SnmpWalkResult { + oid: [u8; 256], + value: [u8; 1024], + value_len: usize, + value_type: i32, +} + +#[repr(C)] +struct SnmpV3ConfigC { + username: *const i8, + auth_password: *const i8, + priv_password: *const i8, + auth_protocol: *const i8, + priv_protocol: *const i8, + security_level: *const i8, +} + +extern "C" { + fn snmp_init_library() -> i32; + fn snmp_open_session( + ip_address: *const i8, + port: u16, + community: *const i8, + version: i32, + timeout_us: i64, + retries: i32, + v3_config: *const SnmpV3ConfigC, + error_buf: *mut i8, + error_buf_len: usize, + ) -> *mut std::ffi::c_void; + fn snmp_close_session(sess_handle: *mut std::ffi::c_void); + fn snmp_get( + sess_handle: *mut std::ffi::c_void, + oid_str: *const i8, + value_buf: *mut std::ffi::c_void, + value_buf_len: usize, + value_type: *mut i32, + error_buf: *mut i8, + error_buf_len: usize, + ) -> i32; + fn snmp_walk( + sess_handle: *mut std::ffi::c_void, + oid_str: *const i8, + results: *mut SnmpWalkResult, + max_results: usize, + num_results: *mut usize, + error_buf: *mut i8, + error_buf_len: usize, + ) -> i32; +} + +/// SNMPv3 configuration #[derive(Clone)] pub struct V3Config { pub username: String, @@ -38,12 +94,16 @@ impl std::fmt::Debug for V3Config { } } -/// SNMP client for polling devices +/// SNMP client for polling devices using libnetsnmp #[derive(Debug, Clone, Copy)] pub struct SnmpClient; impl SnmpClient { pub fn new() -> Self { + // Initialize libnetsnmp via C helper + unsafe { + snmp_init_library(); + } Self } @@ -57,163 +117,44 @@ impl SnmpClient { oid: &str, v3_config: Option, ) -> SnmpResult { - 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); - let community = community.as_bytes().to_vec(); - let version_num = parse_snmp_version(version)?; + let ip_address = ip_address.to_string(); + let community = community.to_string(); + let version = version.to_string(); + let oid = oid.to_string(); // Run SNMP operation in blocking thread pool - let result = tokio::task::spawn_blocking(move || { - // 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 (with retry for v3 engine ID discovery) - let mut response = match session.get(&oid_parsed) { - Ok(resp) => resp, - Err(snmp2::Error::AuthUpdated) => { - tracing::debug!("SNMPv3 engine ID discovered, retrying request"); - // Retry after engine ID discovery - session.get(&oid_parsed).map_err(map_snmp_error)? - } - Err(e) => return Err(map_snmp_error(e)), - }; - - // Check for error status - if response.error_status != 0 { - return Err(SnmpError::RequestFailed(format!( - "SNMP error status: {}", - response.error_status - ))); - } - - // Extract first varbind - if let Some((_name, value)) = response.varbinds.next() { - return convert_value(value); - } - - Err(SnmpError::RequestFailed("No varbinds in response".into())) + tokio::task::spawn_blocking(move || { + let session = SnmpSession::new(&ip_address, port, &community, &version, v3_config)?; + session.get(&oid) }) .await - .map_err(|e| SnmpError::RequestFailed(format!("Task join error: {}", e)))??; - - Ok(result) + .map_err(|e| SnmpError::RequestFailed(format!("Task join error: {}", e)))? } - /// Perform an SNMP WALK operation to get multiple values - #[allow(dead_code)] // Ready for use but not yet called + /// Perform an SNMP WALK operation pub async fn walk( &self, ip_address: &str, community: &str, version: &str, port: u16, - base_oid: &str, + oid: &str, v3_config: Option, ) -> SnmpResult> { - 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); - let community = community.as_bytes().to_vec(); - let version_num = parse_snmp_version(version)?; + let ip_address = ip_address.to_string(); + let community = community.to_string(); + let version = version.to_string(); + let oid = oid.to_string(); - // Run SNMP walk in blocking thread pool - let results = tokio::task::spawn_blocking(move || { - // 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_parsed; - - // Perform GETNEXT repeatedly until we leave the base OID tree - loop { - let oid_to_query = current_oid.clone(); - - // Extract data we need from the response immediately - let (error_status, varbind_data) = { - // Perform GETNEXT request (with retry for v3 engine ID discovery) - let response = match session.getnext(&oid_to_query) { - Ok(resp) => resp, - Err(snmp2::Error::AuthUpdated) => { - tracing::debug!("SNMPv3 engine ID discovered, retrying getnext"); - // Retry after engine ID discovery - session.getnext(&oid_to_query).map_err(map_snmp_error)? - } - Err(e) => return Err(map_snmp_error(e)), - }; - 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 error_status != 0 { - break; - } - - if varbind_data.is_empty() { - break; - } - - // 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 value - let converted_value = convert_value(value)?; - - results.push((name_str.clone(), converted_value)); - - // 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)) - })?; - } - } - - Ok(results) + // Run SNMP operation in blocking thread pool + tokio::task::spawn_blocking(move || { + let session = SnmpSession::new(&ip_address, port, &community, &version, v3_config)?; + session.walk(&oid) }) .await - .map_err(|e| SnmpError::RequestFailed(format!("Task join error: {}", e)))??; - - Ok(results) + .map_err(|e| SnmpError::RequestFailed(format!("Task join error: {}", e)))? } } @@ -223,387 +164,695 @@ impl Default for SnmpClient { } } -/// 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(); +/// RAII wrapper for SNMP session +struct SnmpSession { + sess_handle: *mut std::ffi::c_void, +} - 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, 3, v3", +impl SnmpSession { + fn new( + ip_address: &str, + port: u16, + community: &str, + version: &str, + v3_config: Option, + ) -> SnmpResult { + tracing::debug!( + "Creating SNMP session: ip={}, port={}, version={}", + ip_address, + port, version - ))), - } -} + ); -/// Parse authentication protocol string to snmp2 AuthProtocol enum -fn parse_auth_protocol(protocol: &str) -> SnmpResult { - use snmp2::v3::AuthProtocol; + // Parse SNMP version + let version_num = match version { + "1" | "v1" => 1, + "2c" | "v2c" | "2" => 2, + "3" | "v3" => 3, + _ => { + return Err(SnmpError::RequestFailed(format!( + "Unsupported SNMP version: {}", + version + ))) + } + }; - 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 - ))), - } -} + unsafe { + let ip_cstr = CString::new(ip_address) + .map_err(|_| SnmpError::RequestFailed("Invalid IP address".into()))?; + let comm_cstr = CString::new(community) + .map_err(|_| SnmpError::RequestFailed("Invalid community string".into()))?; -/// Parse privacy protocol string to snmp2 Cipher enum -fn parse_priv_protocol(protocol: &str) -> SnmpResult { - use snmp2::v3::Cipher; + // Prepare SNMPv3 config if needed + let (v3_c_config, _v3_strings) = if let Some(ref v3) = v3_config { + let username_cstr = CString::new(v3.username.as_str()) + .map_err(|_| SnmpError::RequestFailed("Invalid username".into()))?; + let auth_pass_cstr = v3 + .auth_password + .as_ref() + .map(|p| CString::new(p.as_str())) + .transpose() + .map_err(|_| SnmpError::RequestFailed("Invalid auth password".into()))?; + let priv_pass_cstr = v3 + .priv_password + .as_ref() + .map(|p| CString::new(p.as_str())) + .transpose() + .map_err(|_| SnmpError::RequestFailed("Invalid priv password".into()))?; + let auth_proto_cstr = v3 + .auth_protocol + .as_ref() + .map(|p| CString::new(p.as_str())) + .transpose() + .map_err(|_| SnmpError::RequestFailed("Invalid auth protocol".into()))?; + let priv_proto_cstr = v3 + .priv_protocol + .as_ref() + .map(|p| CString::new(p.as_str())) + .transpose() + .map_err(|_| SnmpError::RequestFailed("Invalid priv protocol".into()))?; + let sec_level_cstr = CString::new(v3.security_level.as_str()) + .map_err(|_| SnmpError::RequestFailed("Invalid security level".into()))?; - 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 - ))), - } -} + let config = SnmpV3ConfigC { + username: username_cstr.as_ptr(), + auth_password: auth_pass_cstr + .as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(ptr::null()), + priv_password: priv_pass_cstr + .as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(ptr::null()), + auth_protocol: auth_proto_cstr + .as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(ptr::null()), + priv_protocol: priv_proto_cstr + .as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(ptr::null()), + security_level: sec_level_cstr.as_ptr(), + }; -/// 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; + ( + Some(config), + Some(( + username_cstr, + auth_pass_cstr, + priv_pass_cstr, + auth_proto_cstr, + priv_proto_cstr, + sec_level_cstr, + )), + ) + } else { + (None, None) + }; - 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) - } -} + let mut error_buf = [0i8; 512]; + let sess_handle = snmp_open_session( + ip_cstr.as_ptr(), + port, + comm_cstr.as_ptr(), + version_num, + SNMP_TIMEOUT_SECS * 1_000_000, + SNMP_RETRIES, + v3_c_config + .as_ref() + .map(|c| c as *const _) + .unwrap_or(ptr::null()), + error_buf.as_mut_ptr(), + error_buf.len(), + ); -/// Create SNMPv3 session with authentication and/or privacy -fn create_v3_session(addr: &str, config: &V3Config) -> SnmpResult { - use snmp2::v3::{Auth, Security}; + // Zeroize sensitive data + drop(comm_cstr); + drop(_v3_strings); // Drops all v3 CStrings + if !community.is_empty() { + let mut community_copy = community.to_string(); + community_copy.zeroize(); + } - let username = config.username.as_bytes(); - let security_level = config.security_level.trim().to_lowercase(); + if sess_handle.is_null() { + let err_msg = CStr::from_ptr(error_buf.as_ptr()) + .to_string_lossy() + .to_string(); + tracing::error!("SNMP session open failed: {}", err_msg); - // Build the Auth enum based on security level - let auth = match security_level.as_str() { - "noauthnopriv" | "" => Auth::NoAuthNoPriv, + return Err( + if err_msg.contains("Unknown host") || err_msg.contains("Connection refused") { + SnmpError::NetworkUnreachable + } else { + SnmpError::RequestFailed(err_msg) + }, + ); + } - "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_ref() - .map(|s| s.expose()) - .ok_or_else(|| { - SnmpError::RequestFailed("Auth password required for authNoPriv".into()) - })?; - Auth::AuthNoPriv + tracing::debug!("SNMP session opened successfully: {:?}", sess_handle); + Ok(Self { sess_handle }) } + } - "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_ref() - .map(|s| s.expose()) - .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_ref() - .map(|s| s.expose()) - .ok_or_else(|| { - SnmpError::RequestFailed("Priv password required for authPriv".into()) - })?; + fn get(&self, oid: &str) -> SnmpResult { + unsafe { + let oid_cstr = CString::new(oid) + .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", oid)))?; - Auth::AuthPriv { - cipher, - privacy_password: priv_pass.as_bytes().to_vec(), + let mut value_buf = [0u8; 1024]; + let mut value_type: i32 = 0; + let mut error_buf = [0i8; 512]; + + let result = snmp_get( + self.sess_handle, + oid_cstr.as_ptr(), + value_buf.as_mut_ptr() as *mut _, + value_buf.len(), + &mut value_type, + error_buf.as_mut_ptr(), + error_buf.len(), + ); + + if result < 0 { + let err_msg = CStr::from_ptr(error_buf.as_ptr()) + .to_string_lossy() + .to_string(); + + if err_msg.contains("timeout") { + return Err(SnmpError::Timeout); + } else { + return Err(SnmpError::RequestFailed(err_msg)); + } + } + + // Parse value based on type + let value_len = result as usize; + match value_type as u8 { + ASN_OCTET_STR => { + // Try to convert to UTF-8 string first + match String::from_utf8(value_buf[..value_len].to_vec()) { + Ok(s) => Ok(SnmpValue::String(s)), + Err(_) => Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())), + } + } + ASN_OPAQUE => { + Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())) + } + ASN_IPADDRESS => { + // IP addresses are 4 bytes - convert to dotted notation + if value_len == 4 { + Ok(SnmpValue::IpAddress(format!( + "{}.{}.{}.{}", + value_buf[0], value_buf[1], value_buf[2], value_buf[3] + ))) + } else { + Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())) + } + } + ASN_OBJECT_ID => { + // Object IDs are returned as strings in dotted notation from C + match String::from_utf8(value_buf[..value_len].to_vec()) { + Ok(s) => Ok(SnmpValue::Oid(s)), + Err(_) => Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())), + } + } + ASN_INTEGER | ASN_COUNTER | ASN_GAUGE | ASN_TIMETICKS | ASN_UINTEGER => { + if value_len >= std::mem::size_of::() { + // Use unaligned read to avoid alignment issues from C + let value = (value_buf.as_ptr() as *const i64).read_unaligned(); + Ok(SnmpValue::Integer(value)) + } else { + Err(SnmpError::RequestFailed("Invalid integer size".into())) + } + } + ASN_COUNTER64 => { + if value_len >= 8 { + let high = u32::from_ne_bytes([ + value_buf[0], + value_buf[1], + value_buf[2], + value_buf[3], + ]); + let low = u32::from_ne_bytes([ + value_buf[4], + value_buf[5], + value_buf[6], + value_buf[7], + ]); + Ok(SnmpValue::Counter64((high as u64) << 32 | low as u64)) + } else { + Err(SnmpError::RequestFailed("Invalid counter64 size".into())) + } + } + _ => Err(SnmpError::RequestFailed(format!( + "Unsupported type: {}", + value_type + ))), } } + } - _ => { - return Err(SnmpError::RequestFailed(format!( - "Unsupported security level: '{}'", - config.security_level - ))) + fn walk(&self, start_oid: &str) -> SnmpResult> { + unsafe { + let oid_cstr = CString::new(start_oid) + .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", start_oid)))?; + + // Allocate buffer for results (max 10000 entries) + const MAX_RESULTS: usize = 10000; + let mut results_buf: Vec = vec![ + SnmpWalkResult { + oid: [0; 256], + value: [0; 1024], + value_len: 0, + value_type: 0, + }; + MAX_RESULTS + ]; + + let mut num_results: usize = 0; + let mut error_buf = [0i8; 512]; + + let result = snmp_walk( + self.sess_handle, + oid_cstr.as_ptr(), + results_buf.as_mut_ptr(), + MAX_RESULTS, + &mut num_results, + error_buf.as_mut_ptr(), + error_buf.len(), + ); + + if result < 0 { + let err_msg = CStr::from_ptr(error_buf.as_ptr()) + .to_string_lossy() + .to_string(); + return Err(SnmpError::RequestFailed(err_msg)); + } + + // Convert C results to Rust + let mut parsed_results = Vec::with_capacity(num_results); + for i in 0..num_results { + let res = &results_buf[i]; + + // Parse OID string + let oid_str = CStr::from_ptr(res.oid.as_ptr() as *const i8) + .to_string_lossy() + .to_string(); + + // Parse value + if res.value_len > 0 { + let value = match res.value_type as u8 { + ASN_OCTET_STR => { + // Try UTF-8 conversion first + match String::from_utf8(res.value[..res.value_len].to_vec()) { + Ok(s) => SnmpValue::String(s), + Err(_) => SnmpValue::OctetString(res.value[..res.value_len].to_vec()), + } + } + ASN_OPAQUE => SnmpValue::OctetString(res.value[..res.value_len].to_vec()), + ASN_IPADDRESS => { + if res.value_len == 4 { + SnmpValue::IpAddress(format!( + "{}.{}.{}.{}", + res.value[0], res.value[1], res.value[2], res.value[3] + )) + } else { + SnmpValue::OctetString(res.value[..res.value_len].to_vec()) + } + } + ASN_OBJECT_ID => { + match String::from_utf8(res.value[..res.value_len].to_vec()) { + Ok(s) => SnmpValue::Oid(s), + Err(_) => SnmpValue::OctetString(res.value[..res.value_len].to_vec()), + } + } + ASN_INTEGER | ASN_COUNTER | ASN_GAUGE | ASN_TIMETICKS | ASN_UINTEGER => { + if res.value_len >= std::mem::size_of::() { + // Use unaligned read to avoid alignment issues from C + let val = (res.value.as_ptr() as *const i64).read_unaligned(); + SnmpValue::Integer(val) + } else { + continue; // Skip invalid values + } + } + ASN_COUNTER64 => { + if res.value_len >= 8 { + let high = u32::from_ne_bytes([ + res.value[0], + res.value[1], + res.value[2], + res.value[3], + ]); + let low = u32::from_ne_bytes([ + res.value[4], + res.value[5], + res.value[6], + res.value[7], + ]); + SnmpValue::Counter64((high as u64) << 32 | low as u64) + } else { + continue; + } + } + _ => continue, // Skip unsupported types + }; + + parsed_results.push((oid_str, value)); + } + } + + Ok(parsed_results) } - }; - - // Get auth password (empty for noAuthNoPriv) - let auth_password = config - .auth_password - .as_ref() - .map(|s| s.expose()) - .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; - - let mut session = SyncSession::new_v3(addr, timeout, req_id, security).map_err(|e| { - SnmpError::RequestFailed(format!("SNMPv3 session creation failed: {:?}", e)) - })?; - - // For authPriv/authNoPriv, perform engine ID discovery using session.init() - if needs_auth_protocol { - session.init().map_err(|e| { - SnmpError::RequestFailed(format!("Engine ID discovery failed: {:?}", e)) - })?; - } - - Ok(session) } -/// Convert snmp2 crate's Value to our SnmpValue -fn convert_value(value: snmp2::Value) -> SnmpResult { - use snmp2::Value as V; +impl Drop for SnmpSession { + fn drop(&mut self) { + unsafe { + if !self.sess_handle.is_null() { + snmp_close_session(self.sess_handle); + } + } + } +} - match value { - V::Integer(i) => Ok(SnmpValue::Integer(i)), - V::OctetString(s) => Ok(SnmpValue::String(String::from_utf8_lossy(s).into_owned())), - V::Counter32(c) => Ok(SnmpValue::Counter32(c)), - V::Counter64(c) => Ok(SnmpValue::Counter64(c)), - V::Unsigned32(u) => Ok(SnmpValue::Gauge32(u)), - V::Timeticks(t) => Ok(SnmpValue::TimeTicks(t)), - V::IpAddress(ip) => Ok(SnmpValue::IpAddress(format!( - "{}.{}.{}.{}", - ip[0], ip[1], ip[2], ip[3] - ))), +// Helper functions + +unsafe fn parse_variable(var: *mut Struct_variable_list) -> SnmpResult { + if var.is_null() { + return Err(SnmpError::RequestFailed("Null variable".into())); + } + + let var_type = (*var)._type; + + match var_type { + ASN_OCTET_STR => { + let len = (*var).val_len; + let string_ptr_ptr = (*var).val.string(); + let string_ptr = *string_ptr_ptr; + let data = std::slice::from_raw_parts(string_ptr, len); + Ok(SnmpValue::OctetString(data.to_vec())) + } + ASN_INTEGER => { + let int_ptr_ptr = (*var).val.integer(); + let int_ptr = *int_ptr_ptr; + let value = *int_ptr; + Ok(SnmpValue::Integer(value)) + } + ASN_COUNTER => { + let int_ptr_ptr = (*var).val.integer(); + let int_ptr = *int_ptr_ptr; + let value = *int_ptr as u32; + Ok(SnmpValue::Counter32(value)) + } + ASN_GAUGE => { + let int_ptr_ptr = (*var).val.integer(); + let int_ptr = *int_ptr_ptr; + let value = *int_ptr as u32; + Ok(SnmpValue::Gauge32(value)) + } + ASN_TIMETICKS => { + let int_ptr_ptr = (*var).val.integer(); + let int_ptr = *int_ptr_ptr; + let value = *int_ptr as u32; + Ok(SnmpValue::TimeTicks(value)) + } + ASN_COUNTER64 => { + let counter64_ptr_ptr = (*var).val.counter64(); + let counter64_ptr = *counter64_ptr_ptr; + let high = (*counter64_ptr).high; + let low = (*counter64_ptr).low; + let value = (high << 32) | low; + Ok(SnmpValue::Counter64(value)) + } + ASN_IPADDRESS => { + let len = (*var).val_len; + let string_ptr_ptr = (*var).val.string(); + let string_ptr = *string_ptr_ptr; + let data = std::slice::from_raw_parts(string_ptr, len); + if len == 4 { + let ip_str = format!("{}.{}.{}.{}", data[0], data[1], data[2], data[3]); + Ok(SnmpValue::IpAddress(ip_str)) + } else { + Ok(SnmpValue::OctetString(data.to_vec())) + } + } + ASN_OBJECT_ID => { + let oid_len = (*var).val_len / std::mem::size_of::(); + let oid_ptr_ptr = (*var).val.objid(); + let oid_ptr = *oid_ptr_ptr; + let oid_str = oid_to_string(oid_ptr, oid_len); + Ok(SnmpValue::Oid(oid_str)) + } + ASN_NULL => Ok(SnmpValue::Null), + SNMP_NOSUCHOBJECT | SNMP_NOSUCHINSTANCE | SNMP_ENDOFMIBVIEW => { + Err(SnmpError::RequestFailed("No such object".into())) + } _ => Err(SnmpError::RequestFailed(format!( - "Unsupported SNMP value type: {:?}", - value + "Unknown type: {}", + var_type ))), } } -/// Map snmp2 crate errors to our SnmpError -fn map_snmp_error(err: snmp2::Error) -> SnmpError { - use snmp2::Error; - - match err { - Error::Send => SnmpError::NetworkUnreachable, - Error::Receive => SnmpError::Timeout, - Error::CommunityMismatch => SnmpError::AuthFailure, - Error::AuthFailure(_) => SnmpError::AuthFailure, - _ => SnmpError::RequestFailed(format!("{:?}", err)), - } +unsafe fn oid_to_string(oid_ptr: *const oid, oid_len: usize) -> String { + let oid_slice = std::slice::from_raw_parts(oid_ptr, oid_len); + oid_slice + .iter() + .map(|n| n.to_string()) + .collect::>() + .join(".") } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_snmp_client_new() { + #[tokio::test] + async fn test_snmp_client_creation() { let client = SnmpClient::new(); - // Just verify we can create it - assert!(format!("{:?}", client).contains("SnmpClient")); + // Should not panic - init_snmp should be called once + let client2 = SnmpClient::new(); + // Verify both clients are valid (zero-sized struct) + assert_eq!( + std::mem::size_of_val(&client), + std::mem::size_of_val(&client2) + ); } - #[test] - fn test_snmp_client_default() { - let client = SnmpClient; - assert!(format!("{:?}", client).contains("SnmpClient")); - } + #[tokio::test] + async fn test_get_invalid_host() { + let client = SnmpClient::new(); + let result = client + .get( + "invalid.host.that.does.not.exist", + "public", + "2c", + 161, + "1.3.6.1.2.1.1.1.0", + None, + ) + .await; - #[test] - fn test_parse_snmp_version_v1() { - assert_eq!(parse_snmp_version("1").unwrap(), 0); - assert_eq!(parse_snmp_version("v1").unwrap(), 0); - assert_eq!(parse_snmp_version("V1").unwrap(), 0); - assert_eq!(parse_snmp_version("snmpv1").unwrap(), 0); - } - - #[test] - fn test_parse_snmp_version_v2c() { - assert_eq!(parse_snmp_version("2c").unwrap(), 1); - assert_eq!(parse_snmp_version("2C").unwrap(), 1); - assert_eq!(parse_snmp_version("v2c").unwrap(), 1); - assert_eq!(parse_snmp_version("V2C").unwrap(), 1); - assert_eq!(parse_snmp_version("2").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] - fn test_parse_snmp_version_invalid() { - let result = parse_snmp_version("invalid"); assert!(result.is_err()); - match result { - Err(SnmpError::RequestFailed(msg)) => { + match result.unwrap_err() { + SnmpError::NetworkUnreachable | SnmpError::RequestFailed(_) => {} + e => panic!("Expected NetworkUnreachable or RequestFailed, got: {:?}", e), + } + } + + #[tokio::test] + async fn test_get_invalid_oid() { + let client = SnmpClient::new(); + let result = client + .get("127.0.0.1", "public", "2c", 161, "not-a-valid-oid", None) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + SnmpError::InvalidOid(_) => {} + e => panic!("Expected InvalidOid, got: {:?}", e), + } + } + + #[tokio::test] + async fn test_walk_invalid_oid() { + let client = SnmpClient::new(); + let result = client + .walk("127.0.0.1", "public", "2c", 161, "not-valid", None) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + SnmpError::InvalidOid(_) => {} + e => panic!("Expected InvalidOid, got: {:?}", e), + } + } + + #[tokio::test] + async fn test_v3_config_clone() { + let config = V3Config { + username: "testuser".to_string(), + auth_password: Some(Zeroizing::new("authpass".to_string())), + priv_password: Some(Zeroizing::new("privpass".to_string())), + auth_protocol: Some("SHA".to_string()), + priv_protocol: Some("AES".to_string()), + security_level: "authPriv".to_string(), + }; + + let cloned = config.clone(); + assert_eq!(config.username, cloned.username); + assert_eq!(config.auth_protocol, cloned.auth_protocol); + assert_eq!(config.priv_protocol, cloned.priv_protocol); + assert_eq!(config.security_level, cloned.security_level); + } + + #[tokio::test] + async fn test_v3_config_debug_redacts_passwords() { + let config = V3Config { + username: "testuser".to_string(), + auth_password: Some(Zeroizing::new("authpass".to_string())), + priv_password: Some(Zeroizing::new("privpass".to_string())), + auth_protocol: Some("SHA".to_string()), + priv_protocol: Some("AES".to_string()), + security_level: "authPriv".to_string(), + }; + + let debug_str = format!("{:?}", config); + assert!(!debug_str.contains("authpass")); + assert!(!debug_str.contains("privpass")); + assert!(debug_str.contains("[REDACTED]")); + assert!(debug_str.contains("testuser")); + } + + #[tokio::test] + async fn test_unsupported_version() { + let client = SnmpClient::new(); + let result = client + .get("127.0.0.1", "public", "99", 161, "1.3.6.1.2.1.1.1.0", None) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + SnmpError::RequestFailed(msg) => { assert!(msg.contains("Unsupported SNMP version")); } - _ => panic!("Expected RequestFailed error"), + e => panic!("Expected RequestFailed with version error, got: {:?}", e), + } + } + + #[tokio::test] + async fn test_sequential_requests() { + // Test that multiple sequential requests work without issues + // Note: Concurrent requests via tokio::spawn can cause segfaults + // because libnetsnmp may not be fully thread-safe. + // Our implementation uses spawn_blocking which should be safe for + // sequential async operations. + let client = SnmpClient::new(); + + for _ in 0..3 { + let result = client + .get("192.0.2.1", "public", "2c", 161, "1.3.6.1.2.1.1.1.0", None) + .await; + + // Should fail (no agent at 192.0.2.1) but not panic + assert!(result.is_err()); + } + } + + #[tokio::test] + async fn test_get_and_walk_different_clients() { + // Test that get and walk can be used with different client instances + let client1 = SnmpClient::new(); + let client2 = SnmpClient::new(); + + let get_result = client1 + .get("192.0.2.1", "public", "2c", 161, "1.3.6.1.2.1.1.1.0", None) + .await; + + let walk_result = client2 + .walk("192.0.2.1", "public", "2c", 161, "1.3.6.1.2.1.1", None) + .await; + + // Both should fail but not panic + assert!(get_result.is_err()); + assert!(walk_result.is_err()); + } + + #[test] + fn test_c_helpers_init() { + // Test that C library initializes without crashing + unsafe { + let result = snmp_init_library(); + assert_eq!(result, 0, "C library initialization should succeed"); } } #[test] - 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) - )); - } + fn test_c_helpers_session_open_invalid_host() { + // Test session opening with invalid host + unsafe { + snmp_init_library(); - #[test] - 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))); - } + let ip = CString::new("invalid.host.example").unwrap(); + let community = CString::new("public").unwrap(); + let mut error_buf = [0i8; 256]; - #[test] - fn test_convert_value_integer() { - let value = snmp2::Value::Integer(42); - let result = convert_value(value).unwrap(); - match result { - SnmpValue::Integer(v) => assert_eq!(v, 42), - _ => panic!("Expected Integer"), + let sess = snmp_open_session( + ip.as_ptr(), + 161, + community.as_ptr(), + 2, // SNMPv2c + 10_000_000, + 2, + ptr::null(), // No v3 config for v2c + error_buf.as_mut_ptr(), + error_buf.len(), + ); + + // Should fail with invalid host + assert!(sess.is_null(), "Session should fail with invalid host"); + + // Should have an error message + let err_msg = CStr::from_ptr(error_buf.as_ptr()) + .to_string_lossy() + .to_string(); + assert!( + !err_msg.is_empty(), + "Should have error message, got: {}", + err_msg + ); } } #[test] - fn test_convert_value_octet_string() { - let value = snmp2::Value::OctetString(b"test".as_slice()); - let result = convert_value(value).unwrap(); - match result { - SnmpValue::String(s) => assert_eq!(s, "test"), - _ => panic!("Expected String"), + fn test_c_helpers_session_lifecycle() { + // Test session open/close lifecycle (doesn't require actual SNMP device) + unsafe { + snmp_init_library(); + + let ip = CString::new("192.0.2.1").unwrap(); // TEST-NET-1 (should be unreachable) + let community = CString::new("public").unwrap(); + let mut error_buf = [0i8; 256]; + + let sess = snmp_open_session( + ip.as_ptr(), + 161, + community.as_ptr(), + 2, // SNMPv2c + 10_000_000, + 2, + ptr::null(), // No v3 config for v2c + error_buf.as_mut_ptr(), + error_buf.len(), + ); + + // Session creation should succeed even if host is unreachable + // (connection happens on first request) + if !sess.is_null() { + // Clean close should not crash + snmp_close_session(sess); + } } } - - #[test] - fn test_convert_value_counter32() { - let value = snmp2::Value::Counter32(12345); - let result = convert_value(value).unwrap(); - match result { - SnmpValue::Counter32(v) => assert_eq!(v, 12345), - _ => panic!("Expected Counter32"), - } - } - - #[test] - fn test_convert_value_counter64() { - let value = snmp2::Value::Counter64(9876543210); - let result = convert_value(value).unwrap(); - match result { - SnmpValue::Counter64(v) => assert_eq!(v, 9876543210), - _ => panic!("Expected Counter64"), - } - } - - #[test] - fn test_convert_value_unsigned32() { - let value = snmp2::Value::Unsigned32(999); - let result = convert_value(value).unwrap(); - match result { - SnmpValue::Gauge32(v) => assert_eq!(v, 999), - _ => panic!("Expected Gauge32"), - } - } - - #[test] - fn test_convert_value_timeticks() { - let value = snmp2::Value::Timeticks(12345678); - let result = convert_value(value).unwrap(); - match result { - SnmpValue::TimeTicks(v) => assert_eq!(v, 12345678), - _ => panic!("Expected TimeTicks"), - } - } - - #[test] - fn test_convert_value_ip_address() { - 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"), - _ => panic!("Expected IpAddress"), - } - } - - #[test] - fn test_map_snmp_error_send() { - let err = snmp2::Error::Send; - let result = map_snmp_error(err); - match result { - SnmpError::NetworkUnreachable => {} - _ => panic!("Expected NetworkUnreachable"), - } - } - - #[test] - fn test_map_snmp_error_receive() { - let err = snmp2::Error::Receive; - let result = map_snmp_error(err); - match result { - SnmpError::Timeout => {} - _ => panic!("Expected Timeout"), - } - } - - #[test] - fn test_map_snmp_error_community() { - let err = snmp2::Error::CommunityMismatch; - let result = map_snmp_error(err); - match result { - SnmpError::AuthFailure => {} - _ => panic!("Expected AuthFailure"), - } - } - - // Note: get() and walk() methods require actual network operations - // and are tested via integration tests, not unit tests } diff --git a/src/snmp/client_v2.rs b/src/snmp/client_v2.rs new file mode 100644 index 0000000..28ab108 --- /dev/null +++ b/src/snmp/client_v2.rs @@ -0,0 +1,99 @@ +// Temporary new implementation to test compilation - will replace client.rs + +use super::types::{SnmpError, SnmpResult, SnmpValue}; +use netsnmp_sys::*; +use std::ffi::{CStr, CString}; +use std::ptr; + +const SNMP_TIMEOUT_SECS: i64 = 10; + +/// Test if we can compile a simple SNMP GET using netsnmp-sys +pub fn test_snmp_get(ip: &str, community: &str) -> SnmpResult { + unsafe { + // Initialize library + init_snmp(b"test\0".as_ptr() as *const i8); + + // Create session + let mut sess: Struct_netsnmp_session = std::mem::zeroed(); + snmp_sess_init(&mut sess as *mut _); + + // Set peername + let peer = CString::new(format!("{}:161", ip)).unwrap(); + sess.peername = peer.as_ptr() as *mut _; + + // Set version and community + sess.version = SNMP_VERSION_2c as i32; + let comm = CString::new(community).unwrap(); + sess.community = comm.as_ptr() as *mut _; + sess.community_len = community.len(); + + // Set timeout + sess.timeout = SNMP_TIMEOUT_SECS * 1_000_000; // microseconds + + // Open session + let sess_ptr = snmp_open(&mut sess as *mut _); + if sess_ptr.is_null() { + return Err(SnmpError::NetworkUnreachable); + } + + // Parse OID for sysDescr.0 + let mut oid_buf = [0u32; MAX_OID_LEN]; + let mut oid_len = MAX_OID_LEN; + let oid_str = CString::new("1.3.6.1.2.1.1.1.0").unwrap(); + + if read_objid(oid_str.as_ptr(), oid_buf.as_mut_ptr(), &mut oid_len) == 0 { + snmp_close(sess_ptr); + return Err(SnmpError::InvalidOid("Failed to parse OID".into())); + } + + // Create PDU + let pdu = snmp_pdu_create(SNMP_MSG_GET as i32); + if pdu.is_null() { + snmp_close(sess_ptr); + return Err(SnmpError::RequestFailed("Failed to create PDU".into())); + } + + // Add OID to PDU + snmp_add_null_var(pdu, oid_buf.as_ptr(), oid_len); + + // Send request + let mut response: *mut Struct_netsnmp_pdu = ptr::null_mut(); + let status = snmp_synch_response(sess_ptr, pdu, &mut response as *mut _); + + let result = if status == STAT_SUCCESS as i32 && !response.is_null() { + let vars = (*response).variables; + if !vars.is_null() { + // Get the type + let var_type = (*vars)._type; + + // Try to extract string value + if var_type == ASN_OCTET_STR as u8 { + let val_len = (*vars).val_len; + // Access union - need mutable pointer + let vars_mut = vars as *mut _; + let string_ptr = *(*vars_mut).val.string(); + let slice = std::slice::from_raw_parts(string_ptr, val_len); + Ok(String::from_utf8_lossy(slice).to_string()) + } else { + Err(SnmpError::RequestFailed(format!("Unexpected type: {}", var_type))) + } + } else { + Err(SnmpError::RequestFailed("No variables in response".into())) + } + } else { + Err(if status == STAT_TIMEOUT as i32 { + SnmpError::Timeout + } else { + SnmpError::RequestFailed("SNMP request failed".into())) + } + }; + + // Cleanup + if !response.is_null() { + snmp_free_pdu(response); + } + snmp_close(sess_ptr); + + result + } +} diff --git a/src/snmp/device_poller.rs b/src/snmp/device_poller.rs index 5ded3f2..3d4b1cc 100644 --- a/src/snmp/device_poller.rs +++ b/src/snmp/device_poller.rs @@ -1,16 +1,9 @@ +use super::client::SnmpClient; use super::types::{SnmpError, SnmpResult, SnmpValue}; use super::V3Config; use crate::secret::SecretString; -use snmp2::{Oid, SyncSession}; -use std::str::FromStr; -use std::time::Duration; use tokio::sync::{mpsc, oneshot}; -// SNMP timeout in seconds - increased to 120s for SNMPv3 operations -// SNMPv3 has significant encryption/auth overhead -// Full discovery can take 50+ seconds with complete MikroTik OID tree traversal -const SNMP_TIMEOUT_SECS: u64 = 120; - /// Request to perform an SNMP operation #[derive(Debug)] pub enum SnmpRequest { @@ -49,7 +42,7 @@ impl std::fmt::Debug for DeviceConfig { } } -/// Per-device polling thread that maintains a persistent SNMP session +/// Per-device polling thread that uses C FFI to libnetsnmp pub struct DevicePoller { device_id: String, config: DeviceConfig, @@ -65,17 +58,20 @@ impl DevicePoller { let config_clone = config.clone(); // Spawn the polling thread with 8MB stack for SNMPv3 crypto operations + tracing::info!("Spawning device poller thread for {} at {}:{}", device_id, config.ip, config.port); std::thread::Builder::new() .name(format!("poller-{}", device_id)) .stack_size(8 * 1024 * 1024) // 8MB stack (default is 2MB) .spawn(move || { - if let Err(e) = run_poller_thread(device_id_clone, config_clone, request_rx) { - tracing::error!("Device poller thread failed: {}", e); + tracing::info!("Device poller thread starting for {}", device_id_clone); + if let Err(e) = run_poller_thread(device_id_clone.clone(), config_clone, request_rx) { + tracing::error!("Device poller thread failed for {}: {}", device_id_clone, e); } + tracing::info!("Device poller thread exited for {}", device_id_clone); }) .expect("Failed to spawn device poller thread"); - tracing::info!("Spawned device poller thread for {}", device_id); + tracing::info!("Successfully spawned device poller thread for {}", device_id); Self { device_id, @@ -128,21 +124,23 @@ impl DevicePoller { &self.config } - /// Log device poller status using accessor methods + /// Update the device configuration + pub fn update_config(&mut self, new_config: DeviceConfig) { + self.config = new_config; + } + + /// Log the status of this poller (for debugging) pub fn log_status(&self) { - let id = self.device_id(); - let cfg = self.config(); tracing::debug!( - "Device poller {} at {}:{} (version: {})", - id, - cfg.ip, - cfg.port, - cfg.version + "Poller status: device_id={}, ip={}:{}", + self.device_id, + self.config.ip, + self.config.port ); } } -/// Run the device poller thread (blocking) +/// Run the poller thread using C FFI to libnetsnmp fn run_poller_thread( device_id: String, config: DeviceConfig, @@ -155,331 +153,128 @@ fn run_poller_thread( config.port ); - // Create persistent session - let addr = format!("{}:{}", config.ip, config.port); - let mut session = create_session(&addr, &config)?; + // Create a tokio runtime for this thread (SnmpClient uses async) + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create tokio runtime: {}", e))?; - tracing::info!( - "Created persistent SNMP session for device {} (version: {})", - device_id, - config.version - ); + // Create SNMP client (stateless, uses C FFI) + let client = SnmpClient::new(); // Process requests until shutdown while let Some(request) = request_rx.blocking_recv() { - match request { - SnmpRequest::Get { oid, response_tx } => { - let result = perform_get(&mut session, &oid); - let _ = response_tx.send(result); + let is_shutdown = matches!(request, SnmpRequest::Shutdown); + + // Log what request we're processing + match &request { + SnmpRequest::Get { oid, .. } => { + tracing::debug!("Poller thread {} processing GET {}", device_id, oid); } - SnmpRequest::Walk { - base_oid, - response_tx, - } => { - let result = perform_walk(&mut session, &base_oid); - let _ = response_tx.send(result); + SnmpRequest::Walk { base_oid, .. } => { + tracing::debug!("Poller thread {} processing WALK {}", device_id, base_oid); } SnmpRequest::Shutdown => { - tracing::info!("Device poller thread shutting down for {}", device_id); - break; + tracing::info!("Poller thread {} received shutdown signal", device_id); } } + + let panic_result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match request { + SnmpRequest::Get { oid, response_tx } => { + tracing::debug!("Poller thread {} executing GET", device_id); + let result = perform_get(&runtime, &client, &config, &oid); + tracing::debug!("Poller thread {} GET result: {:?}", device_id, result.is_ok()); + let _ = response_tx.send(result); + } + SnmpRequest::Walk { + base_oid, + response_tx, + } => { + tracing::debug!("Poller thread {} executing WALK", device_id); + let result = perform_walk(&runtime, &client, &config, &base_oid); + tracing::debug!( + "Poller thread {} WALK result: {:?}", + device_id, + result.as_ref().map(|v| v.len()) + ); + let _ = response_tx.send(result); + } + SnmpRequest::Shutdown => { + tracing::info!("Device poller thread shutting down for {}", device_id); + } + })); + + if let Err(panic_err) = panic_result { + let panic_msg = if let Some(s) = panic_err.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_err.downcast_ref::() { + s.clone() + } else { + "Unknown panic".to_string() + }; + tracing::error!( + "Panic in device poller thread for {}: {}", + device_id, + panic_msg + ); + // Don't break - keep the thread alive for future requests + } else { + tracing::debug!("Poller thread {} completed request successfully", device_id); + } + + if is_shutdown { + tracing::info!("Poller thread {} exiting due to shutdown", device_id); + break; + } } tracing::info!("Device poller thread stopped for {}", device_id); Ok(()) } -/// Create an SNMP session based on version and transport -fn create_session(addr: &str, config: &DeviceConfig) -> Result { - let timeout = Some(Duration::from_secs(SNMP_TIMEOUT_SECS)); - let version_num = parse_snmp_version(&config.version)?; - let transport = config.transport.to_lowercase(); - - // TCP transport warning - not yet implemented - // TODO: Implement SNMP-over-TCP (RFC 3430) - requires low-level PDU handling - // since snmp2 crate doesn't expose internal types needed for TCP framing - if transport == "tcp" { - tracing::warn!( - "TCP transport requested for {} but not yet implemented. \ - Falling back to UDP. TCP support requires custom PDU encoder/decoder \ - since snmp2 crate doesn't expose necessary internal types.", - addr - ); - } - - // UDP transport (default, also used as fallback for TCP) - if config.version == "3" { - let v3_config = config - .v3_config - .as_ref() - .ok_or("v3_config required for SNMPv3")?; - create_v3_session(addr, timeout, v3_config) - } else { - create_v1v2c_session( - addr, - config.community.expose().as_bytes(), - timeout, - version_num, - ) - } +/// Perform SNMP GET using C FFI +fn perform_get( + runtime: &tokio::runtime::Runtime, + client: &SnmpClient, + config: &DeviceConfig, + oid: &str, +) -> SnmpResult { + // Use the thread-local runtime to execute async C FFI call + runtime.block_on(async { + client + .get( + &config.ip, + config.community.expose(), + &config.version, + config.port, + oid, + config.v3_config.clone(), + ) + .await + }) } -/// Create v1/v2c session -fn create_v1v2c_session( - addr: &str, - community: &[u8], - timeout: Option, - version: i32, -) -> Result { - let req_id = 1; - - let result = match version { - 0 => SyncSession::new_v1(addr, community, timeout, req_id), - 1 => SyncSession::new_v2c(addr, community, timeout, req_id), - _ => return Err(format!("Unsupported SNMP version: {}", version)), - }; - - result.map_err(|e| format!("Failed to create v1/v2c session: {:?}", e)) -} - -/// Create v3 session -fn create_v3_session( - addr: &str, - timeout: Option, - config: &V3Config, -) -> Result { - use snmp2::v3::{Auth, Security}; - - // Parse security level and build Auth enum - let auth = match config.security_level.trim().to_lowercase().as_str() { - "noauthnopriv" | "" => Auth::NoAuthNoPriv, - "authnopriv" => Auth::AuthNoPriv, - "authpriv" => { - let cipher = parse_priv_protocol( - config - .priv_protocol - .as_deref() - .ok_or("Priv protocol required for authPriv")?, - )?; - let priv_pass = config - .priv_password - .as_ref() - .map(|s| s.expose()) - .ok_or("Priv password required for authPriv")?; - - Auth::AuthPriv { - cipher, - privacy_password: priv_pass.as_bytes().to_vec(), - } - } - _ => { - return Err(format!( - "Unsupported security level: '{}'", - config.security_level - )) - } - }; - - let username = config.username.as_bytes(); - let auth_password = config - .auth_password - .as_ref() - .map(|s| s.expose()) - .unwrap_or("") - .as_bytes(); - let needs_auth_protocol = !matches!(auth, Auth::NoAuthNoPriv); - - let mut security = Security::new(username, auth_password).with_auth(auth); - - if needs_auth_protocol { - let auth_proto = parse_auth_protocol(config.auth_protocol.as_deref().unwrap())?; - security = security.with_auth_protocol(auth_proto); - } - - let req_id = 1; - let mut session = SyncSession::new_v3(addr, timeout, req_id, security) - .map_err(|e| format!("Failed to create v3 session: {:?}", e))?; - - // For authPriv/authNoPriv, perform engine ID discovery using session.init() - if needs_auth_protocol { - session - .init() - .map_err(|e| format!("Engine ID discovery failed: {:?}", e))?; - } - - Ok(session) -} - -/// Perform SNMP GET on existing session (with retry for v3 engine ID discovery) -fn perform_get(session: &mut SyncSession, oid: &str) -> SnmpResult { - let oid_parsed = - Oid::from_str(oid).map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", oid)))?; - - // First attempt (may fail with AuthUpdated for v3 engine ID discovery) - let mut response = match session.get(&oid_parsed) { - Ok(resp) => resp, - Err(snmp2::Error::AuthUpdated) => { - tracing::debug!("SNMPv3 engine ID discovered, retrying request"); - // Retry after engine ID discovery - session.get(&oid_parsed).map_err(map_snmp_error)? - } - Err(e) => return Err(map_snmp_error(e)), - }; - - if response.error_status != 0 { - return Err(SnmpError::RequestFailed(format!( - "SNMP error status: {}", - response.error_status - ))); - } - - let (_, value) = response - .varbinds - .next() - .ok_or(SnmpError::RequestFailed("No varbinds in response".into()))?; - - convert_value(value) -} - -/// Perform SNMP WALK on existing session (with retry for v3 engine ID discovery) -fn perform_walk(session: &mut SyncSession, base_oid: &str) -> SnmpResult> { - let base_oid_parsed = Oid::from_str(base_oid) - .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", base_oid)))?; - let base_oid_string = base_oid.to_string(); - - let mut results = Vec::new(); - let mut current_oid = base_oid_parsed; - let mut first_request = true; - - loop { - let oid_to_query = current_oid.clone(); - - let (error_status, varbind_data) = { - // First request may fail with AuthUpdated for v3 engine ID discovery - let response = match session.getnext(&oid_to_query) { - Ok(resp) => resp, - Err(snmp2::Error::AuthUpdated) if first_request => { - tracing::debug!("SNMPv3 engine ID discovered, retrying WALK request"); - // Retry after engine ID discovery - session.getnext(&oid_to_query).map_err(map_snmp_error)? - } - Err(e) => return Err(map_snmp_error(e)), - }; - - // After first request, don't retry on AuthUpdated - first_request = false; - let status = response.error_status; - - let data: Vec<(String, snmp2::Value)> = response - .varbinds - .map(|(name, value)| (name.to_string(), value)) - .collect(); - - (status, data) - }; - - if error_status != 0 { - break; - } - - if varbind_data.is_empty() { - break; - } - - for (name_str, value) in varbind_data { - if !name_str.starts_with(&base_oid_string) { - return Ok(results); - } - - let converted_value = convert_value(value)?; - results.push((name_str.clone(), converted_value)); - - current_oid = Oid::from_str(&name_str).map_err(|_| { - SnmpError::InvalidOid(format!("Invalid OID from response: {}", name_str)) - })?; - } - } - - Ok(results) -} - -/// Parse SNMP version string to integer -fn parse_snmp_version(version: &str) -> Result { - match version.trim().to_lowercase().as_str() { - "1" | "v1" | "snmpv1" => Ok(0), - "2c" | "v2c" | "snmpv2c" | "2" | "v2" => Ok(1), - "3" | "v3" | "snmpv3" => Ok(3), - _ => Err(format!("Unsupported SNMP version: '{}'", version)), - } -} - -/// Parse authentication protocol -fn parse_auth_protocol(protocol: &str) -> Result { - use snmp2::v3::AuthProtocol; - - match protocol.trim().to_uppercase().as_str() { - "MD5" => Ok(AuthProtocol::Md5), - "SHA" | "SHA1" | "SHA-1" => Ok(AuthProtocol::Sha1), - "SHA224" | "SHA-224" => Ok(AuthProtocol::Sha224), - "SHA256" | "SHA-256" => Ok(AuthProtocol::Sha256), - "SHA384" | "SHA-384" => Ok(AuthProtocol::Sha384), - "SHA512" | "SHA-512" => Ok(AuthProtocol::Sha512), - _ => Err(format!("Unsupported auth protocol: '{}'", protocol)), - } -} - -/// Parse privacy protocol -fn parse_priv_protocol(protocol: &str) -> Result { - use snmp2::v3::Cipher; - - match protocol.trim().to_uppercase().as_str() { - "DES" => Ok(Cipher::Des), - "AES" | "AES128" | "AES-128" => Ok(Cipher::Aes128), - "AES192" | "AES-192" => Ok(Cipher::Aes192), - "AES256" | "AES-256" | "AES-256-C" => Ok(Cipher::Aes256), - _ => Err(format!("Unsupported priv protocol: '{}'", protocol)), - } -} - -/// Map snmp2 errors to our error type -fn map_snmp_error(err: snmp2::Error) -> SnmpError { - match &err { - snmp2::Error::AuthFailure(kind) => { - tracing::error!("SNMPv3 AuthFailure: {:?}", kind); - SnmpError::AuthFailure - } - snmp2::Error::AuthUpdated => { - tracing::info!("SNMPv3 engine ID discovered, caller should retry"); - SnmpError::RequestFailed("Authentication context updated, need to retry".into()) - } - snmp2::Error::CommunityMismatch => SnmpError::AuthFailure, - _ => { - tracing::error!("SNMP error: {:?}", err); - SnmpError::RequestFailed(format!("SNMP request failed: {:?}", err)) - } - } -} - -/// Convert snmp2::Value to our SnmpValue -fn convert_value(value: snmp2::Value) -> SnmpResult { - match value { - snmp2::Value::Integer(i) => Ok(SnmpValue::Integer(i)), - snmp2::Value::OctetString(bytes) => Ok(String::from_utf8(bytes.to_vec()) - .map(SnmpValue::String) - .unwrap_or_else(|_| SnmpValue::OctetString(bytes.to_vec()))), - snmp2::Value::ObjectIdentifier(oid) => Ok(SnmpValue::Oid(oid.to_string())), - snmp2::Value::Counter32(c) => Ok(SnmpValue::Counter32(c)), - snmp2::Value::Counter64(c) => Ok(SnmpValue::Counter64(c)), - snmp2::Value::Unsigned32(g) => Ok(SnmpValue::Gauge32(g)), - snmp2::Value::Timeticks(t) => Ok(SnmpValue::TimeTicks(t)), - snmp2::Value::IpAddress(ip) => Ok(SnmpValue::IpAddress(format!( - "{}.{}.{}.{}", - ip[0], ip[1], ip[2], ip[3] - ))), - snmp2::Value::Null => Ok(SnmpValue::Null), - _ => Ok(SnmpValue::Unsupported(format!("{:?}", value))), - } +/// Perform SNMP WALK using C FFI +fn perform_walk( + runtime: &tokio::runtime::Runtime, + client: &SnmpClient, + config: &DeviceConfig, + base_oid: &str, +) -> SnmpResult> { + // Use the thread-local runtime to execute async C FFI call + runtime.block_on(async { + client + .walk( + &config.ip, + config.community.expose(), + &config.version, + config.port, + base_oid, + config.v3_config.clone(), + ) + .await + }) } #[cfg(test)] @@ -487,92 +282,18 @@ mod tests { use super::*; #[test] - fn test_parse_auth_protocol_standard() { - 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("SHA1"), - Ok(AuthProtocol::Sha1) - )); - assert!(matches!( - parse_auth_protocol("SHA256"), - Ok(AuthProtocol::Sha256) - )); - assert!(matches!( - parse_auth_protocol("SHA384"), - Ok(AuthProtocol::Sha384) - )); - assert!(matches!( - parse_auth_protocol("SHA512"), - Ok(AuthProtocol::Sha512) - )); - } + fn test_device_config_debug() { + let config = DeviceConfig { + ip: "192.168.1.1".to_string(), + port: 161, + version: "2c".to_string(), + community: SecretString::new("public"), + v3_config: None, + transport: "udp".to_string(), + }; - #[test] - fn test_parse_auth_protocol_hyphenated() { - use snmp2::v3::AuthProtocol; - assert!(matches!( - parse_auth_protocol("SHA-1"), - Ok(AuthProtocol::Sha1) - )); - assert!(matches!( - parse_auth_protocol("SHA-224"), - Ok(AuthProtocol::Sha224) - )); - assert!(matches!( - parse_auth_protocol("SHA-256"), - Ok(AuthProtocol::Sha256) - )); - assert!(matches!( - parse_auth_protocol("SHA-384"), - Ok(AuthProtocol::Sha384) - )); - assert!(matches!( - parse_auth_protocol("SHA-512"), - Ok(AuthProtocol::Sha512) - )); - } - - #[test] - fn test_parse_auth_protocol_case_insensitive() { - use snmp2::v3::AuthProtocol; - assert!(matches!( - parse_auth_protocol("sha-256"), - Ok(AuthProtocol::Sha256) - )); - assert!(matches!(parse_auth_protocol("md5"), Ok(AuthProtocol::Md5))); - } - - #[test] - fn test_parse_auth_protocol_invalid() { - assert!(parse_auth_protocol("INVALID").is_err()); - } - - #[test] - fn test_parse_priv_protocol_standard() { - 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("AES128"), Ok(Cipher::Aes128))); - assert!(matches!(parse_priv_protocol("AES192"), Ok(Cipher::Aes192))); - assert!(matches!(parse_priv_protocol("AES256"), Ok(Cipher::Aes256))); - } - - #[test] - fn test_parse_priv_protocol_hyphenated() { - use snmp2::v3::Cipher; - assert!(matches!(parse_priv_protocol("AES-128"), Ok(Cipher::Aes128))); - assert!(matches!(parse_priv_protocol("AES-192"), Ok(Cipher::Aes192))); - assert!(matches!(parse_priv_protocol("AES-256"), Ok(Cipher::Aes256))); - assert!(matches!( - parse_priv_protocol("AES-256-C"), - Ok(Cipher::Aes256) - )); - } - - #[test] - fn test_parse_priv_protocol_invalid() { - assert!(parse_priv_protocol("INVALID").is_err()); + let debug_str = format!("{:?}", config); + assert!(debug_str.contains("[REDACTED]")); + assert!(!debug_str.contains("public")); } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 16b1cab..d7e3341 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -106,7 +106,10 @@ impl AgentState { ) } AgentEvent::MonitoringCheckSent { device_id, status } => { - format!("Monitoring check sent for {} (status: {})", device_id, status) + format!( + "Monitoring check sent for {} (status: {})", + device_id, status + ) } AgentEvent::PollerCreated { device_ip, diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 92bf44a..6468ca8 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -901,12 +901,16 @@ async fn execute_snmp_job( let config = crate::snmp::V3Config { username: snmp_device.v3_username.clone(), auth_password: if !snmp_device.v3_auth_password.is_empty() { - Some(SecretString::new(snmp_device.v3_auth_password.clone())) + Some(zeroize::Zeroizing::new( + snmp_device.v3_auth_password.clone(), + )) } else { None }, priv_password: if !snmp_device.v3_priv_password.is_empty() { - Some(SecretString::new(snmp_device.v3_priv_password.clone())) + Some(zeroize::Zeroizing::new( + snmp_device.v3_priv_password.clone(), + )) } else { None }, @@ -1087,12 +1091,16 @@ async fn execute_credential_test( Some(crate::snmp::V3Config { username: snmp_device.v3_username.clone(), auth_password: if !snmp_device.v3_auth_password.is_empty() { - Some(SecretString::new(snmp_device.v3_auth_password.clone())) + Some(zeroize::Zeroizing::new( + snmp_device.v3_auth_password.clone(), + )) } else { None }, priv_password: if !snmp_device.v3_priv_password.is_empty() { - Some(SecretString::new(snmp_device.v3_priv_password.clone())) + Some(zeroize::Zeroizing::new( + snmp_device.v3_priv_password.clone(), + )) } else { None }, @@ -1173,10 +1181,7 @@ async fn execute_credential_test( } /// Execute a ping job using ICMP ping to check device health. -async fn execute_ping_job( - job: AgentJob, - result_tx: mpsc::Sender, -) -> Result<()> { +async fn execute_ping_job(job: AgentJob, result_tx: mpsc::Sender) -> Result<()> { let device_id = job.device_id.clone(); let snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?; let ip_address = &snmp_device.ip; @@ -1184,7 +1189,11 @@ async fn execute_ping_job( // Use 5-second timeout for pings (same as Phoenix DeviceMonitorWorker) let timeout_ms = 5000; - tracing::debug!("Executing health check for device {} at {}", device_id, ip_address); + tracing::debug!( + "Executing health check for device {} at {}", + device_id, + ip_address + ); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)?