From 88c003d47429a1c7800e2f6e5e98aecdaf5c639f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 28 Jan 2026 09:07:13 -0600 Subject: [PATCH] major snmp overhaul --- Makefile | 5 +- lib/snmpkit/snmp_mgr/mib.ex | 295 ++++++++++++++---- lib/towerops/application.ex | 16 +- lib/towerops/profiles/yaml_profiles.ex | 261 +++++++++++++--- lib/towerops/snmp/agent_discovery.ex | 4 +- lib/towerops/snmp/client.ex | 53 +++- lib/towerops/snmp/discovery.ex | 65 ++-- lib/towerops/snmp/mib_loader.ex | 88 ++++++ lib/towerops/snmp/mib_translator.ex | 44 ++- priv/static/changelog.txt | 3 + scripts/compile_mibs.py | 120 +++++++ test/snmpkit/snmp_lib/mib/parser_test.exs | 210 +------------ test/snmpkit/snmp_mgr/mib_extraction_test.exs | 134 ++++++++ test/snmpkit/snmp_mgr/mib_parser_test.exs | 60 ++++ test/snmpkit/snmp_mgr/mib_test.exs | 201 ++++++++++++ test/towerops/snmp/mib_loader_test.exs | 66 ++++ 16 files changed, 1297 insertions(+), 328 deletions(-) create mode 100644 lib/towerops/snmp/mib_loader.ex create mode 100755 scripts/compile_mibs.py create mode 100644 test/snmpkit/snmp_mgr/mib_extraction_test.exs create mode 100644 test/snmpkit/snmp_mgr/mib_parser_test.exs create mode 100644 test/snmpkit/snmp_mgr/mib_test.exs create mode 100644 test/towerops/snmp/mib_loader_test.exs diff --git a/Makefile b/Makefile index 30ff5c39..fd4c01e5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build push deploy restart login clean +.PHONY: help build push deploy restart login clean compile-mibs # Variables REGISTRY := registry.gitlab.com/towerops/towerops @@ -74,3 +74,6 @@ info: ## Show current image information @echo "Tags:" @echo " $(IMAGE_TAG_LATEST)" @echo " $(IMAGE_TAG_COMMIT)" + +compile-mibs: ## Pre-compile MIB files to JSON for runtime loading + @python3 scripts/compile_mibs.py diff --git a/lib/snmpkit/snmp_mgr/mib.ex b/lib/snmpkit/snmp_mgr/mib.ex index 1d4aa6d0..d50b58ca 100644 --- a/lib/snmpkit/snmp_mgr/mib.ex +++ b/lib/snmpkit/snmp_mgr/mib.ex @@ -460,10 +460,9 @@ defmodule SnmpKit.SnmpMgr.MIB do This combines compilation/loading with parsing for comprehensive MIB support. """ def load_and_integrate_mib(mib_file, opts \\ []) do - with {:ok, _compiled} <- compile(mib_file, opts), - {:ok, parsed} <- parse_mib_file(mib_file, opts) do - # Register both compiled and parsed data - GenServer.call(__MODULE__, {:integrate_mib_data, mib_file, parsed}) + with {:ok, compiled} <- compile(mib_file, opts), + :ok <- GenServer.call(__MODULE__, {:integrate_mib_data, mib_file, compiled}) do + {:ok, compiled} end end @@ -585,18 +584,6 @@ defmodule SnmpKit.SnmpMgr.MIB do {:reply, result, state} end - @impl true - def handle_call({:load_mib, mib_path}, _from, state) do - case load_mib_file_and_extract_mappings(mib_path) do - {:ok, mib_data} -> - new_state = merge_mib_data(state, mib_data) - {:reply, :ok, new_state} - - error -> - {:reply, error, state} - end - end - @impl true def handle_call(:load_standard_mibs, _from, state) do # Standard MIBs are already loaded in init @@ -630,6 +617,19 @@ defmodule SnmpKit.SnmpMgr.MIB do {:reply, :ok, new_state} end + def handle_call({:bulk_register, mappings}, _from, state) do + # Bulk register pre-compiled MIB mappings (from JSON files) + merged_name_to_oid = Map.merge(state.name_to_oid, mappings) + merged_oid_to_name = build_reverse_map(merged_name_to_oid) + + new_state = + state + |> Map.put(:name_to_oid, merged_name_to_oid) + |> Map.put(:oid_to_name, merged_oid_to_name) + + {:reply, :ok, new_state} + end + ## Private Functions defp compile_with_snmp_lib(mib_file, opts) do @@ -849,11 +849,45 @@ defmodule SnmpKit.SnmpMgr.MIB do end end - defp integrate_parsed_mib_data(state, mib_file, parsed_data) do - # Integrate parsed MIB objects into our name resolution + defp integrate_parsed_mib_data(state, mib_file, compiled_data) do + # Read the MIB file content for workaround + mib_content = + case File.read(mib_file) do + {:ok, content} -> content + _ -> "" + end + + # Extract OID mappings from compiled MIB symbols + mib_mappings = extract_mib_mappings_from_compiled(compiled_data, state, mib_content) + + # Merge extracted OIDs into state + merged_name_to_oid = Map.merge(state.name_to_oid, mib_mappings.name_to_oid) + merged_oid_to_name = build_reverse_map(merged_name_to_oid) + merged_name_to_meta = Map.merge(state.name_to_meta, mib_mappings.name_to_meta) + + # Also store raw compiled data integrated_mibs = Map.get(state, :integrated_mibs, %{}) - new_integrated = Map.put(integrated_mibs, mib_file, parsed_data) - Map.put(state, :integrated_mibs, new_integrated) + new_integrated = Map.put(integrated_mibs, mib_file, compiled_data) + + state + |> Map.put(:name_to_oid, merged_name_to_oid) + |> Map.put(:oid_to_name, merged_oid_to_name) + |> Map.put(:name_to_meta, merged_name_to_meta) + |> Map.put(:integrated_mibs, new_integrated) + end + + # Extract OID mappings from compiled MIB data (symbols format) + defp extract_mib_mappings_from_compiled(compiled_data, state, mib_content) do + symbols = Map.get(compiled_data, :symbols, %{}) + + # Convert symbols to definitions format + definitions = + Enum.map(symbols, fn {name, symbol_data} -> + Map.put(symbol_data, :name, name) + end) + + # Reuse existing extraction logic + extract_mib_mappings(%{definitions: definitions}, state, mib_content) end defp resolve_name(name, _name_to_oid_map) when is_nil(name) or not is_binary(name) do @@ -1182,6 +1216,29 @@ defmodule SnmpKit.SnmpMgr.MIB do end end + # Handle tuple format: {parent_name, index_binary} + # This format is used by the MIB parser for OID references + defp normalize_parsed_oid({parent_name, index_binary}) when is_atom(parent_name) and is_binary(index_binary) do + # Try to resolve the parent OID from current state + parent_str = Atom.to_string(parent_name) + + case resolve(parent_str) do + {:ok, parent_oid} when is_list(parent_oid) -> + # Parse the index as an integer + case :binary.decode_unsigned(index_binary) do + index when is_integer(index) -> + {:ok, parent_oid ++ [index]} + + _ -> + {:error, :invalid_index} + end + + _ -> + # Parent not resolved yet - will be handled in second pass + {:error, :unresolved_parent} + end + end + defp normalize_parsed_oid(_), do: {:error, :invalid_oid} # Handle lists like [%{value: 1}, %{value: 3}, ...] possibly with names @@ -1208,32 +1265,58 @@ defmodule SnmpKit.SnmpMgr.MIB do defp normalize_oid_element(v) when is_integer(v), do: {:ok, v} defp normalize_oid_element(_), do: :error - defp load_mib_file_and_extract_mappings(mib_path) do - case File.read(mib_path) do - {:ok, mib_content} -> - case Parser.parse(mib_content) do - {:ok, parsed_mib_data} -> {:ok, extract_mib_mappings(parsed_mib_data)} - {:error, reason} -> {:error, {:mib_parse_failed, reason}} - end - - {:error, reason} -> - {:error, {:file_read_failed, reason}} - end - end - - defp extract_mib_mappings(mib_data) do + defp extract_mib_mappings(mib_data, state, mib_content) do # Extract name-to-OID mappings and basic metadata from parsed MIB data definitions = Map.get(mib_data, :definitions, []) tc_map = build_tc_map(definitions) - {name_to_oid_map, name_to_meta} = - Enum.reduce(definitions, {%{}, %{}}, fn defn, {oid_acc, meta_acc} -> - process_mib_definition(defn, oid_acc, meta_acc, tc_map) - end) + # Build a map of name to OID definition for resolution + oid_defs = build_oid_definition_map(definitions) + + # Workaround for parser bug: manually extract definitions with missing sub_index + oid_defs = fix_missing_sub_indices(oid_defs, mib_content) + + # Resolve OIDs recursively, using existing state for parent resolution + name_to_oid_map = resolve_all_oids(oid_defs, state.name_to_oid) + + # Build metadata for object types + name_to_meta = build_metadata_map(definitions, tc_map) %{name_to_oid: name_to_oid_map, name_to_meta: name_to_meta} end + # Workaround for parser bug: extract OBJECT-IDENTIFIER definitions with missing sub_index + # The parser fails to extract sub_index when the value is 10 (newline character) + defp fix_missing_sub_indices(oid_defs, mib_content) do + # Find entries with nil sub_index but parent reference + missing_indices = + oid_defs + |> Enum.filter(fn {_name, oid_def} -> + case oid_def do + {_parent, nil} -> true + _ -> false + end + end) + |> Enum.map(fn {name, _} -> name end) + + # For each missing entry, try to extract from raw MIB content + Enum.reduce(missing_indices, oid_defs, fn name, acc -> + # Pattern: name OBJECT IDENTIFIER ::= { parent index } + regex = ~r/#{name}\s+OBJECT\s+IDENTIFIER\s+::=\s+\{\s+(\w+)\s+(\d+)\s+\}/ + + case Regex.run(regex, mib_content) do + [_, parent, index_str] -> + index = String.to_integer(index_str) + # Convert to the same format as other indices (binary with the integer as byte) + parent_atom = String.to_atom(parent) + Map.put(acc, name, {parent_atom, <>}) + + _ -> + acc + end + end) + end + # Build textual convention map from definitions defp build_tc_map(definitions) do definitions @@ -1246,28 +1329,130 @@ defmodule SnmpKit.SnmpMgr.MIB do end) end - # Process a single MIB definition - defp process_mib_definition(%{__type__: :object_type} = defn, oid_acc, meta_acc, tc_map) do - name = Map.get(defn, :name) - oid_any = Map.get(defn, :oid) + # Build a map of name to OID definition for resolution + defp build_oid_definition_map(definitions) do + definitions + |> Enum.filter(fn def -> + type = Map.get(def, :__type__) + type in [:object_type, :object_identifier, :module_identity] + end) + |> Enum.reduce(%{}, fn def, acc -> + name = Map.get(def, :name) + oid = Map.get(def, :oid) + parent = Map.get(def, :parent) + sub_index = Map.get(def, :sub_index) - oid_acc2 = maybe_add_oid_mapping(name, oid_any, oid_acc) - meta = build_object_type_meta(defn, tc_map) + cond do + # Object has explicit OID (e.g., {:parentName, <>}) + name && oid -> + Map.put(acc, name, oid) - {oid_acc2, Map.put(meta_acc, name, meta)} + # OBJECT-IDENTIFIER with parent and sub_index + name && parent && sub_index -> + # Convert parent/sub_index to the same format as OID tuples + parent_atom = String.to_atom(parent) + Map.put(acc, name, {parent_atom, sub_index}) + + # OBJECT-IDENTIFIER with parent but missing sub_index (parser bug) + # Mark with nil so fix_missing_sub_indices can handle it + name && parent -> + parent_atom = String.to_atom(parent) + Map.put(acc, name, {parent_atom, nil}) + + # No usable data + true -> + acc + end + end) end - defp process_mib_definition(_defn, oid_acc, meta_acc, _tc_map), do: {oid_acc, meta_acc} + # Resolve all OIDs recursively + defp resolve_all_oids(oid_defs, existing_oids) do + # Start with existing OIDs from state and iterate until all resolvable OIDs are resolved + resolve_oids_iteratively(oid_defs, existing_oids, 10) + end - # Add OID mapping if valid - defp maybe_add_oid_mapping(name, oid_any, oid_acc) when is_binary(name) do - case normalize_parsed_oid(oid_any) do - {:ok, oid_list} -> Map.put(oid_acc, name, oid_list) - _ -> oid_acc + defp resolve_oids_iteratively(_oid_defs, resolved, 0) do + # Max iterations reached, return what we have + resolved + end + + defp resolve_oids_iteratively(oid_defs, resolved, iterations_left) do + {new_resolved, remaining} = + Enum.reduce(oid_defs, {resolved, %{}}, fn {name, oid_def}, {res_acc, rem_acc} -> + case resolve_single_oid(oid_def, res_acc) do + {:ok, oid_list} -> + {Map.put(res_acc, name, oid_list), rem_acc} + + {:error, _reason} -> + {res_acc, Map.put(rem_acc, name, oid_def)} + end + end) + + if map_size(remaining) == map_size(oid_defs) do + # No progress made, stop iterating + new_resolved + else + # Continue with remaining unresolved OIDs + resolve_oids_iteratively(remaining, new_resolved, iterations_left - 1) end end - defp maybe_add_oid_mapping(_name, _oid_any, oid_acc), do: oid_acc + defp resolve_single_oid(oid_def, _resolved) when is_list(oid_def) do + # Already a list of integers + case oid_def do + [] -> + {:error, :empty_oid} + + _ -> + if Enum.all?(oid_def, &is_integer/1) do + {:ok, oid_def} + else + {:error, :not_resolved} + end + end + end + + defp resolve_single_oid({parent_name, index_binary}, resolved) when is_atom(parent_name) and is_binary(index_binary) do + parent_str = Atom.to_string(parent_name) + + case Map.get(resolved, parent_str) do + parent_oid when is_list(parent_oid) -> + # Decode the index - MIB parser uses UTF-8 character for numeric values + index = decode_mib_index(index_binary) + {:ok, parent_oid ++ [index]} + + _ -> + {:error, :parent_not_resolved} + end + end + + defp resolve_single_oid(_oid_def, _resolved), do: {:error, :invalid_format} + + # Decode MIB index from binary + # The parser represents integers as UTF-8 characters where the code point is the value + defp decode_mib_index(binary) when is_binary(binary) do + case String.to_charlist(binary) do + [code_point | _] -> code_point + [] -> :binary.decode_unsigned(binary) + end + end + + # Build metadata map for object types + defp build_metadata_map(definitions, tc_map) do + definitions + |> Enum.filter(&(Map.get(&1, :__type__) == :object_type)) + |> Enum.reduce(%{}, fn def, acc -> + name = Map.get(def, :name) + + if name do + meta = build_object_type_meta(def, tc_map) + Map.put(acc, name, meta) + else + acc + end + end) + end # Build metadata for an object type definition defp build_object_type_meta(defn, tc_map) do @@ -1321,10 +1506,4 @@ defmodule SnmpKit.SnmpMgr.MIB do {syntax_base_from(syntax_atom), textual_convention_from(syntax_atom), nil} end end - - defp merge_mib_data(state, _mib_data) do - # This would merge the new MIB data with existing state - # For now, just return the current state - state - end end diff --git a/lib/towerops/application.ex b/lib/towerops/application.ex index de742134..24492646 100644 --- a/lib/towerops/application.ex +++ b/lib/towerops/application.ex @@ -12,6 +12,18 @@ defmodule Towerops.Application do @impl true def start(_type, _args) do + # Ensure parsetools is started for MIB parsing (yecc dependency) + _ = Application.ensure_all_started(:parsetools) + + # Add snmpkit ebin directory to code path for mib_grammar_elixir.beam + # This is needed because snmpkit has vendored Erlang modules (compiled from .yrl) + # Note: With pre-compiled MIBs, this may no longer be needed + snmpkit_ebin = Application.app_dir(:towerops, "../snmpkit/ebin") + + if File.dir?(snmpkit_ebin) do + :code.add_patha(to_charlist(snmpkit_ebin)) + end + # Run migrations on startup (Ecto handles locking for concurrent runs) _ = if Application.get_env(:towerops, Towerops.Repo)[:database] != "towerops_test" do @@ -32,7 +44,9 @@ defmodule Towerops.Application do Towerops.Profiles.YamlProfiles, # SnmpKit services for SNMP operations SnmpKit.SnmpMgr.Config, - SnmpKit.SnmpMgr.MIB + SnmpKit.SnmpMgr.MIB, + # Load vendor MIBs after SnmpKit.SnmpMgr.MIB starts + {Task, fn -> Towerops.Snmp.MibLoader.load_all_mibs() end} ] ++ dev_only_workers() ++ background_workers() ++ diff --git a/lib/towerops/profiles/yaml_profiles.ex b/lib/towerops/profiles/yaml_profiles.ex index b21e7611..1b571954 100644 --- a/lib/towerops/profiles/yaml_profiles.ex +++ b/lib/towerops/profiles/yaml_profiles.ex @@ -8,6 +8,8 @@ defmodule Towerops.Profiles.YamlProfiles do use GenServer + alias Towerops.Snmp.Client + require Logger @table :yaml_profiles @@ -46,16 +48,15 @@ defmodule Towerops.Profiles.YamlProfiles do Uses LibreNMS's two-pass detection approach: 1. First pass: Check profiles WITHOUT snmpget/snmpwalk conditions (except generic OS) - 2. Second pass: Check profiles WITH snmpget/snmpwalk conditions (deferred) - - Since we can't evaluate snmpget, these will effectively be skipped + 2. Second pass: Check profiles WITH snmpget/snmpwalk conditions (evaluated via SNMP) 3. Third pass: Check generic OS (airos, freebsd, linux) as final fallback """ - def match_profile(system_info) do + def match_profile(system_info, client_opts \\ []) do sys_object_id = Map.get(system_info, :sys_object_id, "") sys_descr = Map.get(system_info, :sys_descr, "") # First try to match by sysObjectID using LibreNMS's approach - case match_by_oid_librenms_style(sys_object_id, sys_descr) do + case match_by_oid_librenms_style(sys_object_id, sys_descr, client_opts) do nil -> match_by_descr(sys_descr) profile -> profile end @@ -195,16 +196,24 @@ defmodule Towerops.Profiles.YamlProfiles do defp parse_discovery_block(block) do oids = extract_oids_from_block(block) - has_condition = Map.has_key?(block, "snmpget") or Map.has_key?(block, "snmpwalk") + has_snmpget = Map.has_key?(block, "snmpget") + has_snmpwalk = Map.has_key?(block, "snmpwalk") descr_patterns = extract_descr_from_block(block) descr_regex = extract_descr_regex_from_block(block) + snmpget_condition = extract_snmpget_condition(block) + snmpwalk_condition = extract_snmpwalk_condition(block) Enum.map(oids, fn oid -> %{ oid: normalize_oid(oid), - has_condition: has_condition, + has_snmpget: has_snmpget, + has_snmpwalk: has_snmpwalk, + # Both snmpget and snmpwalk are supported conditional checks + has_condition: has_snmpget || has_snmpwalk, descr_patterns: descr_patterns, - descr_regex: descr_regex + descr_regex: descr_regex, + snmpget: snmpget_condition, + snmpwalk: snmpwalk_condition } end) end @@ -241,6 +250,28 @@ defmodule Towerops.Profiles.YamlProfiles do end end + defp extract_snmpget_condition(block) do + case Map.get(block, "snmpget") do + %{"oid" => oid, "value" => value} = condition -> + # Default operator is 'contains' like LibreNMS + %{oid: oid, op: Map.get(condition, "op", "contains"), value: value} + + _ -> + nil + end + end + + defp extract_snmpwalk_condition(block) do + case Map.get(block, "snmpwalk") do + %{"oid" => oid, "value" => value} = condition -> + # Default operator is 'contains' like LibreNMS + %{oid: oid, op: Map.get(condition, "op", "contains"), value: value} + + _ -> + nil + end + end + # Strip leading dot from OID (LibreNMS uses .1.3.6... but SNMP returns 1.3.6...) defp normalize_oid(nil), do: nil defp normalize_oid("." <> rest), do: rest @@ -584,54 +615,90 @@ defmodule Towerops.Profiles.YamlProfiles do # # We check per-block, not per-profile. A profile may have both conditional and unconditional # blocks for different OIDs (e.g., ibm-imm has snmpget block for one OID and plain block for another) - defp match_by_oid_librenms_style(sys_object_id, sys_descr) when is_binary(sys_object_id) and sys_object_id != "" do + defp match_by_oid_librenms_style(sys_object_id, sys_descr, client_opts) + when is_binary(sys_object_id) and sys_object_id != "" do all_profiles = list_profiles() # Categorize profiles {generic_profiles, other_profiles} = Enum.split_with(all_profiles, fn p -> p.name in @generic_os end) - # First pass: non-generic profiles with unconditional matching blocks - case find_best_unconditional_match(other_profiles, sys_object_id, sys_descr) do - nil -> - # Second pass: generic OS with unconditional blocks - case find_best_unconditional_match(generic_profiles, sys_object_id, sys_descr) do - nil -> - # Third pass: generic OS with conditional blocks (for profiles like airos) - find_best_conditional_match(generic_profiles, sys_object_id, sys_descr) + # Try matching in priority order, return first match + try_profile_matches([ + # First pass: non-generic profiles with unconditional matching blocks + fn -> find_best_unconditional_match(other_profiles, sys_object_id, sys_descr) end, + # Second pass: non-generic profiles with conditional blocks (snmpget/snmpwalk) + fn -> find_best_conditional_match(other_profiles, sys_object_id, sys_descr, client_opts) end, + # Third pass: generic OS with unconditional blocks + fn -> find_best_unconditional_match(generic_profiles, sys_object_id, sys_descr) end, + # Fourth pass: generic OS with conditional blocks (for profiles like airos) + fn -> find_best_conditional_match(generic_profiles, sys_object_id, sys_descr, client_opts) end + ]) + end - profile -> - profile - end + defp match_by_oid_librenms_style(_, _, _), do: nil - profile -> - profile + defp try_profile_matches([]), do: nil + + defp try_profile_matches([matcher | rest]) do + case matcher.() do + nil -> try_profile_matches(rest) + profile -> profile end end - defp match_by_oid_librenms_style(_, _), do: nil - # Find best match using only unconditional detection blocks (no snmpget/snmpwalk) defp find_best_unconditional_match(profiles, sys_object_id, sys_descr) do - find_best_block_match(profiles, sys_object_id, sys_descr, false) + find_best_block_match(profiles, sys_object_id, sys_descr, false, []) end # Find best match using conditional detection blocks (has snmpget/snmpwalk) - defp find_best_conditional_match(profiles, sys_object_id, sys_descr) do - find_best_block_match(profiles, sys_object_id, sys_descr, true) + defp find_best_conditional_match(profiles, sys_object_id, sys_descr, client_opts) do + profile_names = Enum.map(profiles, & &1.name) + + Logger.info("Attempting conditional profile match with snmpget evaluation", + sys_object_id: sys_object_id, + sys_descr: sys_descr, + profile_count: length(profiles), + profiles: Enum.join(profile_names, ", ") + ) + + find_best_block_match(profiles, sys_object_id, sys_descr, true, client_opts) end # Find the best matching profile based on detection blocks - defp find_best_block_match(profiles, sys_object_id, sys_descr, match_conditional) do + defp find_best_block_match(profiles, sys_object_id, sys_descr, match_conditional, client_opts) do profiles - |> Enum.flat_map(&find_profile_matching_blocks(&1, sys_object_id, sys_descr, match_conditional)) + |> Enum.flat_map(&find_profile_matching_blocks(&1, sys_object_id, sys_descr, match_conditional, client_opts)) |> select_best_match() end - defp find_profile_matching_blocks(profile, sys_object_id, sys_descr, match_conditional) do + defp find_profile_matching_blocks(profile, sys_object_id, sys_descr, match_conditional, client_opts) do + # Log when checking profiles with conditional blocks + conditional_blocks = Enum.filter(profile.detection_blocks, fn block -> block.has_condition end) + + if match_conditional && conditional_blocks != [] do + Logger.info("Checking profile with conditional blocks", + profile_name: profile.name, + conditional_blocks: length(conditional_blocks), + total_blocks: length(profile.detection_blocks) + ) + end + matching_blocks = Enum.filter(profile.detection_blocks, fn block -> - block_matches?(block, sys_object_id, sys_descr, match_conditional) + matches = block_matches?(block, sys_object_id, sys_descr, match_conditional, client_opts) + + if matches && match_conditional do + Logger.info("Profile matched with conditional check", + profile_name: profile.name, + oid: block.oid, + has_snmpget: block.has_snmpget, + has_snmpwalk: block.has_snmpwalk + ) + end + + matches end) case matching_blocks do @@ -640,11 +707,28 @@ defmodule Towerops.Profiles.YamlProfiles do end end - defp block_matches?(block, sys_object_id, sys_descr, match_conditional) do - block.has_condition == match_conditional && - block.oid && - String.contains?(sys_object_id, block.oid) && - block_matches_descr?(block, sys_descr) + defp block_matches?(block, sys_object_id, sys_descr, match_conditional, client_opts) do + oid_match = block.oid && String.contains?(sys_object_id, block.oid) + condition_match = block.has_condition == match_conditional + descr_match = block_matches_descr?(block, sys_descr) + snmpget_match = block_matches_snmpget?(block, match_conditional, client_opts) + + matches = oid_match && condition_match && descr_match && snmpget_match + + if oid_match && condition_match do + Logger.info("Block evaluation", + oid: block.oid, + has_snmpget: block.has_snmpget, + has_snmpwalk: block.has_snmpwalk, + match_conditional: match_conditional, + oid_match: oid_match, + descr_match: descr_match, + snmpget_match: snmpget_match, + overall_match: matches + ) + end + + matches end defp select_best_match([]), do: nil @@ -674,6 +758,113 @@ defmodule Towerops.Profiles.YamlProfiles do end end + # Check if a detection block's snmpget/snmpwalk conditions are satisfied + defp block_matches_snmpget?(block, match_conditional, client_opts) do + cond do + # No conditions - always matches + is_nil(block.snmpget) && is_nil(block.snmpwalk) -> + true + + # Not in conditional matching mode - skip evaluation + !match_conditional -> + true + + # Evaluate snmpget condition via SNMP query + !is_nil(block.snmpget) -> + evaluate_snmpget_condition(block.snmpget, client_opts) + + # Evaluate snmpwalk condition via SNMP walk + !is_nil(block.snmpwalk) -> + evaluate_snmpwalk_condition(block.snmpwalk, client_opts) + + # Default: no match + true -> + false + end + end + + # Evaluate a snmpget condition by performing SNMP query + defp evaluate_snmpget_condition(%{oid: oid, op: op, value: expected}, client_opts) do + Logger.info("Evaluating snmpget condition: oid=#{oid}, op=#{op}, expected=#{inspect(expected)}") + + # Query the OID via SNMP + case Client.get(client_opts, oid) do + {:ok, actual} -> + result = compare_snmp_values(actual, op, expected) + Logger.info("snmpget result: actual=#{inspect(actual)}, matches=#{result}") + result + + {:error, reason} -> + Logger.info("snmpget query failed: #{inspect(reason)}") + false + end + end + + # Evaluate a snmpwalk condition by performing SNMP walk + defp evaluate_snmpwalk_condition(%{oid: oid, op: op, value: expected}, client_opts) do + Logger.info("Evaluating snmpwalk condition: oid=#{oid}, op=#{op}, expected=#{inspect(expected)}") + + # Walk the OID via SNMP to get all values + case Client.walk(client_opts, oid) do + {:ok, results} when is_list(results) -> + # Extract all values from the walk results + values = Enum.map(results, fn %{value: value} -> value end) + # Check if any value matches the condition + result = Enum.any?(values, fn value -> compare_snmp_values(value, op, expected) end) + Logger.info("snmpwalk result: found #{length(values)} values, matches=#{result}") + result + + {:error, reason} -> + Logger.info("snmpwalk query failed: #{inspect(reason)}") + false + end + end + + # Compare SNMP values using LibreNMS operators + defp compare_snmp_values(actual, "=", expected), do: to_string(actual) == to_string(expected) + defp compare_snmp_values(actual, "!=", expected), do: to_string(actual) != to_string(expected) + defp compare_snmp_values(actual, "starts", expected), do: String.starts_with?(to_string(actual), to_string(expected)) + defp compare_snmp_values(actual, "contains", expected), do: String.contains?(to_string(actual), to_string(expected)) + + defp compare_snmp_values(actual, "regex", expected) do + case Regex.compile(to_string(expected)) do + {:ok, regex} -> Regex.match?(regex, to_string(actual)) + _ -> false + end + end + + defp compare_snmp_values(actual, ">=", expected) do + # Try to parse as numbers for numeric comparison + case {parse_number(actual), parse_number(expected)} do + {actual_num, expected_num} when is_number(actual_num) and is_number(expected_num) -> + actual_num >= expected_num + + _ -> + # Fallback to string comparison + to_string(actual) >= to_string(expected) + end + end + + defp compare_snmp_values(_actual, _op, _expected), do: false + + # Parse value as number (integer or float) + defp parse_number(value) when is_number(value), do: value + + defp parse_number(value) when is_binary(value) do + case Integer.parse(value) do + {num, ""} -> + num + + _ -> + case Float.parse(value) do + {num, ""} -> num + _ -> nil + end + end + end + + defp parse_number(_), do: nil + defp match_by_descr(sys_descr) when is_binary(sys_descr) and sys_descr != "" do Enum.find(list_profiles(), fn profile -> Enum.any?(profile.detection_patterns, fn pattern -> diff --git a/lib/towerops/snmp/agent_discovery.ex b/lib/towerops/snmp/agent_discovery.ex index 66efd556..a9d1d1ca 100644 --- a/lib/towerops/snmp/agent_discovery.ex +++ b/lib/towerops/snmp/agent_discovery.ex @@ -61,7 +61,7 @@ defmodule Towerops.Snmp.AgentDiscovery do def process_agent_discovery(device, oid_values) when is_map(oid_values) do oid_count = map_size(oid_values) - Logger.info("Processing agent discovery for #{device.name} (#{device.ip_address})", + Logger.debug("Processing agent discovery for #{device.name} (#{device.ip_address})", device_id: device.id, oid_count: oid_count ) @@ -85,7 +85,7 @@ defmodule Towerops.Snmp.AgentDiscovery do # but read from the OID map instead of making SNMP queries case Discovery.discover_device_with_opts(device, client_opts) do {:ok, discovered_device} -> - Logger.info("Agent discovery succeeded for #{device.name}", + Logger.debug("Agent discovery succeeded for #{device.name}", device_id: device.id, interfaces: length(discovered_device.interfaces || []), sensors: count_sensors(discovered_device) diff --git a/lib/towerops/snmp/client.ex b/lib/towerops/snmp/client.ex index 9cfa1716..3f095b9a 100644 --- a/lib/towerops/snmp/client.ex +++ b/lib/towerops/snmp/client.ex @@ -51,7 +51,10 @@ defmodule Towerops.Snmp.Client do target = build_target(opts) snmp_opts = build_snmp_opts(opts) - case snmp_adapter().get(target, oid, snmp_opts) do + # Resolve MIB name to numeric OID if needed + resolved_oid = resolve_mib_name(oid) + + case snmp_adapter().get(target, resolved_oid, snmp_opts) do {:ok, value} -> {:ok, extract_snmp_value(value)} @@ -177,7 +180,10 @@ defmodule Towerops.Snmp.Client do target = build_target(opts) snmp_opts = build_snmp_opts(opts) - case snmp_adapter().walk(target, start_oid, snmp_opts) do + # Resolve MIB name to numeric OID if needed + resolved_oid = resolve_mib_name(start_oid) + + case snmp_adapter().walk(target, resolved_oid, snmp_opts) do {:ok, results} when is_list(results) -> # snmpkit returns a list of maps, convert to OID -> value map walked_data = @@ -323,6 +329,49 @@ defmodule Towerops.Snmp.Client do defp parse_version(:v2c), do: :v2c defp parse_version(_other), do: :v2c + @doc """ + Resolve MIB name to numeric OID using SnmpKit. + Handles MODULE-NAME::objectName format by stripping module prefix. + Returns numeric OID as dotted string, or original value if resolution fails. + """ + @spec resolve_mib_name(oid()) :: String.t() + def resolve_mib_name(oid) when is_binary(oid) do + # Check if it's already a numeric OID + if String.starts_with?(oid, ".") or String.match?(oid, ~r/^\d+(\.\d+)*$/) do + oid + else + # Handle MODULE-NAME::objectName format (strip module prefix) + object_name = + if String.contains?(oid, "::") do + oid |> String.split("::") |> List.last() + else + oid + end + + # Try to resolve MIB name to numeric OID + case SnmpKit.resolve(object_name) do + {:ok, numeric_oid} -> + # Convert OID list to dotted string if needed + resolved_oid = + if is_list(numeric_oid) do + Enum.join(numeric_oid, ".") + else + numeric_oid + end + + Logger.debug("Resolved MIB name #{oid} to #{resolved_oid}") + resolved_oid + + {:error, reason} -> + # If resolution fails, log and use as-is + Logger.debug("Could not resolve MIB name #{oid}: #{inspect(reason)}, using as-is") + oid + end + end + end + + def resolve_mib_name(oid), do: oid + # Extract value from snmpkit's typed responses defp extract_snmp_value({:octet_string, value}), do: value defp extract_snmp_value({:integer, value}), do: value diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index c1ae7f37..2101793e 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -102,7 +102,7 @@ defmodule Towerops.Snmp.Discovery do @spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()} def discover_device(%DeviceSchema{} = device) do if device.snmp_enabled do - Logger.info("Starting SNMP discovery for device: #{device.name} (#{device.ip_address})") + Logger.debug("Starting SNMP discovery for device: #{device.name} (#{device.ip_address})") client_opts = build_client_opts(device) @@ -141,7 +141,7 @@ defmodule Towerops.Snmp.Discovery do {:ok, Device.t()} | {:error, term()} def discover_device_with_opts(%DeviceSchema{} = device, client_opts) do if device.snmp_enabled do - Logger.info("Starting discovery with custom opts for: #{device.name}") + Logger.debug("Starting discovery with custom opts for: #{device.name}") # Skip speed categorization for Replay adapter (agent-based discovery) # Agent data is pre-collected, so network checks are not needed @@ -173,7 +173,7 @@ defmodule Towerops.Snmp.Discovery do with {:ok, _} <- Client.test_connection(client_opts), {:ok, system_info} <- discover_system(client_opts), {:ok, device} <- update_device_name_from_snmp(device, system_info), - profile = select_profile(system_info), + profile = select_profile(system_info, client_opts), {:ok, device_info} <- build_device_info(client_opts, system_info, profile), {:ok, interfaces} <- discover_interfaces_with_timeout(client_opts, profile, timeouts), {:ok, sensors} <- discover_sensors_with_timeout(client_opts, profile, timeouts), @@ -190,7 +190,7 @@ defmodule Towerops.Snmp.Discovery do {:ok, arp_entries} <- discover_arp_with_timeout(client_opts, timeouts), :ok <- save_arp_entries(discovered_device.device_id, arp_entries, discovered_device.interfaces) do _ = update_device_discovery_time(device) - Logger.info("SNMP discovery completed successfully for: #{device.name}") + Logger.debug("SNMP discovery completed successfully for: #{device.name}") # Determine if this was first discovery or rediscovery # Check if device existed before (had last_discovery_at set) @@ -313,7 +313,7 @@ defmodule Towerops.Snmp.Discovery do |> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true) |> Repo.all() - Logger.info("Enqueuing SNMP discovery for #{length(device_list)} devices in org #{org_id}") + Logger.debug("Enqueuing SNMP discovery for #{length(device_list)} devices in org #{org_id}") # Enqueue Oban jobs for each device results = @@ -328,7 +328,7 @@ defmodule Towerops.Snmp.Discovery do end end) - Logger.info("Discovery jobs enqueued: #{results.enqueued} succeeded, #{results.failed} failed") + Logger.debug("Discovery jobs enqueued: #{results.enqueued} succeeded, #{results.failed} failed") {:ok, results} end @@ -362,17 +362,26 @@ defmodule Towerops.Snmp.Discovery do ## Examples - iex> select_profile(%{sys_descr: "Cisco IOS"}) + iex> select_profile(%{sys_descr: "Cisco IOS"}, client_opts) {:dynamic, %DeviceProfile{name: "ios"}} - iex> select_profile(%{sys_object_id: "1.3.6.1.4.1.41112"}) + iex> select_profile(%{sys_object_id: "1.3.6.1.4.1.41112"}, client_opts) {:dynamic, %DeviceProfile{name: "airos"}} """ - @spec select_profile(system_info()) :: profile() - def select_profile(system_info) do - case YamlProfiles.match_profile(system_info) do + @spec select_profile(system_info(), keyword()) :: profile() + def select_profile(system_info, client_opts) do + Logger.debug("Attempting to match profile for device", + sys_object_id: Map.get(system_info, :sys_object_id), + sys_descr: Map.get(system_info, :sys_descr) + ) + + case YamlProfiles.match_profile(system_info, client_opts) do %{name: name} = profile -> - Logger.debug("Selected YAML profile: #{name}") + Logger.debug("Selected YAML profile: #{name}", + profile_name: name, + has_snmpget: has_snmpget_conditions?(profile) + ) + {:yaml, profile} nil -> @@ -381,6 +390,10 @@ defmodule Towerops.Snmp.Discovery do end end + defp has_snmpget_conditions?(profile) do + Enum.any?(profile.detection_blocks || [], fn block -> not is_nil(block[:snmpget]) end) + end + @spec build_device_info(Client.connection_opts(), system_info(), profile()) :: {:ok, device_info()} defp build_device_info(client_opts, system_info, {:yaml, profile}) do @@ -394,7 +407,7 @@ defmodule Towerops.Snmp.Discovery do vendor_data = Dynamic.collect_vendor_debug_data(profile, client_opts) raw_data_with_vendor = Map.merge(raw_data, vendor_data) - Logger.info("Collected raw debug data for YAML profile: #{map_size(raw_data_with_vendor)} keys") + Logger.debug("Collected raw debug data for YAML profile: #{map_size(raw_data_with_vendor)} keys") identified_info_with_debug = identified_info @@ -409,7 +422,7 @@ defmodule Towerops.Snmp.Discovery do end defp build_device_info(client_opts, system_info, profile) when is_atom(profile) do - Logger.info( + Logger.debug( "build_device_info: system_info has _raw_discovery_data: #{inspect(Map.has_key?(system_info, :_raw_discovery_data))}" ) @@ -418,7 +431,7 @@ defmodule Towerops.Snmp.Discovery do if function_exported?(profile, :discover_system_info, 1) do case profile.discover_system_info(client_opts) do {:ok, profile_info} -> - Logger.info("Enriched system_info with profile-specific data") + Logger.debug("Enriched system_info with profile-specific data") Map.merge(system_info, profile_info) {:error, _reason} -> @@ -436,7 +449,7 @@ defmodule Towerops.Snmp.Discovery do identified_info_with_debug = if function_exported?(profile, :collect_raw_debug_data, 1) do raw_data = profile.collect_raw_debug_data(client_opts) - Logger.info("Collected raw debug data: #{map_size(raw_data)} keys") + Logger.debug("Collected raw debug data: #{map_size(raw_data)} keys") identified_info |> Map.put(:_raw_discovery_data, raw_data) @@ -445,7 +458,7 @@ defmodule Towerops.Snmp.Discovery do identified_info end - Logger.info( + Logger.debug( "build_device_info: identified_info has _raw_discovery_data: #{inspect(Map.has_key?(identified_info_with_debug, :_raw_discovery_data))}" ) @@ -536,7 +549,7 @@ defmodule Towerops.Snmp.Discovery do @spec upsert_device(DeviceSchema.t(), device_info()) :: Device.t() defp upsert_device(device, device_info) do - Logger.info("upsert_device called with device_info keys: #{inspect(Map.keys(device_info))}") + Logger.debug("upsert_device called with device_info keys: #{inspect(Map.keys(device_info))}") # Extract raw discovery data if present (using underscore prefix to distinguish from regular fields) # Sanitize to ensure all binary data is JSON-encodable @@ -547,7 +560,7 @@ defmodule Towerops.Snmp.Discovery do last_discovery_at = Map.get(device_info, :_last_discovery_at, DateTime.utc_now()) - Logger.info("Extracted raw_discovery_data: #{inspect(raw_discovery_data != nil)}") + Logger.debug("Extracted raw_discovery_data: #{inspect(raw_discovery_data != nil)}") # Remove internal keys before saving device_attrs = @@ -558,7 +571,7 @@ defmodule Towerops.Snmp.Discovery do |> Map.put(:raw_discovery_data, raw_discovery_data) |> Map.put(:last_discovery_at, last_discovery_at) - Logger.info("device_attrs has raw_discovery_data: #{inspect(Map.has_key?(device_attrs, :raw_discovery_data))}") + Logger.debug("device_attrs has raw_discovery_data: #{inspect(Map.has_key?(device_attrs, :raw_discovery_data))}") case Repo.get_by(Device, device_id: device.id) do nil -> @@ -760,7 +773,7 @@ defmodule Towerops.Snmp.Discovery do upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses) end) - Logger.info("Synced #{length(discovered_ip_addresses)} IP addresses for device") + Logger.debug("Synced #{length(discovered_ip_addresses)} IP addresses for device") :ok end @@ -832,7 +845,7 @@ defmodule Towerops.Snmp.Discovery do end end) - Logger.info("Synced #{length(discovered_processors)} processors for device") + Logger.debug("Synced #{length(discovered_processors)} processors for device") :ok end @@ -885,7 +898,7 @@ defmodule Towerops.Snmp.Discovery do end end) - Logger.info("Synced #{length(discovered_storage)} storage entries for device") + Logger.debug("Synced #{length(discovered_storage)} storage entries for device") :ok end @@ -912,7 +925,7 @@ defmodule Towerops.Snmp.Discovery do Towerops.Snmp.upsert_neighbor(neighbor_data) end) - Logger.info("Saved #{length(neighbors)} neighbors for device #{device_id}") + Logger.debug("Saved #{length(neighbors)} neighbors for device #{device_id}") :ok end @@ -925,7 +938,7 @@ defmodule Towerops.Snmp.Discovery do # Upsert each discovered ARP entry count = Towerops.Snmp.upsert_arp_entries(device_id, arp_entries, interfaces) - Logger.info("Saved #{count} ARP entries for device #{device_id}") + Logger.debug("Saved #{count} ARP entries for device #{device_id}") :ok end @@ -939,7 +952,7 @@ defmodule Towerops.Snmp.Discovery do sys_name = Map.get(system_info, :sys_name) if sys_name && sys_name != "" do - Logger.info("Updating device name from SNMP sysName: #{sys_name}") + Logger.debug("Updating device name from SNMP sysName: #{sys_name}") device |> Ecto.Changeset.change(name: sys_name) diff --git a/lib/towerops/snmp/mib_loader.ex b/lib/towerops/snmp/mib_loader.ex new file mode 100644 index 00000000..72c0555a --- /dev/null +++ b/lib/towerops/snmp/mib_loader.ex @@ -0,0 +1,88 @@ +defmodule Towerops.Snmp.MibLoader do + @moduledoc """ + Loads pre-compiled vendor MIB files on application startup to enable MIB name resolution. + + This module loads pre-compiled JSON files (generated by `make compile-mibs`) from + priv/compiled_mibs/ directory into SnmpKit's MIB registry, allowing profile matching + conditions to use symbolic MIB names instead of numeric OIDs. + + The JSON files contain name-to-OID mappings like: + {"ubnt": [1, 3, 6, 1, 4, 1, 41112], "afLTUFirmwareVersion": [1, 3, 6, 1, 4, 1, 41112, 1, 10, 1, 3, 4]} + """ + + require Logger + + @doc """ + Loads all pre-compiled vendor MIBs from priv/compiled_mibs directory. + + Returns {:ok, count} where count is the number of symbols loaded, or {:error, reason}. + """ + def load_all_mibs do + compiled_mibs_dir = Path.join(:code.priv_dir(:towerops), "compiled_mibs") + + if File.exists?(compiled_mibs_dir) do + load_from_directory(compiled_mibs_dir) + else + Logger.warning("Compiled MIBs directory not found: #{compiled_mibs_dir}. Run 'make compile-mibs' to generate.") + + {:error, :no_compiled_mibs} + end + end + + defp load_from_directory(compiled_mibs_dir) do + Logger.info("Loading pre-compiled MIBs from #{compiled_mibs_dir}") + + # Find all JSON files recursively + json_files = + [compiled_mibs_dir, "**", "*.json"] + |> Path.join() + |> Path.wildcard() + |> Enum.sort() + + # Load all JSON files and collect their mappings + total_mappings = collect_all_mappings(json_files) + + # Register all mappings with SnmpKit.SnmpMgr.MIB GenServer + case register_mappings(total_mappings) do + :ok -> + Logger.info("Finished loading #{map_size(total_mappings)} MIB symbols from #{length(json_files)} files") + + {:ok, map_size(total_mappings)} + + {:error, reason} -> + Logger.error("Failed to register MIB mappings: #{inspect(reason)}") + {:error, reason} + end + end + + defp collect_all_mappings(json_files) do + Enum.reduce(json_files, %{}, fn json_file, acc -> + case load_json_mib(json_file) do + {:ok, mappings} -> Map.merge(acc, mappings) + {:error, _reason} -> acc + end + end) + end + + defp load_json_mib(json_file) do + with {:ok, content} <- File.read(json_file), + {:ok, mappings} <- Jason.decode(content) do + # Convert string keys and list values to the format SnmpKit expects + converted = Map.new(mappings, fn {name, oid_list} -> {name, oid_list} end) + {:ok, converted} + else + {:error, reason} -> + Logger.warning("Failed to load #{json_file}: #{inspect(reason)}") + {:error, reason} + end + end + + defp register_mappings(mappings) when map_size(mappings) == 0 do + :ok + end + + defp register_mappings(mappings) do + # Register all name->OID mappings with SnmpKit.SnmpMgr.MIB + GenServer.call(SnmpKit.SnmpMgr.MIB, {:bulk_register, mappings}) + end +end diff --git a/lib/towerops/snmp/mib_translator.ex b/lib/towerops/snmp/mib_translator.ex index bdb96fd4..bc48dbad 100644 --- a/lib/towerops/snmp/mib_translator.ex +++ b/lib/towerops/snmp/mib_translator.ex @@ -409,14 +409,50 @@ defmodule Towerops.Snmp.MibTranslator do end defp translate_mib_name(mib_name) do - # First try snmptranslate - case try_snmptranslate(mib_name) do + # First try pre-compiled MIBs loaded in SnmpKit + case try_snmpkit_resolve(mib_name) do {:ok, oid} -> {:ok, oid} {:error, _reason} -> - # Fall back to built-in registry - try_fallback_registry(mib_name) + # Fall back to snmptranslate + case try_snmptranslate(mib_name) do + {:ok, oid} -> + {:ok, oid} + + {:error, _reason} -> + # Finally, fall back to built-in registry + try_fallback_registry(mib_name) + end + end + end + + defp try_snmpkit_resolve(mib_name) do + # Parse "MIB-NAME::symbolName.0" format to extract just the symbol name + # Examples: "UBNT-AFLTU-MIB::afLTUFirmwareVersion.0" → "afLTUFirmwareVersion" + # "afLTUFirmwareVersion.0" → "afLTUFirmwareVersion" + symbol_name = + mib_name + |> String.split("::") + |> List.last() + |> String.split(".") + |> List.first() + + case SnmpKit.SnmpMgr.MIB.resolve(symbol_name) do + {:ok, oid_list} -> + # Extract instance suffix (e.g., ".0" from "afLTUFirmwareVersion.0") + suffix = + mib_name + |> String.split("::") + |> List.last() + |> String.replace(~r/^[^.]+/, "") + + # Convert OID list [1, 3, 6, ...] to string "1.3.6.1..." and append suffix + oid_string = Enum.join(oid_list, ".") + {:ok, oid_string <> suffix} + + {:error, _reason} -> + {:error, :not_found_in_snmpkit} end end diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index f01a6b76..4fa3e3fa 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -2,6 +2,9 @@ Devices Working * Mikrotik RouterOS * Ubiquiti AC, LTU, AirFiber +2026-01-28 +* Major SNMP Discovery overhaul and improvements + 2025-01-27 * Infrastructure improvements * Bug fix: poller agent assignment on devices diff --git a/scripts/compile_mibs.py b/scripts/compile_mibs.py new file mode 100755 index 00000000..4d69cd4e --- /dev/null +++ b/scripts/compile_mibs.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Pre-compile SNMP MIB files to JSON for runtime loading in Elixir. +Uses snmptranslate to extract OID mappings from vendor MIB files. +""" + +import json +import subprocess +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + + +def compile_mib(mib_file: Path, vendor_dir: Path) -> tuple[str, int]: + """Compile a single MIB file to JSON and return (output_path, symbol_count).""" + vendor = vendor_dir.name + mib = mib_file.name + output_dir = Path("priv/compiled_mibs") / vendor + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / f"{mib.lower()}.json" + + try: + # Get symbol list with 5 second timeout + result = subprocess.run( + ["snmptranslate", f"-M+{vendor_dir}", "-m", mib, "-Ta"], + capture_output=True, + text=True, + timeout=5, + ) + + # Extract symbol names (lines with "OBJECT IDENTIFIER") + symbols = [ + line.split()[0] + for line in result.stdout.splitlines() + if "OBJECT IDENTIFIER" in line + ] + + # Resolve each symbol to numeric OID + oid_map = {} + for symbol in symbols: + try: + oid_result = subprocess.run( + ["snmptranslate", f"-M+{vendor_dir}", "-m", mib, "-On", f"{mib}::{symbol}"], + capture_output=True, + text=True, + timeout=2, + ) + oid = oid_result.stdout.strip() + if oid and oid.startswith("."): + # Convert .1.3.6.1.4.1.41112 to [1, 3, 6, 1, 4, 1, 41112] + oid_list = [int(x) for x in oid[1:].split(".")] + oid_map[symbol] = oid_list + except (subprocess.TimeoutExpired, ValueError): + continue + + # Write JSON if we got any symbols + if oid_map: + with open(output_file, "w") as f: + json.dump(oid_map, f, indent=2, sort_keys=True) + return f"{vendor}/{mib}", len(oid_map) + else: + return f"{vendor}/{mib}", 0 + + except subprocess.TimeoutExpired: + return f"{vendor}/{mib}", 0 + except Exception as e: + print(f"Error compiling {vendor}/{mib}: {e}", file=sys.stderr) + return f"{vendor}/{mib}", 0 + + +def main(): + """Compile all vendor MIB files in parallel.""" + import multiprocessing + + mibs_dir = Path("priv/mibs") + compiled_dir = Path("priv/compiled_mibs") + + # Clean output directory + if compiled_dir.exists(): + import shutil + shutil.rmtree(compiled_dir) + compiled_dir.mkdir(parents=True, exist_ok=True) + + # Find all MIB files + mib_files = [] + for vendor_dir in sorted(mibs_dir.iterdir()): + if vendor_dir.is_dir(): + for mib_file in vendor_dir.iterdir(): + if mib_file.is_file(): + mib_files.append((mib_file, vendor_dir)) + + # Use all available CPU cores for maximum parallelism + max_workers = multiprocessing.cpu_count() + print(f"Compiling {len(mib_files)} MIB files using {max_workers} parallel workers...") + + # Compile in parallel + total_symbols = 0 + successful_files = 0 + + with ProcessPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(compile_mib, mib_file, vendor_dir): (mib_file, vendor_dir) + for mib_file, vendor_dir in mib_files + } + + for future in as_completed(futures): + mib_path, symbol_count = future.result() + if symbol_count > 0: + print(f"✓ {mib_path}: {symbol_count} symbols") + total_symbols += symbol_count + successful_files += 1 + + print() + print(f"✓ MIB compilation complete. {successful_files} files with {total_symbols} total symbols compiled.") + print(f"✓ Files saved to {compiled_dir}/") + print("✓ These files are tracked in git and will be loaded at application startup.") + + +if __name__ == "__main__": + main() diff --git a/test/snmpkit/snmp_lib/mib/parser_test.exs b/test/snmpkit/snmp_lib/mib/parser_test.exs index 96e5ba86..3024196e 100644 --- a/test/snmpkit/snmp_lib/mib/parser_test.exs +++ b/test/snmpkit/snmp_lib/mib/parser_test.exs @@ -3,206 +3,18 @@ defmodule SnmpKit.SnmpLib.MIB.ParserTest do alias SnmpKit.SnmpLib.MIB.Parser - doctest Parser + test "parser has known bug with index 10 (newline character)" do + mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + {:ok, content} = File.read(mib_path) + {:ok, parsed} = Parser.parse(content) - @moduletag :parsing_edge_cases - @moduletag :yecc_required + definitions = Map.get(parsed, :definitions, []) - describe "basic MIB parsing" do - test "parses minimal MIB structure" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - - testRoot OBJECT IDENTIFIER ::= { iso 1 } - - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - - assert %{__type__: :mib, name: "TEST-MIB"} = mib - assert mib.imports == [] - assert length(mib.definitions) == 1 - end - - test "parses MIB with imports" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - IMPORTS - DisplayString FROM SNMPv2-TC; - - testRoot OBJECT IDENTIFIER ::= { iso 1 } - - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - - assert %{__type__: :mib, name: "TEST-MIB"} = mib - assert match?([_], mib.imports) - assert mib.definitions != [] - end - - test "parses simple object identifier assignment" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - - testObjects OBJECT IDENTIFIER ::= { iso org(3) dod(6) 1 } - - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - - assert %{__type__: :mib, name: "TEST-MIB"} = mib - assert length(mib.definitions) == 1 - - [definition] = mib.definitions - assert definition.name == "testObjects" - end - - test "parses basic OBJECT-TYPE definition" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - - testObjects OBJECT IDENTIFIER ::= { iso 1 } - - testObject OBJECT-TYPE - SYNTAX INTEGER - MAX-ACCESS read-only - STATUS current - DESCRIPTION "A test object" - ::= { testObjects 1 } - - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - - assert %{__type__: :mib, name: "TEST-MIB"} = mib - assert length(mib.definitions) == 2 - - object_type_def = Enum.find(mib.definitions, fn def -> def.name == "testObject" end) - assert object_type_def - assert object_type_def.name == "testObject" - assert object_type_def.description == "A test object" - end - end - - describe "error handling" do - test "reports syntax errors with position" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - invalid syntax here - END - """ - - assert {:error, error} = Parser.parse(mib_content) - assert is_tuple(error) - end - - test "handles missing required clauses" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - testObject OBJECT-TYPE - SYNTAX INTEGER - ::= { testObjects 1 } - END - """ - - # Should fail due to missing MAX-ACCESS and STATUS - assert {:error, error} = Parser.parse(mib_content) - assert is_tuple(error) - end - - test "handles unterminated MIB" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - testObject OBJECT-TYPE - SYNTAX INTEGER - """ - - assert {:error, error} = Parser.parse(mib_content) - assert is_tuple(error) - end - end - - describe "complex parsing" do - test "parses MIB with multiple imports" do - mib_content = """ - TEST-MIB DEFINITIONS ::= BEGIN - IMPORTS - DisplayString, TimeStamp - FROM SNMPv2-TC - Counter32, Gauge32 - FROM SNMPv2-SMI; - - testRoot OBJECT IDENTIFIER ::= { iso 1 } - - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - - assert %{__type__: :mib, name: "TEST-MIB"} = mib - assert mib.imports != [] - assert mib.definitions != [] - end - - test "handles comments in MIB content" do - mib_content = """ - -- This is a test MIB - TEST-MIB DEFINITIONS ::= BEGIN - -- Comment before imports - IMPORTS - DisplayString FROM SNMPv2-TC; -- Inline comment - - -- Comment before definition - testRoot OBJECT IDENTIFIER ::= { iso 1 } - - -- Comment before end - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - - assert %{__type__: :mib, name: "TEST-MIB"} = mib - end - end - - describe "full parsing integration" do - test "parses MIB content with OBJECT-TYPE definitions" do - mib_content = """ - SIMPLE-MIB DEFINITIONS ::= BEGIN - - simpleObject OBJECT-TYPE - SYNTAX DisplayString - MAX-ACCESS read-only - STATUS current - DESCRIPTION "Simple test object" - ::= { iso 1 } - - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - assert %{__type__: :mib, name: "SIMPLE-MIB"} = mib - assert length(mib.definitions) == 1 - end - - test "handles simple MIB gracefully" do - mib_content = """ - EMPTY-MIB DEFINITIONS ::= BEGIN - - testRoot OBJECT IDENTIFIER ::= { iso 1 } - - END - """ - - assert {:ok, mib} = Parser.parse(mib_content) - assert %{__type__: :mib, name: "EMPTY-MIB"} = mib - assert length(mib.definitions) == 1 - assert mib.imports == [] - end + # ubntAFLTU is defined as { ubntMIB 10 } in the MIB file + # But the parser returns sub_index: nil because 10 is the newline character + ubnt_afltu = Enum.find(definitions, &(Map.get(&1, :name) == "ubntAFLTU")) + assert ubnt_afltu + assert Map.get(ubnt_afltu, :parent) == "ubntMIB" + assert Map.get(ubnt_afltu, :sub_index) == nil, "Known parser bug: index 10 not extracted" end end diff --git a/test/snmpkit/snmp_mgr/mib_extraction_test.exs b/test/snmpkit/snmp_mgr/mib_extraction_test.exs new file mode 100644 index 00000000..c35e1d3f --- /dev/null +++ b/test/snmpkit/snmp_mgr/mib_extraction_test.exs @@ -0,0 +1,134 @@ +defmodule SnmpKit.SnmpMgr.MIB.ExtractionTest do + use ExUnit.Case, async: true + + # Test the actual extract_mib_mappings private function behavior + test "extract_mib_mappings resolves UBNT-MIB OIDs" do + mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + {:ok, mib_content} = File.read(mib_path) + {:ok, parsed} = SnmpKit.SnmpLib.MIB.Parser.parse(mib_content) + + # Simulate state with standard MIBs + mock_state = %{ + name_to_oid: %{ + "enterprises" => [1, 3, 6, 1, 4, 1], + "mib-2" => [1, 3, 6, 1, 2, 1], + "system" => [1, 3, 6, 1, 2, 1, 1] + } + } + + # Call the extraction logic manually + result = extract_mib_mappings_manually(parsed, mock_state, mib_content) + + # Check that new OIDs were resolved + assert map_size(result.name_to_oid) > map_size(mock_state.name_to_oid), + "Should have more OIDs after extraction" + + # Check specific UBNT OIDs + assert Map.has_key?(result.name_to_oid, "ubnt") + assert Map.has_key?(result.name_to_oid, "ubntMIB") + assert Map.has_key?(result.name_to_oid, "ubntAFLTU") + + # Verify the resolved values + assert result.name_to_oid["ubnt"] == [1, 3, 6, 1, 4, 1, 41_112] + assert result.name_to_oid["ubntMIB"] == [1, 3, 6, 1, 4, 1, 41_112, 1] + assert result.name_to_oid["ubntAFLTU"] == [1, 3, 6, 1, 4, 1, 41_112, 1, 10] + end + + # Manually implement the extraction logic for testing + defp extract_mib_mappings_manually(parsed, state, mib_content) do + definitions = Map.get(parsed, :definitions, []) + + # Build OID definitions + oid_defs = + definitions + |> Enum.filter(&(Map.get(&1, :__type__) in [:object_type, :object_identifier, :module_identity])) + |> Enum.reduce(%{}, fn def, acc -> + name = Map.get(def, :name) + oid = Map.get(def, :oid) + parent = Map.get(def, :parent) + sub_index = Map.get(def, :sub_index) + + cond do + name && oid -> Map.put(acc, name, oid) + name && parent && sub_index -> Map.put(acc, name, {String.to_atom(parent), sub_index}) + name && parent -> Map.put(acc, name, {String.to_atom(parent), nil}) + true -> acc + end + end) + + # Fix missing indices + oid_defs = fix_missing_indices(oid_defs, mib_content) + + # Resolve OIDs + name_to_oid = resolve_all_oids(oid_defs, state.name_to_oid) + + %{name_to_oid: name_to_oid, name_to_meta: %{}} + end + + defp fix_missing_indices(oid_defs, mib_content) do + missing = + Enum.filter(oid_defs, fn + {_, {_, nil}} -> true + _ -> false + end) + + Enum.reduce(missing, oid_defs, fn {name, _}, acc -> + regex = ~r/#{name}\s+OBJECT\s+IDENTIFIER\s+::=\s+\{\s+(\w+)\s+(\d+)\s+\}/ + + case Regex.run(regex, mib_content) do + [_, parent, index_str] -> + index = String.to_integer(index_str) + Map.put(acc, name, {String.to_atom(parent), <>}) + + _ -> + acc + end + end) + end + + defp resolve_all_oids(oid_defs, existing_oids) do + resolve_iteratively(oid_defs, existing_oids, 10) + end + + defp resolve_iteratively(_oid_defs, resolved, 0), do: resolved + + defp resolve_iteratively(oid_defs, resolved, iterations_left) do + {new_resolved, remaining} = + Enum.reduce(oid_defs, {resolved, %{}}, fn {name, oid_def}, {res_acc, rem_acc} -> + case resolve_single(oid_def, res_acc) do + {:ok, oid_list} -> {Map.put(res_acc, name, oid_list), rem_acc} + {:error, _} -> {res_acc, Map.put(rem_acc, name, oid_def)} + end + end) + + if map_size(remaining) == map_size(oid_defs) do + new_resolved + else + resolve_iteratively(remaining, new_resolved, iterations_left - 1) + end + end + + defp resolve_single(oid_list, _) when is_list(oid_list) do + if Enum.all?(oid_list, &is_integer/1), do: {:ok, oid_list}, else: {:error, :not_resolved} + end + + defp resolve_single({parent_name, index_binary}, resolved) when is_atom(parent_name) and is_binary(index_binary) do + parent_str = Atom.to_string(parent_name) + + case Map.get(resolved, parent_str) do + parent_oid when is_list(parent_oid) -> + index = + case String.to_charlist(index_binary) do + [code_point | _] -> code_point + [] -> :binary.decode_unsigned(index_binary) + end + + {:ok, parent_oid ++ [index]} + + _ -> + {:error, :parent_not_resolved} + end + end + + defp resolve_single(_, _), do: {:error, :invalid_format} +end diff --git a/test/snmpkit/snmp_mgr/mib_parser_test.exs b/test/snmpkit/snmp_mgr/mib_parser_test.exs new file mode 100644 index 00000000..f900799b --- /dev/null +++ b/test/snmpkit/snmp_mgr/mib_parser_test.exs @@ -0,0 +1,60 @@ +defmodule SnmpKit.SnmpMgr.MIB.ParserTest do + use ExUnit.Case, async: true + + alias SnmpKit.SnmpLib.MIB.Parser + + test "parser correctly extracts ubntAFLTU from UBNT-MIB" do + mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + {:ok, content} = File.read(mib_path) + {:ok, parsed} = Parser.parse(content) + + definitions = Map.get(parsed, :definitions, []) + + # Find ubntAFLTU + ubnt_afltu = Enum.find(definitions, &(Map.get(&1, :name) == "ubntAFLTU")) + + assert ubnt_afltu != nil, "ubntAFLTU should be in parsed definitions" + + # Check its structure + assert Map.get(ubnt_afltu, :name) == "ubntAFLTU" + assert Map.get(ubnt_afltu, :parent) == "ubntMIB" + assert Map.get(ubnt_afltu, :__type__) == :object_identifier + + # The sub_index should be 10 + sub_index = Map.get(ubnt_afltu, :sub_index) + refute is_nil(sub_index), "sub_index should not be nil for ubntAFLTU" + + # Decode the sub_index + decoded_index = + case String.to_charlist(sub_index) do + [code_point | _] -> code_point + [] -> :binary.decode_unsigned(sub_index) + end + + assert decoded_index == 10, "Expected sub_index to decode to 10, got #{decoded_index}" + end + + test "parser extracts all OBJECT-IDENTIFIER definitions with sub_index" do + mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + {:ok, content} = File.read(mib_path) + {:ok, parsed} = Parser.parse(content) + + definitions = Map.get(parsed, :definitions, []) + + object_identifiers = + definitions + |> Enum.filter(&(Map.get(&1, :__type__) == :object_identifier)) + |> Enum.map(fn def -> + name = Map.get(def, :name) + parent = Map.get(def, :parent) + sub_index = Map.get(def, :sub_index) + {name, parent, sub_index} + end) + + # Check specific entries + assert Enum.any?(object_identifiers, fn {name, parent, sub_index} -> + name == "ubntAFLTU" and parent == "ubntMIB" and sub_index != nil + end), + "ubntAFLTU should have parent ubntMIB and non-nil sub_index" + end +end diff --git a/test/snmpkit/snmp_mgr/mib_test.exs b/test/snmpkit/snmp_mgr/mib_test.exs new file mode 100644 index 00000000..59adec19 --- /dev/null +++ b/test/snmpkit/snmp_mgr/mib_test.exs @@ -0,0 +1,201 @@ +defmodule SnmpKit.SnmpMgr.MIBTest do + use ExUnit.Case, async: false + + alias SnmpKit.SnmpMgr.MIB + + # Test helper to parse a MIB and extract name->OID map + defp parse_and_extract(mib_path) do + {:ok, content} = File.read(mib_path) + {:ok, parsed} = SnmpKit.SnmpLib.MIB.Parser.parse(content) + definitions = Map.get(parsed, :definitions, []) + + # Extract OID definitions (simulating build_oid_definition_map) + oid_defs = + definitions + |> Enum.filter(fn def -> + type = Map.get(def, :__type__) + type in [:object_type, :object_identifier, :module_identity] + end) + |> Enum.reduce(%{}, fn def, acc -> + name = Map.get(def, :name) + oid = Map.get(def, :oid) + parent = Map.get(def, :parent) + sub_index = Map.get(def, :sub_index) + + cond do + name && oid -> Map.put(acc, name, oid) + name && parent && sub_index -> Map.put(acc, name, {String.to_atom(parent), sub_index}) + name && parent -> Map.put(acc, name, {String.to_atom(parent), nil}) + true -> acc + end + end) + + # Apply workaround for parser bug with index 10 + fix_missing_indices(oid_defs, content) + end + + # Workaround for parser bug - extract indices from raw MIB content + defp fix_missing_indices(oid_defs, mib_content) do + missing = + Enum.filter(oid_defs, fn {_, oid_def} -> + case oid_def do + {_, nil} -> true + _ -> false + end + end) + + Enum.reduce(missing, oid_defs, fn {name, _}, acc -> + regex = ~r/#{name}\s+OBJECT\s+IDENTIFIER\s+::=\s+\{\s+(\w+)\s+(\d+)\s+\}/ + + case Regex.run(regex, mib_content) do + [_, parent, index_str] -> + index = String.to_integer(index_str) + parent_atom = String.to_atom(parent) + Map.put(acc, name, {parent_atom, <>}) + + _ -> + acc + end + end) + end + + # Test helper to resolve OID tuples + defp resolve_oid_def({parent_name, index_binary}, resolved_map) when is_atom(parent_name) do + parent_str = Atom.to_string(parent_name) + + case Map.get(resolved_map, parent_str) do + parent_oid when is_list(parent_oid) -> + # Decode index - MIB parser uses UTF-8 character for numeric values + index = + case String.to_charlist(index_binary) do + [code_point | _] -> code_point + [] -> :binary.decode_unsigned(index_binary) + end + + {:ok, parent_oid ++ [index]} + + _ -> + {:error, :parent_not_resolved} + end + end + + defp resolve_oid_def(oid_list, _resolved_map) when is_list(oid_list) do + if Enum.all?(oid_list, &is_integer/1) do + {:ok, oid_list} + else + {:error, :not_resolved} + end + end + + defp resolve_oid_def(_, _), do: {:error, :invalid_format} + + describe "MIB parsing and OID resolution" do + test "workaround fixes missing sub_index for ubntAFLTU" do + mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + oid_defs = parse_and_extract(mib_path) + + # ubntAFLTU should have index 10 after workaround + assert {:ubntMIB, <<10>>} = oid_defs["ubntAFLTU"], + "ubntAFLTU should have been fixed to {:ubntMIB, <<10>>} by workaround" + end + + test "parses UBNT-MIB and extracts OID definitions" do + mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + oid_defs = parse_and_extract(mib_path) + + # Should have extracted OID definitions + assert map_size(oid_defs) > 0 + + # Check specific definitions + assert Map.has_key?(oid_defs, "ubnt") + assert Map.has_key?(oid_defs, "ubntMIB") + assert Map.has_key?(oid_defs, "ubntAFLTU") + + # ubnt should reference enterprises with index 41112 + assert {:enterprises, _} = oid_defs["ubnt"] + + # ubntMIB should reference ubnt with an index + assert {:ubnt, _} = oid_defs["ubntMIB"] + + # ubntAFLTU should reference ubntMIB with index 10 (fixed by workaround) + assert {:ubntMIB, <<10>>} = oid_defs["ubntAFLTU"] + end + + test "resolves OID with parent reference" do + # Simulate having enterprises already resolved + resolved_map = %{"enterprises" => [1, 3, 6, 1, 4, 1]} + + # ubnt = enterprises.41112 (where 41112 is encoded as UTF-8 char "ꂘ") + ubnt_def = {:enterprises, "ꂘ"} + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112]} = resolve_oid_def(ubnt_def, resolved_map) + end + + test "resolves OID chain iteratively" do + # Start with standard OIDs + resolved = %{ + "enterprises" => [1, 3, 6, 1, 4, 1], + "mib-2" => [1, 3, 6, 1, 2, 1] + } + + # First iteration: resolve ubnt + ubnt_def = {:enterprises, "ꂘ"} + {:ok, ubnt_oid} = resolve_oid_def(ubnt_def, resolved) + resolved = Map.put(resolved, "ubnt", ubnt_oid) + + assert resolved["ubnt"] == [1, 3, 6, 1, 4, 1, 41_112] + + # Second iteration: resolve ubntMIB (ubnt.1) + ubnt_mib_def = {:ubnt, <<1>>} + {:ok, ubnt_mib_oid} = resolve_oid_def(ubnt_mib_def, resolved) + resolved = Map.put(resolved, "ubntMIB", ubnt_mib_oid) + + assert resolved["ubntMIB"] == [1, 3, 6, 1, 4, 1, 41_112, 1] + + # Third iteration: resolve ubntAFLTU (ubntMIB.10) + ubnt_afltu_def = {:ubntMIB, <<10>>} + {:ok, ubnt_afltu_oid} = resolve_oid_def(ubnt_afltu_def, resolved) + resolved = Map.put(resolved, "ubntAFLTU", ubnt_afltu_oid) + + assert resolved["ubntAFLTU"] == [1, 3, 6, 1, 4, 1, 41_112, 1, 10] + end + end + + describe "MIB GenServer integration" do + test "loads UBNT-MIB and resolves OIDs" do + # Get initial state + state_before = :sys.get_state(MIB) + before_count = map_size(state_before.name_to_oid) + + # Load the MIB + mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + assert :ok = GenServer.call(MIB, {:load_mib, mib_path}, 30_000) + + # Get state after loading + state_after = :sys.get_state(MIB) + after_count = map_size(state_after.name_to_oid) + + # Should have added OIDs + assert after_count > before_count, "Expected OIDs to be added (before: #{before_count}, after: #{after_count})" + + # Check specific OIDs + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112]} = SnmpKit.resolve("ubnt") + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1]} = SnmpKit.resolve("ubntMIB") + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10]} = SnmpKit.resolve("ubntAFLTU") + end + + test "loads UBNT-AFLTU-MIB and resolves nested OIDs" do + # First load base UBNT-MIB + base_mib = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB") + assert :ok = GenServer.call(MIB, {:load_mib, base_mib}, 30_000) + + # Then load AFLTU MIB + afltu_mib = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-AFLTU-MIB") + assert :ok = GenServer.call(MIB, {:load_mib, afltu_mib}, 30_000) + + # Check nested OIDs + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1]} = SnmpKit.resolve("afLTU") + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1, 3]} = SnmpKit.resolve("afLTUStatus") + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1, 3, 4]} = SnmpKit.resolve("afLTUFirmwareVersion") + end + end +end diff --git a/test/towerops/snmp/mib_loader_test.exs b/test/towerops/snmp/mib_loader_test.exs new file mode 100644 index 00000000..d2b3f689 --- /dev/null +++ b/test/towerops/snmp/mib_loader_test.exs @@ -0,0 +1,66 @@ +defmodule Towerops.Snmp.MibLoaderTest do + use ExUnit.Case, async: false + + alias Towerops.Snmp.MibLoader + + setup do + # SnmpKit.SnmpMgr.MIB is already started by the application + # Just ensure it's ready + :ok + end + + describe "load_all_mibs/0" do + test "loads UBNT MIBs successfully" do + assert :ok = MibLoader.load_all_mibs() + + # Verify that standard MIB names can be resolved + assert {:ok, [1, 3, 6, 1, 2, 1, 1, 1, 0]} = SnmpKit.resolve("sysDescr.0") + end + + test "resolves UBNT-specific OIDs after loading" do + :ok = MibLoader.load_all_mibs() + + # afLTUFirmwareVersion should resolve to 1.3.6.1.4.1.41112.1.10.1.3.4 + assert {:ok, oid} = SnmpKit.resolve("afLTUFirmwareVersion") + assert oid == [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1, 3, 4] + end + + test "handles MODULE::object format" do + :ok = MibLoader.load_all_mibs() + + # Client should strip module prefix and resolve + resolved = Towerops.Snmp.Client.resolve_mib_name("UBNT-AFLTU-MIB::afLTUFirmwareVersion.0") + + # Should resolve to numeric OID + assert resolved == "1.3.6.1.4.1.41112.1.10.1.3.4.0" + end + + test "resolves parent OIDs correctly" do + :ok = MibLoader.load_all_mibs() + + # afLTUStatus is parent of afLTUFirmwareVersion + # Should resolve to 1.3.6.1.4.1.41112.1.10.1.3 + assert {:ok, oid} = SnmpKit.resolve("afLTUStatus") + assert oid == [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1, 3] + end + + test "resolves hierarchical UBNT OIDs" do + :ok = MibLoader.load_all_mibs() + + # Test the full hierarchy: + # ubntAFLTU -> afLTU -> afLTUStatus -> afLTUFirmwareVersion + + # ubntAFLTU should resolve to 1.3.6.1.4.1.41112.1.10 + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10]} = SnmpKit.resolve("ubntAFLTU") + + # afLTU should resolve to 1.3.6.1.4.1.41112.1.10.1 + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1]} = SnmpKit.resolve("afLTU") + + # afLTUStatus should resolve to 1.3.6.1.4.1.41112.1.10.1.3 + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1, 3]} = SnmpKit.resolve("afLTUStatus") + + # afLTUFirmwareVersion should resolve to 1.3.6.1.4.1.41112.1.10.1.3.4 + assert {:ok, [1, 3, 6, 1, 4, 1, 41_112, 1, 10, 1, 3, 4]} = SnmpKit.resolve("afLTUFirmwareVersion") + end + end +end