towerops/lib/towerops/snmp/mib_parser.ex
Graham McIntire 96bd8b3829
add MIB-based validation and generic profile tests
- Add MIB files from LibreNMS in priv/mibs/ for reference
- Create MibParser module to validate OIDs against official MIB definitions
- Add MIB validation tests to ensure hardcoded OIDs match MIB specs
- Refactor SNMP tests to be generic/behavior-focused instead of vendor-specific
- Remove vendor-specific test files (cisco_test, net_snmp_test)
- All 104 tests passing with automated OID validation
2026-01-08 08:57:21 -06:00

172 lines
4.7 KiB
Elixir

defmodule Towerops.Snmp.MibParser do
@moduledoc """
Simple MIB file parser for extracting OID definitions.
This parser extracts OID assignments from MIB files for validation purposes.
It's not a full ASN.1 parser - just enough to validate our hardcoded OIDs
against the official MIB definitions.
## Usage
iex> MibParser.parse_mib_file("priv/mibs/standard/IF-MIB")
{:ok, %{
"ifIndex" => "1.3.6.1.2.1.2.2.1.1",
"ifDescr" => "1.3.6.1.2.1.2.2.1.2",
...
}}
"""
require Logger
@doc """
Parse a MIB file and return a map of object names to OID strings.
"""
@spec parse_mib_file(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()}
def parse_mib_file(path) do
case File.read(path) do
{:ok, content} ->
oids = parse_mib_content(content)
{:ok, oids}
{:error, reason} = error ->
Logger.error("Failed to read MIB file #{path}: #{inspect(reason)}")
error
end
end
@doc """
Parse MIB content and extract OID definitions.
"""
@spec parse_mib_content(String.t()) :: %{String.t() => String.t()}
def parse_mib_content(content) do
# First, build a map of object assignments (e.g., "ifTable ::= { interfaces 2 }")
assignments = extract_assignments(content)
# Then resolve all OIDs by following the assignment chain
resolve_oids(assignments)
end
@doc """
Validate that a given OID matches the definition in a MIB file.
"""
@spec validate_oid(String.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
def validate_oid(mib_file, object_name, expected_oid) do
case parse_mib_file(mib_file) do
{:ok, oids} ->
case Map.get(oids, object_name) do
^expected_oid ->
:ok
actual_oid when is_binary(actual_oid) ->
{:error, "OID mismatch for #{object_name}: expected #{expected_oid}, got #{actual_oid}"}
nil ->
{:error, "Object #{object_name} not found in MIB"}
end
{:error, reason} ->
{:error, "Failed to parse MIB: #{inspect(reason)}"}
end
end
# Private functions
defp extract_assignments(content) do
# Match patterns like:
# objectName OBJECT-TYPE ::= { parent index }
# objectName OBJECT IDENTIFIER ::= { parent index }
# We'll extract: objectName, parent, index
# Remove comments (lines starting with --)
content = Regex.replace(~r/--.*$/m, content, "")
# Match assignment patterns
# This regex captures: name, parent, and index
~r/(\w+)\s+(?:OBJECT-TYPE|OBJECT\s+IDENTIFIER)[^:]*::=\s*\{\s*(\w+)\s+(\d+)\s*\}/
|> Regex.scan(content)
|> Map.new(fn [_, name, parent, index] ->
{name, {parent, index}}
end)
end
defp resolve_oids(assignments) do
# Well-known root OIDs
roots = %{
"iso" => "1",
"org" => "1.3",
"dod" => "1.3.6",
"internet" => "1.3.6.1",
"directory" => "1.3.6.1.1",
"mgmt" => "1.3.6.1.2",
"mib-2" => "1.3.6.1.2.1",
"experimental" => "1.3.6.1.3",
"private" => "1.3.6.1.4",
"enterprises" => "1.3.6.1.4.1",
"security" => "1.3.6.1.5",
"snmpV2" => "1.3.6.1.6",
"snmpModules" => "1.3.6.1.6.3"
}
# Build OID map by recursively resolving assignments
Enum.reduce(assignments, roots, fn {name, {parent, index}}, acc ->
case resolve_oid(parent, index, acc, assignments) do
{:ok, oid} -> Map.put(acc, name, oid)
:error -> acc
end
end)
end
defp resolve_oid(parent, index, resolved, assignments) do
cond do
# Parent already resolved
Map.has_key?(resolved, parent) ->
{:ok, "#{resolved[parent]}.#{index}"}
# Parent needs to be resolved from assignments
Map.has_key?(assignments, parent) ->
{grandparent, parent_index} = assignments[parent]
case resolve_oid(grandparent, parent_index, resolved, assignments) do
{:ok, parent_oid} ->
{:ok, "#{parent_oid}.#{index}"}
:error ->
:error
end
# Can't resolve
true ->
:error
end
end
@doc """
List all MIB files in the priv/mibs directory.
"""
@spec list_mib_files() :: [String.t()]
def list_mib_files do
mibs_dir = Application.app_dir(:towerops, "priv/mibs")
case File.ls(mibs_dir) do
{:ok, subdirs} ->
Enum.flat_map(subdirs, &list_mib_files_in_subdir(mibs_dir, &1))
{:error, _} ->
[]
end
end
defp list_mib_files_in_subdir(mibs_dir, subdir) do
subdir_path = Path.join(mibs_dir, subdir)
case File.ls(subdir_path) do
{:ok, files} ->
files
|> Enum.filter(&String.ends_with?(&1, "MIB"))
|> Enum.map(&Path.join([mibs_dir, subdir, &1]))
{:error, _} ->
[]
end
end
end