towerops/lib/snmpkit/snmp_lib/manager.ex
Graham McIntie e195e5c9e8 fix: close UDP sockets in error paths to prevent fd leak
get_bulk, set, and get_multi used 'with' chains that skipped
close_socket when the operation failed, leaking file descriptors.
Over time this exhausted the fd limit causing EMFILE crashes.

Switched to case + try/after to guarantee socket cleanup.
2026-02-14 09:50:12 -06:00

1001 lines
33 KiB
Elixir

defmodule SnmpKit.SnmpLib.Manager do
@moduledoc """
High-level SNMP management operations providing a simplified interface for common SNMP tasks.
This module builds on the core SnmpLib functionality to provide production-ready SNMP
management capabilities including GET, GETBULK, SET operations with intelligent error
handling, connection reuse, and performance optimizations.
## Features
- **Simple API**: High-level functions for common SNMP operations
- **Connection Reuse**: Efficient socket management for multiple operations
- **Error Handling**: Comprehensive error handling with meaningful messages
- **Performance**: Optimized for bulk operations and large-scale polling
- **Timeout Management**: Configurable timeouts with sensible defaults
- **Community Support**: Support for different community strings per device
## Quick Start
# Simple GET operation
{:ok, {type, value}} = SnmpKit.SnmpLib.Manager.get("192.168.1.1", [1, 3, 6, 1, 2, 1, 1, 1, 0])
# GET with custom community and timeout
{:ok, {type, value}} = SnmpKit.SnmpLib.Manager.get("192.168.1.1", "1.3.6.1.2.1.1.1.0",
community: "private", timeout: 10_000)
# Bulk operations for efficiency
{:ok, results} = SnmpKit.SnmpLib.Manager.get_bulk("192.168.1.1", [1, 3, 6, 1, 2, 1, 2, 2],
max_repetitions: 20)
# SET operation
{:ok, :success} = SnmpKit.SnmpLib.Manager.set("192.168.1.1", [1, 3, 6, 1, 2, 1, 1, 5, 0],
{:string, "New System Name"})
## Configuration Options
- `community`: SNMP community string (default: "public")
- `version`: SNMP version (:v1, :v2c) (default: :v2c)
- `timeout`: Operation timeout in milliseconds (default: 5000)
- `retries`: Number of retry attempts (default: 3)
- `port`: SNMP port (default: 161)
- `local_port`: Local source port (default: 0 for random)
"""
alias SnmpKit.SnmpLib.OID
alias SnmpKit.SnmpLib.PDU
alias SnmpKit.SnmpLib.Transport
alias SnmpKit.SnmpLib.Utils
require Logger
@default_community "public"
@default_version :v2c
@default_timeout 5_000
@default_retries 3
@default_port 161
@default_local_port 0
@default_max_repetitions 30
@default_non_repeaters 0
@type host :: binary() | :inet.ip_address()
@type oid :: [non_neg_integer()] | binary()
@type snmp_value :: any()
@type community :: binary()
@type version :: :v1 | :v2c
@type operation_result :: {:ok, snmp_value()} | {:error, atom() | {atom(), any()}}
@type bulk_result :: {:ok, [varbind()]} | {:error, atom() | {atom(), any()}}
@type varbind :: {oid(), atom(), snmp_value()}
@type manager_opts :: [
community: community(),
version: version(),
timeout: pos_integer(),
retries: non_neg_integer(),
port: pos_integer(),
local_port: non_neg_integer()
]
@type bulk_opts :: [
community: community(),
version: version(),
timeout: pos_integer(),
retries: non_neg_integer(),
port: pos_integer(),
local_port: non_neg_integer(),
max_repetitions: pos_integer(),
non_repeaters: non_neg_integer()
]
## Public API
@doc """
Performs an SNMP GET operation to retrieve a single value.
## Parameters
- `host`: Target device IP address or hostname
- `oid`: Object identifier as list or string (e.g., [1,3,6,1,2,1,1,1,0] or "1.3.6.1.2.1.1.1.0")
- `opts`: Configuration options (see module docs for available options)
## Returns
- `{:ok, {type, value}}`: Successfully retrieved the value with its SNMP type
- `{:error, reason}`: Operation failed with reason
## Examples
# Basic GET operation (would succeed with real device)
# SnmpKit.SnmpLib.Manager.get("192.168.1.1", [1, 3, 6, 1, 2, 1, 1, 1, 0])
# {:ok, {:octet_string, "Cisco IOS Software"}}
# GET with custom community and timeout (would succeed with real device)
# SnmpKit.SnmpLib.Manager.get("192.168.1.1", "1.3.6.1.2.1.1.1.0", community: "private", timeout: 10_000)
# {:ok, {:octet_string, "Private System Description"}}
# Test that function exists and handles invalid input properly
iex> match?({:error, _}, SnmpKit.SnmpLib.Manager.get("invalid.host", [1, 3, 6, 1, 2, 1, 1, 3, 0], timeout: 100))
true
"""
@spec get(host(), oid(), manager_opts()) ::
{:ok, {atom(), any()}}
| {:error, atom() | {:network_error, atom()} | {:socket_error, atom()}}
def get(host, oid, opts \\ []) do
opts = merge_default_opts(opts)
normalized_oid = normalize_oid(oid)
Logger.debug("Starting GET operation: host=#{inspect(host)}, oid=#{inspect(normalized_oid)}")
case create_socket(opts) do
{:ok, socket} ->
Logger.debug("Socket created successfully")
case perform_get_operation(socket, host, normalized_oid, opts) do
{:ok, response} ->
Logger.debug("GET operation completed, extracting result")
:ok = close_socket(socket)
result = extract_get_result(response)
Logger.debug("Final GET result: #{inspect(result)}")
result
{:error, reason} ->
Logger.debug("GET operation failed: #{inspect(reason)}")
:ok = close_socket(socket)
{:error, reason}
end
{:error, reason} ->
Logger.debug("Socket creation failed: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Performs an SNMP GETNEXT operation to retrieve the next value in the MIB tree.
GETNEXT is used to traverse the MIB tree by retrieving the next available
object after the specified OID. This is essential for MIB walking operations
and discovering available objects on SNMP devices.
## Parameters
- `host`: Target device IP address or hostname
- `oid`: Object identifier to get the next value after
- `opts`: Configuration options
## Implementation Details
- **SNMP v1**: Uses proper GETNEXT PDU for compatibility
- **SNMP v2c+**: Uses optimized GETBULK with max_repetitions=1
## Returns
- `{:ok, {next_oid, type, value}}`: Next OID and its value as a tuple
- `{:error, reason}`: Operation failed with reason
## Examples
# Get next OID after system description
{:ok, {next_oid, type, value}} = SnmpKit.SnmpLib.Manager.get_next("192.168.1.1", "1.3.6.1.2.1.1.1.0")
# SNMP v1 compatibility
{:ok, {next_oid, type, value}} = SnmpKit.SnmpLib.Manager.get_next("192.168.1.1", "1.3.6.1.2.1.1.1.0", version: :v1)
# With custom community
{:ok, {next_oid, type, value}} = SnmpKit.SnmpLib.Manager.get_next("192.168.1.1", "1.3.6.1.2.1.1.1.0",
community: "private", timeout: 10_000)
"""
@spec get_next(host(), oid(), manager_opts()) ::
{:ok, {oid(), atom(), any()}} | {:error, atom() | {atom(), any()}}
def get_next(host, oid, opts \\ []) do
opts = merge_default_opts(opts)
normalized_oid = normalize_oid(oid)
Logger.debug(
"Starting GETNEXT operation: host=#{inspect(host)}, oid=#{inspect(normalized_oid)}, version=#{opts[:version]}"
)
case opts[:version] do
:v1 ->
# Use proper GETNEXT PDU for SNMP v1 compatibility
perform_get_next_v1(host, normalized_oid, opts)
_ ->
# Use GETBULK with max_repetitions=1 for v2c+ efficiency
perform_get_next_v2c(host, normalized_oid, opts)
end
end
@doc """
Performs an SNMP GETBULK operation for efficient bulk data retrieval.
GETBULK is more efficient than multiple GET operations when retrieving
multiple consecutive values, especially for table walking operations.
## Parameters
- `host`: Target device IP address or hostname
- `base_oid`: Base OID to start the bulk operation
- `opts`: Configuration options including bulk-specific options
## Bulk-Specific Options
- `max_repetitions`: Maximum number of repetitions (default: 10)
- `non_repeaters`: Number of non-repeating variables (default: 0)
## Returns
- `{:ok, varbinds}`: List of {oid, type, value} tuples
- `{:error, reason}`: Operation failed with reason
## Examples
# Test that get_bulk function exists and handles invalid input properly
iex> match?({:error, _}, SnmpKit.SnmpLib.Manager.get_bulk("invalid.host", [1, 3, 6, 1, 2, 1, 2, 2], timeout: 100))
true
# High-repetition bulk for large tables
# SnmpKit.SnmpLib.Manager.get_bulk("192.168.1.1", "1.3.6.1.2.1.2.2", max_repetitions: 50)
# {:ok, [...]} Returns up to 50 interface entries
"""
@spec get_bulk(host(), oid(), bulk_opts()) :: bulk_result()
def get_bulk(host, base_oid, opts \\ []) do
opts = merge_bulk_opts(opts)
normalized_oid = normalize_oid(base_oid)
# GETBULK requires SNMPv2c or higher
if opts[:version] == :v1 do
{:error, :getbulk_requires_v2c}
else
case create_socket(opts) do
{:ok, socket} ->
try do
case perform_bulk_operation(socket, host, normalized_oid, opts) do
{:ok, response} -> extract_bulk_result(response)
{:error, reason} -> {:error, reason}
end
after
close_socket(socket)
end
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Performs an SNMP SET operation to modify a value on the target device.
## Parameters
- `host`: Target device IP address or hostname
- `oid`: Object identifier to modify
- `value`: New value as {type, data} tuple (e.g., {:string, "new name"})
- `opts`: Configuration options
## Supported Value Types
- `{:string, binary()}`: OCTET STRING
- `{:integer, integer()}`: INTEGER
- `{:counter32, non_neg_integer()}`: Counter32
- `{:gauge32, non_neg_integer()}`: Gauge32
- `{:timeticks, non_neg_integer()}`: TimeTicks
- `{:ip_address, binary()}`: IpAddress (4 bytes)
## Returns
- `{:ok, :success}`: SET operation completed successfully
- `{:error, reason}`: Operation failed with reason
## Examples
# Test that SET function exists and handles invalid input properly
iex> match?({:error, _}, SnmpKit.SnmpLib.Manager.set("invalid.host", [1, 3, 6, 1, 2, 1, 1, 5, 0], {:string, "test"}, timeout: 100))
true
"""
@spec set(host(), oid(), {atom(), any()}, manager_opts()) :: {:ok, :success} | {:error, any()}
def set(host, oid, {type, value}, opts \\ []) do
opts = merge_default_opts(opts)
normalized_oid = normalize_oid(oid)
case create_socket(opts) do
{:ok, socket} ->
try do
case perform_set_operation(socket, host, normalized_oid, {type, value}, opts) do
{:ok, response} -> extract_set_result(response)
{:error, reason} -> {:error, reason}
end
after
close_socket(socket)
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Performs multiple GET operations efficiently with connection reuse.
More efficient than individual get/3 calls when retrieving multiple values
from the same device by reusing the same socket connection.
## Parameters
- `host`: Target device IP address or hostname
- `oids`: List of OIDs to retrieve
- `opts`: Configuration options
## Returns
- `{:ok, results}`: List of {oid, type, value} or {oid, {:error, reason}} tuples
- `{:error, reason}`: Connection or overall operation failed
## Examples
# Test that get_multi function exists and handles invalid input properly
iex> oids = ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0", "1.3.6.1.2.1.1.5.0"]
iex> match?({:error, _}, SnmpKit.SnmpLib.Manager.get_multi("invalid.host", oids, timeout: 100))
true
"""
@spec get_multi(host(), [oid()], manager_opts()) ::
{:ok, [{oid(), atom(), any() | {:error, any()}}]} | {:error, any()}
def get_multi(host, oids, opts \\ []) when is_list(oids) do
# Validate input parameters
case oids do
[] ->
{:error, :empty_oids}
_ ->
opts = merge_default_opts(opts)
normalized_oids = Enum.map(oids, &normalize_oid/1)
case create_socket(opts) do
{:ok, socket} ->
try do
results = get_multi_with_socket(socket, host, normalized_oids, opts)
process_multi_results(results)
after
close_socket(socket)
end
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Interprets SNMP errors with enhanced semantics for common cases.
Provides more specific error interpretation when generic errors like `:gen_err`
are returned by devices that should return more specific SNMP error codes.
## Parameters
- `error`: The original error returned by SNMP operations
- `operation`: The SNMP operation type (`:get`, `:set`, `:get_bulk`)
- `version`: SNMP version (`:v1`, `:v2c`, `:v3`)
## Returns
More specific error atom when possible, otherwise the original error.
## Examples
# Interpret genErr for GET operations
iex> SnmpKit.SnmpLib.Manager.interpret_error(:gen_err, :get, :v2c)
:no_such_object
iex> SnmpKit.SnmpLib.Manager.interpret_error(:gen_err, :get, :v1)
:no_such_name
iex> SnmpKit.SnmpLib.Manager.interpret_error(:too_big, :get, :v2c)
:too_big
"""
@spec interpret_error(atom(), atom(), atom()) :: atom()
def interpret_error(:gen_err, :get, :v1) do
# In SNMPv1, genErr for GET operations commonly means OID doesn't exist
:no_such_name
end
def interpret_error(:gen_err, :get, version) when version in [:v2c, :v2, :v3] do
# In SNMPv2c+, genErr for GET operations commonly means object doesn't exist
:no_such_object
end
def interpret_error(:gen_err, :get_bulk, version) when version in [:v2c, :v2, :v3] do
# For bulk operations, genErr often indicates end of MIB or missing objects
:no_such_object
end
def interpret_error(error, _operation, _version) do
# Return original error for all other cases
error
end
@doc """
Checks if a host is reachable via SNMP by performing a basic GET operation.
Useful for device discovery and health checking. Attempts to retrieve
sysUpTime (1.3.6.1.2.1.1.3.0) which should be available on all SNMP devices.
## Parameters
- `host`: Target device IP address or hostname
- `opts`: Configuration options (typically just community and timeout)
## Returns
- `{:ok, :reachable}`: Device responded to SNMP request
- `{:error, reason}`: Device not reachable or SNMP not available
## Examples
# Test that ping function exists and handles invalid input properly
iex> match?({:error, _}, SnmpKit.SnmpLib.Manager.ping("invalid.host", timeout: 100))
true
"""
@spec ping(host(), manager_opts()) :: {:ok, :reachable} | {:error, any()}
def ping(host, opts \\ []) do
# Use sysUpTime OID as it should be available on all SNMP devices
sys_uptime_oid = [1, 3, 6, 1, 2, 1, 1, 3, 0]
case get(host, sys_uptime_oid, opts) do
{:ok, {_type, _value}} -> {:ok, :reachable}
{:error, reason} -> {:error, reason}
end
end
## Private Implementation
# Socket management
defp create_socket(_opts) do
case Transport.create_client_socket() do
{:ok, socket} -> {:ok, socket}
{:error, reason} -> {:error, {:socket_error, reason}}
end
end
defp close_socket(socket) do
Transport.close_socket(socket)
end
# Operation implementations
defp perform_get_operation(socket, host, oid, opts) do
request_id = generate_request_id()
pdu = PDU.build_get_request(oid, request_id)
perform_snmp_request(socket, host, pdu, opts)
end
defp perform_bulk_operation(socket, host, base_oid, opts) do
request_id = generate_request_id()
max_reps = opts[:max_repetitions] || @default_max_repetitions
non_reps = opts[:non_repeaters] || @default_non_repeaters
pdu = PDU.build_get_bulk_request(base_oid, request_id, non_reps, max_reps)
perform_snmp_request(socket, host, pdu, opts)
end
defp perform_set_operation(socket, host, oid, value, opts) do
request_id = generate_request_id()
pdu = PDU.build_set_request(oid, value, request_id)
perform_snmp_request(socket, host, pdu, opts)
end
defp perform_snmp_request(socket, host, pdu, opts) do
community = opts[:community] || @default_community
version = opts[:version] || @default_version
timeout = opts[:timeout] || @default_timeout
port_option = opts[:port] || @default_port
{parsed_host, parsed_port} = parse_host_and_port(host, port_option)
message = PDU.build_message(pdu, community, version)
Logger.debug("Built SNMP message: #{inspect(message)}")
with {:ok, packet} <- encode_pdu_message(message),
{:ok, response_packet} <- send_and_receive(socket, parsed_host, parsed_port, packet, timeout) do
decode_response_message(response_packet)
end
end
defp parse_host_and_port(host, port_option) do
case Utils.parse_target(host) do
{:ok, %{host: h, port: p}} ->
# Check if host contained a port specification
if host_contains_port?(host) do
# Host:port format - use parsed port (backward compatibility)
{h, p}
else
# Host without port - use :port option
{h, port_option}
end
{:error, _} ->
# Parse failed - use original host and :port option
{host, port_option}
end
end
defp encode_pdu_message(message) do
case PDU.encode_message(message) do
{:ok, packet} ->
Logger.debug("Encoded PDU packet for transmission")
{:ok, packet}
{:error, encode_reason} = encode_error ->
Logger.error("PDU encode failed: #{inspect(encode_reason)}")
encode_error
end
end
defp decode_response_message(response_packet) do
Logger.debug("Received response packet from network")
case PDU.decode_message(response_packet) do
{:ok, response_message} ->
Logger.debug("Decoded response message: #{inspect(response_message)}")
{:ok, response_message}
{:error, decode_reason} = decode_error ->
Logger.error("PDU decode failed: #{inspect(decode_reason)}")
decode_error
end
end
defp send_and_receive(socket, host, port, packet, timeout) do
# Normal send-and-receive flow
case Transport.send_packet(socket, host, port, packet) do
:ok ->
Logger.debug("Packet sent successfully, waiting for response (timeout: #{timeout}ms)")
case Transport.receive_packet(socket, timeout) do
{:ok, {response_packet, _from_addr, _from_port}} ->
Logger.debug("Received response packet: #{byte_size(response_packet)} bytes")
{:ok, response_packet}
{:error, :timeout} = timeout_error ->
Logger.debug("Timeout waiting for response after #{timeout}ms")
timeout_error
{:error, reason} ->
Logger.debug("Error receiving response: #{inspect(reason)}")
{:error, {:network_error, reason}}
end
{:error, reason} ->
Logger.debug("Error sending packet: #{inspect(reason)}")
{:error, {:network_error, reason}}
end
end
# Multi-get implementation with connection reuse
defp get_multi_with_socket(socket, host, oids, opts) do
Enum.map(oids, fn oid ->
perform_single_get(socket, host, oid, opts)
end)
end
defp perform_single_get(socket, host, oid, opts) do
case perform_get_operation(socket, host, oid, opts) do
{:ok, response} ->
case extract_get_result_with_oid(response) do
{:ok, {oid, type, value}} -> {oid, type, value}
{:error, reason} -> {oid, {:error, reason}}
end
{:error, reason} ->
{oid, {:error, reason}}
end
end
# Result extraction
defp extract_get_result(%{pdu: %{error_status: error_status}} = response) when error_status != 0 do
Logger.debug("Extracting error result - error_status: #{error_status}")
Logger.debug("Full response PDU: #{inspect(response.pdu)}")
{:error, decode_error_status(error_status)}
end
# Handle responses with one or more varbinds - some devices return extra varbinds
defp extract_get_result(%{pdu: %{varbinds: [{oid, type, value} | _rest]}} = response) do
Logger.debug("Extracting successful result - PDU: #{inspect(response.pdu)}")
Logger.debug("Varbind details - oid: #{inspect(oid)}, type: #{inspect(type)}, value: #{inspect(value)}")
# Check for SNMPv2c exception values in both type and value fields
case {type, value} do
# Exception values in type field (from simulator)
{:no_such_object, _} ->
Logger.debug("Found exception in type field: no_such_object")
{:error, :no_such_object}
{:no_such_instance, _} ->
Logger.debug("Found exception in type field: no_such_instance")
{:error, :no_such_instance}
{:end_of_mib_view, _} ->
Logger.debug("Found exception in type field: end_of_mib_view")
{:error, :end_of_mib_view}
# Exception values in value field (standard format)
{_, {:no_such_object, _}} ->
Logger.debug("Found exception in value field: no_such_object")
{:error, :no_such_object}
{_, {:no_such_instance, _}} ->
Logger.debug("Found exception in value field: no_such_instance")
{:error, :no_such_instance}
{_, {:end_of_mib_view, _}} ->
Logger.debug("Found exception in value field: end_of_mib_view")
{:error, :end_of_mib_view}
# Normal value - return type and value only (OID is known from input)
_ ->
Logger.debug("Returning successful value with type: #{inspect({type, value})}")
{:ok, {type, value}}
end
end
defp extract_get_result(response) do
Logger.error("Invalid response format: #{inspect(response)}")
{:error, :invalid_response}
end
defp extract_get_result_with_oid(%{pdu: %{error_status: error_status}} = response) when error_status != 0 do
Logger.debug("Extracting error result - error_status: #{error_status}")
Logger.debug("Full response PDU: #{inspect(response.pdu)}")
{:error, decode_error_status(error_status)}
end
# Handle responses with one or more varbinds - some devices return extra varbinds
defp extract_get_result_with_oid(%{pdu: %{varbinds: [{oid, type, value} | _rest]}} = response) do
Logger.debug("Extracting successful result - PDU: #{inspect(response.pdu)}")
Logger.debug("Varbind details - oid: #{inspect(oid)}, type: #{inspect(type)}, value: #{inspect(value)}")
# Check for SNMPv2c exception values in both type and value fields
case {type, value} do
# Exception values in type field (from simulator)
{:no_such_object, _} ->
Logger.debug("Found exception in type field: no_such_object")
{:error, :no_such_object}
{:no_such_instance, _} ->
Logger.debug("Found exception in type field: no_such_instance")
{:error, :no_such_instance}
{:end_of_mib_view, _} ->
Logger.debug("Found exception in type field: end_of_mib_view")
{:error, :end_of_mib_view}
# Exception values in value field (standard format)
{_, {:no_such_object, _}} ->
Logger.debug("Found exception in value field: no_such_object")
{:error, :no_such_object}
{_, {:no_such_instance, _}} ->
Logger.debug("Found exception in value field: no_such_instance")
{:error, :no_such_instance}
{_, {:end_of_mib_view, _}} ->
Logger.debug("Found exception in value field: end_of_mib_view")
{:error, :end_of_mib_view}
# Normal value - return full 3-tuple for multi operations
_ ->
Logger.debug("Returning successful varbind with type: #{inspect({oid, type, value})}")
{:ok, {oid, type, value}}
end
end
defp extract_get_result_with_oid(response) do
Logger.error("Invalid response format: #{inspect(response)}")
{:error, :invalid_response}
end
defp extract_bulk_result(%{pdu: %{error_status: error_status}}) when error_status != 0 do
{:error, decode_error_status(error_status)}
end
defp extract_bulk_result(%{pdu: %{varbinds: varbinds}}) do
valid_varbinds =
Enum.filter(varbinds, fn {_oid, type, value} ->
# Check for SNMPv2c exception values in both type and value fields
case {type, value} do
# Exception values in type field (from simulator)
{:no_such_object, _} -> false
{:no_such_instance, _} -> false
{:end_of_mib_view, _} -> false
# Exception values in value field (standard format)
{_, {:no_such_object, _}} -> false
{_, {:no_such_instance, _}} -> false
{_, {:end_of_mib_view, _}} -> false
# Valid varbind
_ -> true
end
end)
# Return documented 3-tuple varbind format {oid, type, value}
{:ok, valid_varbinds}
end
defp extract_bulk_result(_), do: {:error, :invalid_response}
defp extract_set_result(%{pdu: %{error_status: 0}}) do
{:ok, :success}
end
defp extract_set_result(%{pdu: %{error_status: error_status}}) when error_status != 0 do
{:error, decode_error_status(error_status)}
end
defp extract_set_result(_), do: {:error, :invalid_response}
# GETNEXT implementation for SNMP v1
defp perform_get_next_v1(host, oid, opts) do
case create_socket(opts) do
{:ok, socket} ->
Logger.debug("Socket created successfully for GETNEXT v1")
case perform_get_next_operation(socket, host, oid, opts) do
{:ok, response} ->
Logger.debug("GETNEXT v1 operation completed, extracting result")
:ok = close_socket(socket)
result = extract_get_next_result(response)
Logger.debug("Final GETNEXT v1 result: #{inspect(result)}")
result
{:error, reason} ->
Logger.debug("GETNEXT v1 operation failed: #{inspect(reason)}")
:ok = close_socket(socket)
{:error, reason}
end
{:error, reason} ->
Logger.debug("Socket creation failed for GETNEXT v1: #{inspect(reason)}")
{:error, reason}
end
end
# GETNEXT implementation for SNMP v2c+
defp perform_get_next_v2c(host, oid, opts) do
# Use get_bulk with max_repetitions=1 for efficiency
bulk_opts = Keyword.merge(opts, max_repetitions: 1, non_repeaters: 0)
case get_bulk(host, oid, bulk_opts) do
{:ok, [{next_oid, type, value}]} ->
Logger.debug("GETNEXT v2c+ via GETBULK successful: #{inspect({next_oid, type, value})}")
{:ok, {next_oid, type, value}}
{:ok, []} ->
Logger.debug("GETNEXT v2c+ reached end of MIB")
{:error, :end_of_mib_view}
{:ok, results} when is_list(results) ->
# Take the first result if multiple returned
case List.first(results) do
{next_oid, type, value} -> {:ok, {next_oid, type, value}}
_ -> {:error, :invalid_response}
end
{:error, reason} ->
Logger.debug("GETNEXT v2c+ via GETBULK failed: #{inspect(reason)}")
{:error, reason}
end
end
defp perform_get_next_operation(socket, host, oid, opts) do
request_id = generate_request_id()
pdu = PDU.build_get_next_request(oid, request_id)
perform_snmp_request(socket, host, pdu, opts)
end
# Handle responses with one or more varbinds - some devices return extra varbinds
defp extract_get_next_result(%{pdu: %{varbinds: [{next_oid, type, value} | _rest]}} = response) do
Logger.debug("Extracting GETNEXT result - PDU: #{inspect(response.pdu)}")
Logger.debug("Varbind details - next_oid: #{inspect(next_oid)}, type: #{inspect(type)}, value: #{inspect(value)}")
# Check for SNMPv2c exception values in both type and value fields
case {type, value} do
# Exception values in type field (from simulator)
{:no_such_object, _} ->
Logger.debug("Found exception in type field: no_such_object")
{:error, :no_such_object}
{:no_such_instance, _} ->
Logger.debug("Found exception in type field: no_such_instance")
{:error, :no_such_instance}
{:end_of_mib_view, _} ->
Logger.debug("Found exception in type field: end_of_mib_view")
{:error, :end_of_mib_view}
# Exception values in value field (standard format)
{_, {:no_such_object, _}} ->
Logger.debug("Found exception in value field: no_such_object")
{:error, :no_such_object}
{_, {:no_such_instance, _}} ->
Logger.debug("Found exception in value field: no_such_instance")
{:error, :no_such_instance}
{_, {:end_of_mib_view, _}} ->
Logger.debug("Found exception in value field: end_of_mib_view")
{:error, :end_of_mib_view}
# Normal value - return both next OID and value
_ ->
Logger.debug("Returning successful GETNEXT result: #{inspect({next_oid, type, value})}")
{:ok, {next_oid, type, value}}
end
end
defp extract_get_next_result(response) do
Logger.error("Invalid GETNEXT response format: #{inspect(response)}")
{:error, :invalid_response}
end
# Helper functions
defp normalize_oid(oid) when is_list(oid) do
# Validate the list OID before returning
case OID.valid_oid?(oid) do
:ok -> oid
{:error, :empty_oid} -> [1, 3, 6, 1]
{:error, _} -> [1, 3, 6, 1]
end
end
defp normalize_oid(oid) when is_binary(oid) do
# First try MIB symbolic name resolution
case SnmpKit.SnmpLib.MIB.Registry.resolve_name(oid) do
{:ok, oid_list} ->
oid_list
{:error, _} ->
# Fallback to numeric string parsing
case OID.string_to_list(oid) do
{:ok, oid_list} -> oid_list
# Safe fallback
{:error, _} -> [1, 3, 6, 1]
end
end
end
defp normalize_oid(_), do: [1, 3, 6, 1]
defp generate_request_id do
:rand.uniform(2_147_483_647)
end
defp decode_error_status(0), do: :no_error
defp decode_error_status(1), do: :too_big
defp decode_error_status(2), do: :no_such_name
defp decode_error_status(3), do: :bad_value
defp decode_error_status(4), do: :read_only
defp decode_error_status(5), do: :gen_err
# SNMPv2c additional error codes (RFC 3416)
defp decode_error_status(6), do: :no_access
defp decode_error_status(7), do: :wrong_type
defp decode_error_status(8), do: :wrong_length
defp decode_error_status(9), do: :wrong_encoding
defp decode_error_status(10), do: :wrong_value
defp decode_error_status(11), do: :no_creation
defp decode_error_status(12), do: :inconsistent_value
defp decode_error_status(13), do: :resource_unavailable
defp decode_error_status(14), do: :commit_failed
defp decode_error_status(15), do: :undo_failed
defp decode_error_status(16), do: :authorization_error
defp decode_error_status(17), do: :not_writable
defp decode_error_status(18), do: :inconsistent_name
defp decode_error_status(error), do: {:unknown_error, error}
defp merge_default_opts(opts) do
Keyword.merge(
[
community: @default_community,
version: @default_version,
timeout: @default_timeout,
retries: @default_retries,
port: @default_port,
local_port: @default_local_port
],
opts
)
end
defp merge_bulk_opts(opts) do
opts
|> merge_default_opts()
|> Keyword.merge(
max_repetitions: @default_max_repetitions,
non_repeaters: @default_non_repeaters
)
|> Keyword.merge(opts)
end
# Helper to determine if host string contains port specification
defp host_contains_port?(host) when is_binary(host) do
cond do
# RFC 3986 bracket notation: [IPv6]:port
String.starts_with?(host, "[") and String.contains?(host, "]:") ->
check_bracketed_ipv6_port(host)
# Plain IPv6 addresses (contain :: or multiple colons) - no port embedded
String.contains?(host, "::") ->
false
host |> String.graphemes() |> Enum.count(&(&1 == ":")) > 1 ->
false
# IPv4 or simple hostname with port
String.contains?(host, ":") ->
check_simple_host_port(host)
# No colon at all
true ->
false
end
end
defp host_contains_port?(_), do: false
# Check if bracketed IPv6 address has valid port
defp check_bracketed_ipv6_port(host) do
case String.split(host, "]:", parts: 2) do
[_ipv6_part, port_part] -> valid_port?(port_part)
_ -> false
end
end
# Check if simple host:port format has valid port
defp check_simple_host_port(host) do
case String.split(host, ":", parts: 2) do
[_host_part, port_part] -> valid_port?(port_part)
_ -> false
end
end
# Validate port number
defp valid_port?(port_string) do
case Integer.parse(port_string) do
{port, ""} when port > 0 and port <= 65_535 -> true
_ -> false
end
end
# Check if all results failed with the same network-related error
defp process_multi_results(results) do
errors =
Enum.filter(results, fn
{_oid, {:error, _}} -> true
_ -> false
end)
# If all results are errors, check if they're all network-related
case {length(errors), length(results)} do
{same, same} when same > 0 ->
# All operations failed, check if it's a consistent network error
network_errors =
Enum.filter(errors, fn
{_oid, {:error, {:network_error, _}}} -> true
_ -> false
end)
case length(network_errors) do
^same ->
# All errors are network errors, return the first one as global failure
{_oid, {:error, reason}} = hd(errors)
{:error, reason}
_ ->
{:ok, results}
end
_ ->
{:ok, results}
end
end
end