Packages

A comprehensive SNMP toolkit for Elixir featuring a unified API, pure Elixir implementation, and powerful device simulation. Perfect for network monitoring, testing, and development with support for SNMP operations, MIB management, and realistic device simulation.

Current section

Files

Jump to
snmpkit lib snmpkit snmp_sim mib shared_profiles.ex
Raw

lib/snmpkit/snmp_sim/mib/shared_profiles.ex

defmodule SnmpKit.SnmpSim.MIB.SharedProfiles do
@moduledoc """
Memory-efficient shared OID profiles using ETS tables.
Reduces memory from 1GB to ~10MB for 10K devices by sharing profile data.
"""
use GenServer
require Logger
@table_opts [:set, :public, :named_table, {:read_concurrency, true}]
defstruct [
:profile_tables,
:behavior_tables,
:metadata_table,
:stats
]
# API Functions
@doc """
Start the shared profiles manager.
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Initialize shared profiles for device types.
## Examples
:ok = SnmpKit.SnmpSim.MIB.SharedProfiles.init_profiles()
"""
def init_profiles do
GenServer.call(__MODULE__, :init_profiles)
end
@doc """
Load a MIB-based profile for a device type.
## Examples
:ok = SnmpKit.SnmpSim.MIB.SharedProfiles.load_mib_profile(
:cable_modem,
["DOCS-CABLE-DEVICE-MIB", "IF-MIB"]
)
"""
def load_mib_profile(device_type, mib_files, opts \\ []) do
GenServer.call(__MODULE__, {:load_mib_profile, device_type, mib_files, opts}, 30_000)
end
@doc """
Load a walk file-based profile with enhanced behaviors.
## Examples
:ok = SnmpKit.SnmpSim.MIB.SharedProfiles.load_walk_profile(
:cable_modem,
"priv/walks/cable_modem.walk",
behaviors: [:realistic_counters, :daily_patterns]
)
"""
def load_walk_profile(device_type, walk_file, opts \\ []) do
GenServer.call(__MODULE__, {:load_walk_profile, device_type, walk_file, opts}, 30_000)
end
@doc """
Get a value for a specific OID with device-specific state applied.
## Examples
value = SnmpKit.SnmpSim.MIB.SharedProfiles.get_oid_value(
:cable_modem,
"1.3.6.1.2.1.2.2.1.10.1",
%{device_id: "cm_001", uptime: 3600}
)
"""
def get_oid_value(device_type, oid, device_state) do
GenServer.call(__MODULE__, {:get_oid_value, device_type, oid, device_state})
end
@doc """
Get the next OID in lexicographic order for GETNEXT operations.
"""
def get_next_oid(device_type, oid) do
GenServer.call(__MODULE__, {:get_next_oid, device_type, oid})
end
@doc """
Get multiple OIDs for GETBULK operations.
"""
def get_bulk_oids(device_type, start_oid, max_repetitions) do
GenServer.call(__MODULE__, {:get_bulk_oids, device_type, start_oid, max_repetitions})
end
@doc """
Get all OIDs for a device type.
"""
def get_all_oids(device_type) do
GenServer.call(__MODULE__, {:get_all_oids, device_type})
end
@doc """
Get memory usage statistics for the shared profiles.
"""
def get_memory_stats do
GenServer.call(__MODULE__, :get_memory_stats)
end
@doc """
List all available device type profiles.
"""
def list_profiles do
GenServer.call(__MODULE__, :list_profiles)
end
@doc """
Clear all profiles (useful for testing).
"""
def clear_all_profiles do
GenServer.call(__MODULE__, :clear_all_profiles)
end
@doc """
Store profile data directly (useful for testing).
"""
def store_profile(device_type, profile_data, behavior_data) do
GenServer.call(__MODULE__, {:store_profile, device_type, profile_data, behavior_data})
end
@doc """
Compare OIDs lexicographically (useful for testing).
"""
def compare_oids_lexicographically(oid1, oid2) do
# Convert OIDs to lists if they're strings
oid1_list =
case oid1 do
oid when is_binary(oid) ->
case String.split(oid, ".") do
# Handle empty string case
[""] ->
[]
parts ->
try do
Enum.map(parts, &String.to_integer/1)
rescue
ArgumentError ->
# Invalid OID format, return empty list to handle gracefully
[]
end
end
oid when is_list(oid) ->
oid
end
oid2_list =
case oid2 do
oid when is_binary(oid) ->
case String.split(oid, ".") do
# Handle empty string case
[""] ->
[]
parts ->
try do
Enum.map(parts, &String.to_integer/1)
rescue
ArgumentError ->
# Invalid OID format, return empty list to handle gracefully
[]
end
end
oid when is_list(oid) ->
oid
end
compare_oid_parts(oid1_list, oid2_list)
end
# GenServer Callbacks
@impl true
def init(_opts) do
# Create metadata table
metadata_table = :ets.new(:snmp_sim_metadata, @table_opts)
state = %__MODULE__{
profile_tables: %{},
behavior_tables: %{},
metadata_table: metadata_table,
stats: init_stats()
}
Logger.info("SharedProfiles manager started")
{:ok, state}
end
@impl true
def handle_call(:init_profiles, _from, state) do
# Pre-create tables for common device types
device_types = [:cable_modem, :cmts, :switch, :router, :mta, :server]
{profile_tables, behavior_tables} =
Enum.reduce(device_types, {%{}, %{}}, fn device_type, {prof_acc, behav_acc} ->
prof_table = create_profile_table(device_type)
behav_table = create_behavior_table(device_type)
{
Map.put(prof_acc, device_type, prof_table),
Map.put(behav_acc, device_type, behav_table)
}
end)
new_state = %{state | profile_tables: profile_tables, behavior_tables: behavior_tables}
{:reply, :ok, new_state}
end
# Suppress Dialyzer warnings for this function
@dialyzer {:nowarn_function, handle_call: 3}
@impl true
def handle_call({:load_mib_profile, device_type, mib_files, opts}, _from, state) do
try do
case load_mib_profile_impl(device_type, mib_files, opts, state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
error ->
# Handle all error cases in a unified way
error_msg =
case error do
{:error, {:mib_load_failed, %{__exception__: true} = exception}} ->
"MIB load failed: #{Exception.message(exception)}"
{:error, {:mib_load_failed, reason}} when is_binary(reason) or is_atom(reason) ->
"MIB load failed: #{reason}"
{:error, reason} when is_binary(reason) or is_atom(reason) ->
"Error: #{reason}"
other ->
"Unexpected error: #{inspect(other, pretty: true)}"
end
Logger.error(error_msg)
{:reply, {:error, :load_failed}, state}
end
rescue
e ->
error_msg = "Error in load_mib_profile: #{Exception.format(:error, e, __STACKTRACE__)}"
Logger.error(error_msg)
{:reply, {:error, :internal_error}, state}
end
end
@impl true
def handle_call({:load_walk_profile, device_type, walk_file, opts}, _from, state) do
case load_walk_profile_impl(device_type, walk_file, opts, state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_call({:get_oid_value, device_type, oid, device_state}, _from, state) do
result = get_oid_value_impl(device_type, oid, device_state, state)
{:reply, result, state}
end
@impl true
def handle_call({:get_next_oid, device_type, oid}, _from, state) do
result = get_next_oid_impl(device_type, oid, state)
{:reply, result, state}
end
@impl true
def handle_call({:get_bulk_oids, device_type, start_oid, max_repetitions}, _from, state) do
# CRITICAL FIX: For GETBULK, we should only return OIDs that come AFTER start_oid
# If start_oid is the last OID, get_next_oid should return :end_of_mib
result =
case get_next_oid_impl(device_type, start_oid, state) do
{:ok, _first_oid} ->
# Start collecting from first_oid, but don't include it in the initial accumulator
# We'll add it during the collection process
collect_bulk_oids_with_values(
device_type,
# Start from start_oid, not first_oid
start_oid,
max_repetitions,
# Empty accumulator - don't pre-include any OIDs
[],
state
)
:end_of_mib ->
{:ok, []}
{:error, reason} ->
{:error, reason}
end
{:reply, result, state}
end
@impl true
def handle_call({:get_all_oids, device_type}, _from, state) do
case Map.get(state.profile_tables, device_type) do
nil ->
{:reply, {:error, :device_type_not_found}, state}
table ->
# Get all OIDs and return them
all_oids =
:ets.tab2list(table)
|> Enum.map(fn {oid, _data} -> oid end)
|> Enum.sort(&compare_oids_lexicographically/2)
{:reply, {:ok, all_oids}, state}
end
end
@impl true
def handle_call(:get_memory_stats, _from, state) do
stats = calculate_memory_stats(state)
{:reply, stats, state}
end
@impl true
def handle_call(:list_profiles, _from, state) do
profiles = Map.keys(state.profile_tables)
{:reply, profiles, state}
end
@impl true
def handle_call(:clear_all_profiles, _from, state) do
# Clear all ETS tables
Enum.each(state.profile_tables, fn {_type, table} ->
:ets.delete_all_objects(table)
end)
Enum.each(state.behavior_tables, fn {_type, table} ->
:ets.delete_all_objects(table)
end)
:ets.delete_all_objects(state.metadata_table)
{:reply, :ok, %{state | stats: init_stats()}}
end
@impl true
def handle_call({:store_profile, device_type, profile_data, behavior_data}, _from, state) do
# Ensure tables exist for this device type
{prof_table, behav_table, new_state} = ensure_device_tables(device_type, state)
# Store in ETS tables
store_profile_data(prof_table, profile_data)
store_behavior_data(behav_table, behavior_data)
# Calculate object count based on data type
object_count =
case profile_data do
data when is_map(data) -> map_size(data)
data when is_list(data) -> length(data)
_ -> 0
end
# Update metadata
metadata = %{
device_type: device_type,
source_type: :directly_stored,
object_count: object_count,
loaded_at: DateTime.utc_now(),
options: %{}
}
:ets.insert(new_state.metadata_table, {device_type, metadata})
# Update stats
updated_stats = update_load_stats(new_state.stats, device_type, object_count)
{:reply, :ok, %{new_state | stats: updated_stats}}
end
# Implementation Functions
defp load_mib_profile_impl(device_type, mib_files, opts, state) do
# Ensure tables exist for this device type
{prof_table, behav_table, new_state} = ensure_device_tables(device_type, state)
try do
# Compile MIBs if needed
compiled_mibs = SnmpKit.SnmpSim.MIB.Compiler.compile_mib_files(mib_files)
# Extract object definitions
all_objects =
compiled_mibs
|> Enum.map(&extract_objects_from_compiled_mib/1)
|> Enum.reduce(%{}, &Map.merge/2)
# Analyze behaviors
{:ok, behaviors} = SnmpKit.SnmpSim.MIB.BehaviorAnalyzer.analyze_mib_behaviors(all_objects)
# Store in ETS tables
store_profile_data(prof_table, all_objects)
store_behavior_data(behav_table, behaviors)
# Update metadata
metadata = %{
device_type: device_type,
source_type: :compiled_mib,
mib_files: mib_files,
object_count: map_size(all_objects),
loaded_at: DateTime.utc_now(),
options: opts
}
:ets.insert(new_state.metadata_table, {device_type, metadata})
# Update stats
updated_stats = update_load_stats(new_state.stats, device_type, map_size(all_objects))
{:ok, %{new_state | stats: updated_stats}}
rescue
error ->
Logger.error("Failed to load MIB profile for #{device_type}: #{inspect(error)}")
{:error, {:mib_load_failed, error}}
end
end
defp load_walk_profile_impl(device_type, walk_file, opts, state) do
# Ensure tables exist for this device type
{prof_table, behav_table, new_state} = ensure_device_tables(device_type, state)
try do
# Parse walk file
{:ok, oid_map} = SnmpKit.SnmpSim.WalkParser.parse_walk_file(walk_file)
# Enhance with intelligent behaviors
enhanced_behaviors =
SnmpKit.SnmpSim.MIB.BehaviorAnalyzer.enhance_walk_file_behaviors(oid_map)
# Separate profile data and behaviors
profile_data =
Map.new(enhanced_behaviors, fn {oid, data} ->
{oid, Map.drop(data, [:behavior])}
end)
behavior_data =
Map.new(enhanced_behaviors, fn {oid, data} ->
{oid, Map.get(data, :behavior, {:static_value, %{}})}
end)
# Store in ETS tables
store_profile_data(prof_table, profile_data)
store_behavior_data(behav_table, behavior_data)
# Update metadata
metadata = %{
device_type: device_type,
source_type: :walk_file,
source_file: walk_file,
object_count: map_size(oid_map),
loaded_at: DateTime.utc_now(),
options: opts
}
:ets.insert(new_state.metadata_table, {device_type, metadata})
# Update stats
updated_stats = update_load_stats(new_state.stats, device_type, map_size(oid_map))
{:ok, %{new_state | stats: updated_stats}}
rescue
error ->
Logger.error("Failed to load walk profile for #{device_type}: #{inspect(error)}")
{:error, {:walk_load_failed, error}}
end
end
defp get_oid_value_impl(device_type, oid, device_state, state) do
with prof_table when prof_table != nil <- Map.get(state.profile_tables, device_type),
behav_table when behav_table != nil <- Map.get(state.behavior_tables, device_type) do
case :ets.lookup(prof_table, oid) do
[{^oid, profile_data}] ->
# Get behavior configuration
behavior_config =
case :ets.lookup(behav_table, oid) do
[{^oid, behavior}] -> behavior
[] -> {:static_value, %{}}
end
# Apply behavior to generate current value
current_value =
SnmpKit.SnmpSim.ValueSimulator.simulate_value(
profile_data,
behavior_config,
device_state
)
# Convert string type to atom type for compatibility
atom_type = convert_type_to_atom(profile_data.type)
# Handle the case where ValueSimulator returns a typed tuple
final_value =
case current_value do
# Extract value from typed tuple
{_type, value} -> value
# Use value as-is if not a tuple
value -> value
end
{:ok, {atom_type, final_value}}
[] ->
{:error, :no_such_name}
end
else
nil -> {:error, :device_type_not_found}
end
end
defp get_next_oid_impl(device_type, oid, state) do
case Map.get(state.profile_tables, device_type) do
nil ->
{:error, :device_type_not_found}
table ->
# Get all OIDs and find the next one
all_oids =
:ets.tab2list(table)
|> Enum.map(fn {oid, _data} -> oid end)
|> Enum.sort(&compare_oids_lexicographically/2)
case find_next_oid_in_list(all_oids, oid) do
nil -> :end_of_mib
next_oid -> {:ok, next_oid}
end
end
end
defp collect_bulk_oids_with_values(device_type, _current_oid, 0, acc, state) do
# Convert OID list to 3-tuples with actual values from walk file
oid_tuples =
Enum.map(Enum.reverse(acc), fn oid ->
case get_oid_value_impl(device_type, oid, %{}, state) do
{:ok, {type, value}} -> {oid, type, value}
{:ok, value} -> {oid, :octet_string, value}
{:error, _} -> {oid, :octet_string, "Bulk value for #{oid}"}
end
end)
{:ok, oid_tuples}
end
defp collect_bulk_oids_with_values(device_type, current_oid, remaining, acc, state) do
case get_next_oid_impl(device_type, current_oid, state) do
{:ok, next_oid} ->
# Check if this OID actually exists in the walk file
case get_oid_value_impl(device_type, next_oid, %{}, state) do
{:ok, _} ->
# OID exists, continue collecting
collect_bulk_oids_with_values(
device_type,
next_oid,
remaining - 1,
[next_oid | acc],
state
)
{:error, :no_such_name} ->
# OID doesn't exist in walk file - this indicates end of MIB
# Convert accumulated OIDs to 3-tuples with actual values
oid_tuples =
Enum.map(Enum.reverse(acc), fn oid ->
case get_oid_value_impl(device_type, oid, %{}, state) do
{:ok, {type, value}} -> {oid, type, value}
{:ok, value} -> {oid, :octet_string, value}
{:error, _} -> {oid, :octet_string, "Bulk value for #{oid}"}
end
end)
{:ok, oid_tuples}
{:error, _} ->
# Other errors - continue with fallback
collect_bulk_oids_with_values(
device_type,
next_oid,
remaining - 1,
[next_oid | acc],
state
)
end
:end_of_mib ->
# Convert OID list to 3-tuples with actual values from walk file
oid_tuples =
Enum.map(Enum.reverse(acc), fn oid ->
case get_oid_value_impl(device_type, oid, %{}, state) do
{:ok, {type, value}} -> {oid, type, value}
{:ok, value} -> {oid, :octet_string, value}
{:error, _} -> {oid, :octet_string, "Bulk value for #{oid}"}
end
end)
{:ok, oid_tuples}
{:error, _reason} ->
# Convert accumulated OIDs to 3-tuples with actual values
oid_tuples =
Enum.map(Enum.reverse(acc), fn oid ->
case get_oid_value_impl(device_type, oid, %{}, state) do
{:ok, {type, value}} -> {oid, type, value}
{:ok, value} -> {oid, :octet_string, value}
{:error, _} -> {oid, :octet_string, "Bulk value for #{oid}"}
end
end)
{:ok, oid_tuples}
end
end
# Helper Functions
defp ensure_device_tables(device_type, state) do
prof_table = Map.get(state.profile_tables, device_type) || create_profile_table(device_type)
behav_table =
Map.get(state.behavior_tables, device_type) || create_behavior_table(device_type)
new_state = %{
state
| profile_tables: Map.put(state.profile_tables, device_type, prof_table),
behavior_tables: Map.put(state.behavior_tables, device_type, behav_table)
}
{prof_table, behav_table, new_state}
end
defp create_profile_table(device_type) do
table_name = String.to_atom("#{device_type}_profile")
case :ets.info(table_name) do
:undefined ->
:ets.new(table_name, @table_opts)
_ ->
# Table already exists, return the existing table name
table_name
end
end
defp create_behavior_table(device_type) do
table_name = String.to_atom("#{device_type}_behavior")
case :ets.info(table_name) do
:undefined ->
:ets.new(table_name, @table_opts)
_ ->
# Table already exists, return the existing table name
table_name
end
end
defp extract_objects_from_compiled_mib(_compiled_mib) do
# This would extract objects from the compiled MIB
# For now, return empty map - would be implemented based on actual MIB structure
%{}
end
defp store_profile_data(table, profile_data) do
profile_data
|> Enum.each(fn {oid, data} ->
:ets.insert(table, {oid, data})
end)
end
defp store_behavior_data(table, behavior_data) do
behavior_data
|> Enum.each(fn {oid, behavior} ->
:ets.insert(table, {oid, behavior})
end)
end
defp compare_oid_parts([], []), do: false
defp compare_oid_parts([], _), do: true
defp compare_oid_parts(_, []), do: false
defp compare_oid_parts([h1 | _t1], [h2 | _t2]) when h1 < h2, do: true
defp compare_oid_parts([h1 | _t1], [h2 | _t2]) when h1 > h2, do: false
defp compare_oid_parts([h1 | t1], [h2 | t2]) when h1 == h2, do: compare_oid_parts(t1, t2)
defp find_next_oid_in_list(oids, target_oid) do
# For GETNEXT, we need to find the first OID that is lexicographically greater than target_oid
# OR the first descendant of target_oid if target_oid is a prefix
# First try to find descendants (for cases like "1.3.6.1.2.1" -> "1.3.6.1.2.1.1.1.0")
descendants = Enum.filter(oids, fn oid -> oid_is_descendant(target_oid, oid) end)
case descendants do
[] ->
# No descendants, find the next OID lexicographically
# Must be strictly greater than target_oid (not equal)
Enum.find(oids, fn oid ->
oid != target_oid and compare_oids_lexicographically(target_oid, oid)
end)
_ ->
# Return the first (smallest) descendant
Enum.min_by(
descendants,
fn oid ->
case oid do
oid when is_binary(oid) -> String.split(oid, ".") |> Enum.map(&String.to_integer/1)
oid when is_list(oid) -> oid
end
end,
fn -> nil end
)
end
end
# Check if an OID is a descendant of a target OID (i.e., target is a prefix)
defp oid_is_descendant(target_oid, candidate_oid) do
# Convert both OIDs to string format for comparison
target_str =
case target_oid do
target when is_binary(target) -> target
target when is_list(target) -> Enum.join(target, ".")
end
candidate_str =
case candidate_oid do
candidate when is_binary(candidate) -> candidate
candidate when is_list(candidate) -> Enum.join(candidate, ".")
end
target_parts = String.split(target_str, ".")
candidate_parts = String.split(candidate_str, ".")
# If target is shorter and matches the beginning of candidate, candidate is a descendant
if length(target_parts) < length(candidate_parts) do
target_parts == Enum.take(candidate_parts, length(target_parts))
else
false
end
end
defp init_stats do
%{
profiles_loaded: 0,
total_objects: 0,
memory_usage: 0,
lookup_count: 0,
cache_hits: 0
}
end
defp update_load_stats(stats, _device_type, object_count) do
%{
stats
| profiles_loaded: stats.profiles_loaded + 1,
total_objects: stats.total_objects + object_count
}
end
defp calculate_memory_stats(state) do
profile_memory = calculate_table_memory(state.profile_tables)
behavior_memory = calculate_table_memory(state.behavior_tables)
metadata_memory = calculate_table_memory(%{metadata: state.metadata_table})
%{
total_memory_kb: div(profile_memory + behavior_memory + metadata_memory, 1024),
profile_memory_kb: div(profile_memory, 1024),
behavior_memory_kb: div(behavior_memory, 1024),
metadata_memory_kb: div(metadata_memory, 1024),
table_count: map_size(state.profile_tables) + map_size(state.behavior_tables) + 1,
profiles_loaded: state.stats.profiles_loaded,
total_objects: state.stats.total_objects
}
end
defp calculate_table_memory(tables) when is_map(tables) do
tables
|> Enum.map(fn {_name, table} ->
:ets.info(table, :memory) * :erlang.system_info(:wordsize)
end)
|> Enum.sum()
end
defp convert_type_to_atom(type) when is_binary(type) do
case String.upcase(type) do
"OCTET STRING" -> :octet_string
"STRING" -> :octet_string
"INTEGER" -> :integer
"GAUGE32" -> :gauge32
"COUNTER32" -> :counter32
"COUNTER64" -> :counter64
"TIMETICKS" -> :timeticks
"OBJECT IDENTIFIER" -> :object_identifier
"OID" -> :object_identifier
"IP ADDRESS" -> :ip_address
"IPADDRESS" -> :ip_address
"OPAQUE" -> :opaque
"BITS" -> :bits
"NULL" -> :null
_ -> String.to_atom(String.downcase(type))
end
end
defp convert_type_to_atom(type) when is_atom(type) do
type
end
end