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}} {: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}} {: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} 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