major overhaul to use more C for snmp, may not be stable

This commit is contained in:
Graham McIntire 2026-02-09 12:31:41 -06:00
parent cd45e9ff51
commit 792825f682
No known key found for this signature in database
16 changed files with 1748 additions and 1055 deletions

7
.envrc Normal file
View file

@ -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"

4
.gitignore vendored
View file

@ -23,5 +23,9 @@ data/
.DS_Store
Thumbs.db
# Nix
.direnv/
result
# Local scripts
monitor-deploy.sh

158
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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();

82
flake.lock generated Normal file
View file

@ -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
}

53
flake.nix Normal file
View file

@ -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"
'';
};
});
}

478
native/snmp_helper.c Normal file
View file

@ -0,0 +1,478 @@
#include "snmp_helper.h"
#include <net-snmp/net-snmp-config.h>
#include <net-snmp/net-snmp-includes.h>
#include <string.h>
#include <stdio.h>
#include <pthread.h>
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;
}

111
native/snmp_helper.h Normal file
View file

@ -0,0 +1,111 @@
#ifndef SNMP_HELPER_H
#define SNMP_HELPER_H
#include <stdint.h>
#include <stddef.h>
/**
* 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

View file

@ -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
},

File diff suppressed because it is too large Load diff

99
src/snmp/client_v2.rs Normal file
View file

@ -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<String> {
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
}
}

View file

@ -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::<String>() {
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<SyncSession, String> {
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<SnmpValue> {
// 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<Duration>,
version: i32,
) -> Result<SyncSession, String> {
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<Duration>,
config: &V3Config,
) -> Result<SyncSession, String> {
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<SnmpValue> {
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<Vec<(String, SnmpValue)>> {
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<i32, String> {
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<snmp2::v3::AuthProtocol, String> {
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<snmp2::v3::Cipher, String> {
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<SnmpValue> {
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<Vec<(String, SnmpValue)>> {
// 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"));
}
}

View file

@ -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,

View file

@ -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<MonitoringCheck>,
) -> Result<()> {
async fn execute_ping_job(job: AgentJob, result_tx: mpsc::Sender<MonitoringCheck>) -> 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)?