From ff89415ed3d91aa8d3fb58f0670226a20a3c40d7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 4 Feb 2026 18:18:37 -0600 Subject: [PATCH] feat: add SNMPv3 support to discovery and SNMP client The discovery process was only passing SNMPv2c credentials (community string) to the SNMP client, causing SNMPv3 devices to fail discovery even when v3 credentials were configured. Changes: - Updated build_client_opts in discovery.ex to include SNMPv3 credentials (security_name, security_level, auth_protocol, auth_password, priv_protocol, priv_password) when version is "3" - Updated build_snmp_opts in client.ex to pass v3 credentials to SnmpKit - Added parse functions for security_level, auth_protocol, and priv_protocol - Updated connection_opts typespec to include v3 parameters - Fixed parameter names to match SnmpKit expectations (security_name vs sec_name) This enables full SNMPv3 discovery on devices like MikroTik routers that use v3 authentication, allowing them to discover the same sensors and data that SNMPv2c would show. --- lib/towerops/snmp/client.ex | 53 ++++++++++++++++++++++++++++++---- lib/towerops/snmp/discovery.ex | 20 +++++++++++-- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/lib/towerops/snmp/client.ex b/lib/towerops/snmp/client.ex index 1a959faa..5765eee9 100644 --- a/lib/towerops/snmp/client.ex +++ b/lib/towerops/snmp/client.ex @@ -10,11 +10,17 @@ defmodule Towerops.Snmp.Client do @type connection_opts :: [ ip: String.t(), - community: String.t(), version: String.t(), port: non_neg_integer(), transport: String.t(), - timeout: non_neg_integer() + timeout: non_neg_integer(), + community: String.t(), + security_name: String.t(), + security_level: String.t(), + auth_protocol: String.t(), + auth_password: String.t(), + priv_protocol: String.t(), + priv_password: String.t() ] @type oid :: String.t() | [non_neg_integer()] @@ -417,25 +423,62 @@ defmodule Towerops.Snmp.Client do defp build_snmp_opts(opts) do version = parse_version(Keyword.fetch!(opts, :version)) - community = Keyword.fetch!(opts, :community) port = Keyword.get(opts, :port, 161) timeout = Keyword.get(opts, :timeout, @default_timeout) - [ - community: community, + base_opts = [ version: version, port: port, timeout: timeout, retries: @default_retries ] + + # Add version-specific authentication options + case version do + :v3 -> + base_opts ++ + [ + security_name: Keyword.fetch!(opts, :security_name), + security_level: parse_security_level(Keyword.fetch!(opts, :security_level)), + auth_protocol: parse_auth_proto(Keyword.get(opts, :auth_protocol)), + auth_password: Keyword.get(opts, :auth_password), + priv_protocol: parse_priv_proto(Keyword.get(opts, :priv_protocol)), + priv_password: Keyword.get(opts, :priv_password) + ] + + _ -> + base_opts ++ [community: Keyword.fetch!(opts, :community)] + end end defp parse_version("1"), do: :v1 defp parse_version("2c"), do: :v2c + defp parse_version("3"), do: :v3 defp parse_version(:v1), do: :v1 defp parse_version(:v2c), do: :v2c + defp parse_version(:v3), do: :v3 defp parse_version(_other), do: :v2c + defp parse_security_level("noAuthNoPriv"), do: :no_auth_no_priv + defp parse_security_level("authNoPriv"), do: :auth_no_priv + defp parse_security_level("authPriv"), do: :auth_priv + defp parse_security_level(_), do: :no_auth_no_priv + + defp parse_auth_proto("MD5"), do: :md5 + defp parse_auth_proto("SHA"), do: :sha + defp parse_auth_proto("SHA-224"), do: :sha224 + defp parse_auth_proto("SHA-256"), do: :sha256 + defp parse_auth_proto("SHA-384"), do: :sha384 + defp parse_auth_proto("SHA-512"), do: :sha512 + defp parse_auth_proto(_), do: :sha256 + + defp parse_priv_proto("DES"), do: :des + defp parse_priv_proto("AES"), do: :aes + defp parse_priv_proto("AES-192"), do: :aes192 + defp parse_priv_proto("AES-256"), do: :aes256 + defp parse_priv_proto("AES-256-C"), do: :aes256c + defp parse_priv_proto(_), do: :aes + @doc """ Resolve MIB name to numeric OID using SnmpKit. Handles MODULE-NAME::objectName format by stripping module prefix. diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 049a2c76..4b713333 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -382,12 +382,28 @@ defmodule Towerops.Snmp.Discovery do # Get SNMP config with hierarchical fallback (device -> site -> organization) snmp_config = Devices.get_snmp_config(device) - [ + base_opts = [ ip: device.ip_address, - community: snmp_config.community, version: snmp_config.version, port: device.snmp_port || 161 ] + + # Add version-specific credentials + if snmp_config.version == "3" do + v3_config = Devices.get_snmpv3_config(device) + + base_opts ++ + [ + security_name: v3_config.username, + security_level: v3_config.security_level, + auth_protocol: v3_config.auth_protocol, + auth_password: v3_config.auth_password, + priv_protocol: v3_config.priv_protocol, + priv_password: v3_config.priv_password + ] + else + base_opts ++ [community: snmp_config.community] + end end @spec discover_system(Client.connection_opts()) :: {:ok, system_info()} | {:error, term()}