towerops/lib/snmpkit/snmp_mgr/core.ex

524 lines
16 KiB
Elixir

defmodule SnmpKit.SnmpMgr.Core do
@moduledoc """
Core SNMP operations using Erlang's SNMP PDU functions directly.
This module handles the low-level SNMP PDU encoding/decoding and UDP communication
without requiring the heavyweight :snmpm manager process.
## Timeout Behavior
All functions in this module use a single timeout parameter that controls
the SNMP PDU timeout - how long to wait for a response to each individual
SNMP packet sent to the target device.
- **Default timeout**: 10 seconds (10,000 milliseconds)
- **Timeout applies to**: Each individual SNMP PDU (GET, SET, GETBULK, etc.)
- **Not applicable to**: Multi-PDU operations (use walk functions for those)
For operations that may require multiple PDUs (like walking large tables),
consider using the higher-level walk functions in `SnmpKit.SnmpMgr.MultiV2`
which handle multi-PDU timeouts appropriately.
"""
alias SnmpKit.SnmpLib.Manager
alias SnmpKit.SnmpLib.OID
alias SnmpKit.SnmpMgr.MIB
alias SnmpKit.SnmpMgr.Target
@type snmp_result :: {:ok, term()} | {:error, atom() | tuple()}
@type target :: binary() | tuple() | map()
@type oid :: binary() | list(non_neg_integer())
@type opts :: keyword()
# Type normalization map for manager-expected atoms
@type_normalization %{
:string => :string,
:octet_string => :string,
:octetString => :string,
:integer => :integer,
:unsigned32 => :integer,
:gauge32 => :gauge32,
:counter32 => :counter32,
:counter64 => :counter64,
:timeticks => :timeticks,
:timeTicks => :timeticks,
:ip_address => :ip_address,
:ipAddress => :ip_address,
:object_identifier => :object_identifier,
:objectId => :object_identifier,
:oid => :object_identifier,
:null => :null,
:opaque => :opaque
}
@doc """
Sends an SNMP GET request and returns the response.
## Parameters
- `target` - SNMP target (host, "host:port", or target map)
- `oid` - Object identifier (string or list format)
- `opts` - Request options
- `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
- `:community` - SNMP community string (default: "public")
- `:version` - SNMP version (:v1, :v2c) (default: :v2c)
- `:port` - SNMP port (default: 161)
"""
@spec send_get_request(target(), oid(), opts()) :: snmp_result()
def send_get_request(target, oid, opts \\ []) do
# Parse target to extract host and port
{host, updated_opts} =
case Target.parse(target) do
{:ok, %{host: host, port: port}} ->
# Only use parsed port if the target actually contained a port specification
if target_contains_port?(target) do
# Target contained port - use parsed port
opts_with_port = Keyword.put(opts, :port, port)
{host, opts_with_port}
else
# Target didn't contain port - preserve user's port option
{host, opts}
end
{:error, _reason} ->
# Failed to parse, use as-is
{target, opts}
end
# Convert oid to proper format
oid_parsed =
case parse_oid(oid) do
{:ok, oid_list} -> oid_list
{:error, _} -> oid
end
# Map options to snmp_lib format
snmp_lib_opts = map_options_to_snmp_lib(updated_opts)
# Use SnmpKit.SnmpLib.Manager for the actual operation
case Manager.get(host, oid_parsed, snmp_lib_opts) do
{:ok, {type, value}} ->
{:ok, {type, value}}
# Type information must be preserved - reject responses without type information
{:ok, value} ->
{:error,
{:type_information_lost,
"SNMP GET operation must preserve type information. Got value without type: #{inspect(value)}"}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Sends a GET request and returns the result in 3-tuple format.
This function returns `{oid_string, type, value}` for consistency with
other operations like walk, bulk, etc.
"""
@spec send_get_request_with_type(target(), oid(), opts()) ::
{:ok, {String.t(), atom(), any()}} | {:error, any()}
def send_get_request_with_type(target, oid, opts \\ []) do
# Parse target to extract host and port
{host, updated_opts} =
case Target.parse(target) do
{:ok, %{host: host, port: port}} ->
# Use parsed port, overriding any default
opts_with_port = Keyword.put(opts, :port, port)
{host, opts_with_port}
{:error, _reason} ->
# Failed to parse, use as-is
{target, opts}
end
# Convert oid to proper format - always work with lists internally
oid_parsed =
case parse_oid(oid) do
{:ok, oid_list} -> oid_list
# Safe fallback
{:error, _} -> [1, 3]
end
# Generate string representation only for final response
oid_string = Enum.join(oid_parsed, ".")
# Map options to snmp_lib format
snmp_lib_opts = map_options_to_snmp_lib(updated_opts)
case Manager.get(host, oid_parsed, snmp_lib_opts) do
{:ok, {type, value}} ->
{:ok, {oid_string, type, value}}
{:ok, value} ->
# Type information must be preserved - reject responses without type information
{:error,
{:type_information_lost,
"SNMP GET operation must preserve type information. Got value without type for OID #{oid_string}: #{inspect(value)}"}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Sends a GETNEXT request to retrieve the next OID in the MIB tree.
Now uses the proper SnmpKit.SnmpLib.Manager.get_next/3 function which handles
version-specific logic (GETNEXT for v1, GETBULK for v2c+) correctly.
"""
@spec send_get_next_request(target(), oid(), opts()) :: snmp_result()
def send_get_next_request(target, oid, opts \\ []) do
# Parse target to extract host and port
{host, updated_opts} =
case Target.parse(target) do
{:ok, %{host: host, port: port}} ->
# Use parsed port, overriding any default
opts_with_port = Keyword.put(opts, :port, port)
{host, opts_with_port}
{:error, _reason} ->
# Failed to parse, use as-is
{target, opts}
end
# Convert oid to proper format
oid_parsed =
case parse_oid(oid) do
{:ok, oid_list} -> oid_list
{:error, _} -> oid
end
# Map options to snmp_lib format
snmp_lib_opts = map_options_to_snmp_lib(updated_opts)
# Use the new SnmpKit.SnmpLib.Manager.get_next function which properly handles version logic
case Manager.get_next(host, oid_parsed, snmp_lib_opts) do
{:ok, {next_oid, type, value}} ->
# ALWAYS preserve type information - this is critical for SNMP
# Convert OID to string only for final response format
next_oid_string =
if is_list(next_oid) do
Enum.join(next_oid, ".")
else
# Should not happen, but handle gracefully
to_string(next_oid)
end
{:ok, {next_oid_string, type, value}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Sends an SNMP SET request and returns the response.
## Parameters
- `target` - SNMP target (host, "host:port", or target map)
- `oid` - Object identifier to set
- `value` - Value to set (will be encoded based on type)
- `opts` - Request options
- `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
- `:community` - SNMP community string (default: "public")
- `:version` - SNMP version (:v1, :v2c) (default: :v2c)
"""
@spec send_set_request(target(), oid(), term(), opts()) :: snmp_result()
def send_set_request(target, oid, value, opts \\ []) do
{host, updated_opts} = parse_target_with_port(target, opts)
oid_parsed = parse_oid_or_fallback(oid)
typed_value = convert_value_to_typed(value)
snmp_lib_opts = map_options_to_snmp_lib(updated_opts)
case Manager.set(host, oid_parsed, typed_value, snmp_lib_opts) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
end
defp parse_target_with_port(target, opts) do
case Target.parse(target) do
{:ok, %{host: host, port: port}} ->
if target_contains_port?(target) do
{host, Keyword.put(opts, :port, port)}
else
{host, opts}
end
{:error, _reason} ->
{target, opts}
end
end
defp parse_oid_or_fallback(oid) do
case parse_oid(oid) do
{:ok, oid_list} -> oid_list
{:error, _} -> oid
end
end
defp convert_value_to_typed(value) do
cond do
match?({t, _v} when is_atom(t), value) -> normalize_manager_typed(value)
is_binary(value) -> {:string, value}
is_integer(value) -> {:integer, value}
ipv4_tuple?(value) -> {:ip_address, value}
true -> {:opaque, value}
end
end
defp ipv4_tuple?({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d), do: true
defp ipv4_tuple?(_), do: false
# Normalize typed values to the atoms that SnmpKit.SnmpLib.Manager expects
defp normalize_manager_typed({type, val}) when is_atom(type) do
mapped_type = Map.get(@type_normalization, type, type)
{mapped_type, val}
end
# Extract varbinds from get_bulk results
defp extract_varbinds(%{"varbinds" => varbinds}) when is_list(varbinds), do: varbinds
defp extract_varbinds(results) when is_list(results), do: results
defp extract_varbinds(_), do: []
@doc """
Sends an SNMP GETBULK request (SNMPv2c only).
GETBULK is more efficient than multiple GETNEXT operations for retrieving
multiple consecutive OIDs.
## Parameters
- `target` - SNMP target (host, "host:port", or target map)
- `oid` - Starting OID for bulk retrieval
- `opts` - Request options
- `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
- `:max_repetitions` - Maximum number of OIDs to retrieve (default: 30)
- `:community` - SNMP community string (default: "public")
- `:version` - SNMP version (must be :v2c) (default: :v2c)
"""
@spec send_get_bulk_request(target(), oid(), opts()) :: snmp_result()
def send_get_bulk_request(target, oid, opts \\ []) do
version = Keyword.get(opts, :version, :v2c)
case version do
:v2c ->
{host, updated_opts} = parse_target_with_port(target, opts)
oid_parsed = parse_oid_or_fallback(oid)
snmp_lib_opts = map_options_to_snmp_lib(updated_opts)
case Manager.get_bulk(host, oid_parsed, snmp_lib_opts) do
{:ok, results} ->
{:ok, extract_varbinds(results)}
{:error, reason} ->
{:error, reason}
end
_ ->
{:error, :getbulk_requires_v2c}
end
end
@doc """
Sends an asynchronous SNMP GET request.
Returns immediately with a reference. The calling process will receive
a message with the result.
## Parameters
- `target` - SNMP target (host, "host:port", or target map)
- `oid` - Object identifier (string or list format)
- `opts` - Request options
- `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
- `:community` - SNMP community string (default: "public")
- `:version` - SNMP version (:v1, :v2c) (default: :v2c)
## Returns
Reference that will be included in the response message.
"""
@spec send_get_request_async(target(), oid(), opts()) :: reference()
def send_get_request_async(target, oid, opts \\ []) do
caller = self()
ref = make_ref()
spawn(fn ->
result = send_get_request(target, oid, opts)
send(caller, {ref, result})
end)
ref
end
@doc """
Sends an asynchronous SNMP GETBULK request.
Returns immediately with a reference. The calling process will receive
a message with the result.
## Parameters
- `target` - SNMP target (host, "host:port", or target map)
- `oid` - Starting OID for bulk retrieval
- `opts` - Request options
- `:timeout` - SNMP PDU timeout in milliseconds (default: 10000)
- `:max_repetitions` - Maximum number of OIDs to retrieve (default: 30)
- `:community` - SNMP community string (default: "public")
- `:version` - SNMP version (must be :v2c) (default: :v2c)
## Returns
Reference that will be included in the response message.
"""
@spec send_get_bulk_request_async(target(), oid(), opts()) :: reference()
def send_get_bulk_request_async(target, oid, opts \\ []) do
caller = self()
ref = make_ref()
spawn(fn ->
result = send_get_bulk_request(target, oid, opts)
send(caller, {ref, result})
end)
ref
end
# Private functions for snmp_lib integration
@spec map_options_to_snmp_lib(opts()) :: list()
defp map_options_to_snmp_lib(opts) do
# Map SnmpMgr options to SnmpKit.SnmpLib.Manager options
mapped = []
mapped =
if community = Keyword.get(opts, :community),
do: [{:community, community} | mapped],
else: mapped
mapped =
if timeout = Keyword.get(opts, :timeout), do: [{:timeout, timeout} | mapped], else: mapped
mapped =
if retries = Keyword.get(opts, :retries), do: [{:retries, retries} | mapped], else: mapped
mapped =
if version = Keyword.get(opts, :version), do: [{:version, version} | mapped], else: mapped
mapped = if port = Keyword.get(opts, :port), do: [{:port, port} | mapped], else: mapped
mapped =
if max_repetitions = Keyword.get(opts, :max_repetitions),
do: [{:max_repetitions, max_repetitions} | mapped],
else: mapped
mapped =
if non_repeaters = Keyword.get(opts, :non_repeaters),
do: [{:non_repeaters, non_repeaters} | mapped],
else: mapped
mapped
end
@doc """
Parses and normalizes an OID to internal list format.
Converts external OID input (string or list) to internal list of integers format.
This function establishes the API boundary - all external input is converted to
internal list format here.
"""
@spec parse_oid(oid()) :: {:ok, list(non_neg_integer())} | {:error, term()}
def parse_oid(oid) when is_list(oid) do
# Already a list - validate and return
case OID.valid_oid?(oid) do
:ok ->
{:ok, oid}
{:error, :empty_oid} ->
# Empty list fallback
{:ok, [1, 3]}
{:error, _reason} ->
case oid do
# Single [1] is invalid, use [1,3]
[1] -> {:ok, [1, 3]}
_ -> {:error, :invalid_oid_list}
end
end
end
def parse_oid(oid) when is_binary(oid) do
case String.trim(oid) do
"" -> {:ok, [1, 3]}
trimmed -> resolve_oid_string(trimmed)
end
end
def parse_oid(_), do: {:error, :invalid_oid_input}
defp resolve_oid_string(trimmed) do
with {:error, _} <- SnmpKit.SnmpLib.MIB.Registry.resolve_name(trimmed),
{:error, _} <- parse_numeric_oid(trimmed) do
MIB.resolve(trimmed)
end
end
defp parse_numeric_oid(oid_string) do
case OID.string_to_list(oid_string) do
{:ok, [_ | _] = oid_list} -> {:ok, oid_list}
{:ok, []} -> {:ok, [1, 3]}
error -> error
end
end
# Type information must never be inferred - it must be preserved from SNMP responses
# Removing type inference functions to prevent loss of critical type information
# Any SNMP operation that does not preserve type information should fail with an error
# Private helper to check if target contains port specification
defp target_contains_port?(target) when is_binary(target) do
cond do
# RFC 3986 bracket notation: [IPv6]:port
String.starts_with?(target, "[") and String.contains?(target, "]:") ->
target |> extract_bracket_port() |> valid_port_string?()
# Plain IPv6 addresses (contain :: or multiple colons) - no port embedded
String.contains?(target, "::") ->
false
target |> String.graphemes() |> Enum.count(&(&1 == ":")) > 1 ->
false
# IPv4 or simple hostname with port
String.contains?(target, ":") ->
target |> extract_simple_port() |> valid_port_string?()
# No colon at all
true ->
false
end
end
defp target_contains_port?(_), do: false
defp extract_bracket_port(target) do
case String.split(target, "]:", parts: 2) do
[_ipv6_part, port_part] -> port_part
_ -> nil
end
end
defp extract_simple_port(target) do
case String.split(target, ":", parts: 2) do
[_host_part, port_part] -> port_part
_ -> nil
end
end
defp valid_port_string?(nil), do: false
defp valid_port_string?(port_str) do
case Integer.parse(port_str) do
{port, ""} when port > 0 and port <= 65_535 -> true
_ -> false
end
end
end