major snmp overhaul

This commit is contained in:
Graham McIntire 2026-01-28 09:07:13 -06:00
parent 667fde17d7
commit 88c003d474
16 changed files with 1297 additions and 328 deletions

View file

@ -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

View file

@ -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, <<index>>})
_ ->
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, <<index>>})
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

View file

@ -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() ++

View file

@ -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)
# Try matching in priority order, return first match
try_profile_matches([
# 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)
profile ->
profile
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
end
defp match_by_oid_librenms_style(_, _, _), do: nil
defp match_by_oid_librenms_style(_, _), do: nil
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
# 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 ->

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -409,16 +409,52 @@ defmodule Towerops.Snmp.MibTranslator do
end
defp translate_mib_name(mib_name) do
# First try snmptranslate
# First try pre-compiled MIBs loaded in SnmpKit
case try_snmpkit_resolve(mib_name) do
{:ok, oid} ->
{:ok, oid}
{:error, _reason} ->
# Fall back to snmptranslate
case try_snmptranslate(mib_name) do
{:ok, oid} ->
{:ok, oid}
{:error, _reason} ->
# Fall back to built-in registry
# 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
defp try_snmptranslate(mib_name) do
# Get MIB directories from config or use default

View file

@ -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

120
scripts/compile_mibs.py Executable file
View file

@ -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()

View file

@ -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

View file

@ -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), <<index>>})
_ ->
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

View file

@ -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

View file

@ -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, <<index>>})
_ ->
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

View file

@ -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