Current section
Files
Jump to
Current section
Files
lib/snmpkit/snmp_sim/device.ex
defmodule SnmpKit.SnmpSim.Device do
@moduledoc """
Lightweight Device GenServer for handling SNMP requests.
Uses shared profiles and minimal device-specific state for scalability.
Features:
- Dynamic value generation with realistic patterns
- Shared profile system for memory efficiency
- Counter and gauge simulation with proper incrementing
- Comprehensive error handling and fallback mechanisms
- Support for SNMP walk operations
"""
use GenServer
require Logger
alias SnmpKit.SnmpSim.{DeviceDistribution}
alias SnmpKit.SnmpSim.Core.Server
alias SnmpKit.SnmpSim.Device.ErrorInjector
alias SnmpKit.SnmpSim.Device.OidHandler
import SnmpKit.SnmpSim.Device.OidHandler
import SnmpKit.SnmpSim.Device.PduProcessor, only: [process_snmp_pdu: 2]
defstruct [
:device_id,
:port,
:device_type,
:server_pid,
:mac_address,
:uptime_start,
# Device-specific counter state
:counters,
# Device-specific gauge state
:gauges,
# Device-specific status variables
:status_vars,
:community,
# For tracking access time
:last_access,
# Active error injection conditions
:error_conditions,
# Flag indicating if device has walk data loaded
:has_walk_data,
# Manual OID map for devices created with {:manual, oid_map}
:oid_map,
# Tracks if a profile (of any source) was explicitly provided at start
:profile_provided,
# Manual device flag to force no shared profile usage
:manual_device,
# Modem firmware upgrade support (DOCSIS)
:upgrade_enabled,
:upgrade
]
@default_community "public"
@doc """
Start a device with the given device configuration.
## Device Config
Device config should contain:
- `:port` - UDP port for the device (required)
- `:device_type` - Type of device (:cable_modem, :switch, etc.)
- `:device_id` - Unique device identifier
- `:community` - SNMP community string (default: "public")
- `:mac_address` - MAC address (auto-generated if not provided)
## Examples
device_config = %{
port: 9001,
device_type: :cable_modem,
device_id: "cable_modem_9001",
community: "public"
}
{:ok, device} = SnmpKit.SnmpSim.Device.start_link(device_config)
"""
def start_link(device_config) when is_map(device_config) do
GenServer.start_link(__MODULE__, device_config)
end
@doc """
Stop a device gracefully with resilient error handling.
"""
def stop(device_pid) when is_pid(device_pid) do
case Process.alive?(device_pid) do
false ->
:ok
true ->
try do
GenServer.stop(device_pid, :normal, 5000)
catch
:exit, {:noproc, _} ->
:ok
:exit, {:normal, _} ->
:ok
:exit, {:shutdown, _} ->
:ok
:exit, {:timeout, _} ->
# Process didn't stop gracefully, force kill
Process.exit(device_pid, :kill)
:ok
:exit, _reason ->
:ok
end
end
end
def stop(%{pid: device_pid}) when is_pid(device_pid) do
stop(device_pid)
end
def stop(device_info) when is_map(device_info) do
cond do
Map.has_key?(device_info, :pid) -> stop(device_info.pid)
Map.has_key?(device_info, :device_pid) -> stop(device_info.device_pid)
true -> {:error, :no_pid_found}
end
end
@doc """
Cleanup all orphaned SNMP simulator device processes.
Useful for test cleanup when devices may have been left running.
"""
def cleanup_all_devices do
# Find all processes running SnmpKit.SnmpSim.Device
device_processes =
Process.list()
|> Enum.filter(fn pid ->
try do
case Process.info(pid, :dictionary) do
{:dictionary, dict} ->
# Check if this process is running SnmpKit.SnmpSim.Device
Enum.any?(dict, fn
{:"$initial_call", {SnmpKit.SnmpSim.Device, :init, 1}} ->
true
{:"$ancestors", ancestors} when is_list(ancestors) ->
Enum.any?(ancestors, fn ancestor ->
is_atom(ancestor) and Atom.to_string(ancestor) =~ "SnmpSim"
end)
_ ->
false
end)
_ ->
false
end
catch
_, _ -> false
end
end)
# Stop each device process
cleanup_count =
Enum.reduce(device_processes, 0, fn pid, acc ->
case stop(pid) do
:ok -> acc + 1
_ -> acc
end
end)
Logger.info("Cleaned up #{cleanup_count} orphaned device processes")
{:ok, cleanup_count}
end
@doc """
Monitor a device process and get notified when it dies.
Returns a monitor reference that can be used with Process.demonitor/1.
"""
def monitor_device(device_pid) when is_pid(device_pid) do
Process.monitor(device_pid)
end
@doc """
Create a device with monitoring enabled.
Returns {:ok, {device_pid, monitor_ref}} or {:error, reason}.
"""
def start_link_monitored(device_config) when is_map(device_config) do
case start_link(device_config) do
{:ok, device_pid} ->
monitor_ref = monitor_device(device_pid)
{:ok, {device_pid, monitor_ref}}
error ->
error
end
end
@doc """
Get device information and statistics.
"""
def get_info(device_pid) do
GenServer.call(device_pid, :get_info)
end
@doc """
Update device counters manually (useful for testing).
"""
def update_counter(device_pid, oid, increment) do
GenServer.call(device_pid, {:update_counter, oid, increment})
end
@doc """
Set a gauge value manually (useful for testing).
"""
def set_gauge(device_pid, oid, value) do
GenServer.call(device_pid, {:set_gauge, oid, value})
end
@doc """
Get an OID value from the device (for testing).
"""
def get(device_pid, oid) do
GenServer.call(device_pid, {:get_oid, oid})
end
@doc """
Get the next OID value from the device (for testing).
"""
def get_next(device_pid, oid) do
GenServer.call(device_pid, {:get_next_oid, oid})
end
@doc """
Get bulk OID values from the device (for testing).
"""
def get_bulk(device_pid, oids, max_repetitions) do
get_bulk(device_pid, oids, 0, max_repetitions)
end
def get_bulk(device_pid, oids, non_repeaters, max_repetitions) do
GenServer.call(device_pid, {:get_bulk_oid, oids, non_repeaters, max_repetitions})
end
@doc """
Walk OID values from the device (for testing).
"""
def walk(device_pid, oid) do
GenServer.call(device_pid, {:walk_oid, oid})
end
@doc """
Simulate a device reboot.
"""
def reboot(device_pid) do
GenServer.call(device_pid, :reboot)
end
# GenServer callbacks
@impl true
def init(device_config) when is_map(device_config) do
case Map.fetch(device_config, :device_id) do
:error ->
raise "Missing required :device_id in device init args"
{:ok, device_id} ->
port = Map.fetch!(device_config, :port)
device_type = Map.fetch!(device_config, :device_type)
community = Map.get(device_config, :community, @default_community)
mac_address =
Map.get(device_config, :mac_address, generate_mac_address(device_type, port))
# Extract OID map from profile if provided
{oid_map, has_walk_data} =
case Map.get(device_config, :profile) do
%{oid_map: oid_map} when is_map(oid_map) and map_size(oid_map) > 0 ->
Logger.info(
"Device #{device_id} loading #{map_size(oid_map)} OIDs from manual profile"
)
{oid_map, true}
_ ->
# Load walk file if provided and set flag
walk_data_loaded =
case Map.get(device_config, :walk_file) do
nil ->
false
walk_file ->
Logger.info(
"Loading walk file #{walk_file} for device type #{inspect(device_type)}"
)
case SnmpKit.SnmpSim.MIB.SharedProfiles.load_walk_profile(
device_type,
walk_file
) do
:ok ->
Logger.info(
"Successfully loaded walk file #{walk_file} for device type #{inspect(device_type)}"
)
true
{:error, reason} ->
Logger.warning(
"Failed to load walk file #{walk_file} for device type #{inspect(device_type)}: #{inspect(reason)}"
)
false
end
end
{%{}, walk_data_loaded}
end
# Start the UDP server for this device
case Server.start_link(port, community: community) do
{:ok, server_pid} ->
# Initialize modem upgrade defaults
upgrade_enabled =
case device_type do
:cable_modem -> Map.get(device_config, :upgrade_enabled, true)
_ -> false
end
upgrade_opts = Map.get(device_config, :upgrade_opts, %{})
state = %__MODULE__{
device_id: device_id,
port: port,
device_type: device_type,
server_pid: server_pid,
mac_address: mac_address,
uptime_start: :erlang.monotonic_time(),
counters: %{},
gauges: %{},
status_vars: %{},
community: community,
last_access: System.monotonic_time(:millisecond),
error_conditions: %{},
has_walk_data: has_walk_data,
oid_map: oid_map,
profile_provided: Map.has_key?(device_config, :profile),
manual_device: Map.get(device_config, :manual_device, false),
upgrade_enabled: upgrade_enabled,
upgrade: SnmpKit.SnmpSim.Device.ModemUpgrade.default_state(upgrade_opts)
}
# Set up the SNMP handler for this device
device_pid = self()
handler_fn = fn pdu, context ->
GenServer.call(device_pid, {:handle_snmp, pdu, context})
end
:ok = Server.set_device_handler(server_pid, handler_fn)
# Initialize device state from shared profile
{:ok, initialized_state} = initialize_device_state(state)
Logger.info("Device #{device_id} started on port #{port}")
{:ok, initialized_state}
{:error, reason} ->
Logger.error(
"Failed to start UDP server for device #{device_id} on port #{port}: #{inspect(reason)}"
)
{:stop, reason}
end
end
end
@impl true
def handle_call(:get_info, _from, state) when is_map(state) do
# Get OID count (simplified for testing)
# Placeholder value for testing
oid_count = 100
info = %{
device_id: state.device_id,
port: state.port,
device_type: state.device_type,
mac_address: state.mac_address,
uptime: calculate_uptime(state),
oid_count: oid_count,
counters: map_size(state.counters),
gauges: map_size(state.gauges),
status_vars: map_size(state.status_vars),
last_access: state.last_access,
has_walk_data: state.has_walk_data
}
# Update last access time
new_state = %{state | last_access: System.monotonic_time(:millisecond)}
{:reply, info, new_state}
end
@impl true
def handle_call({:handle_snmp, pdu, _context}, _from, state) do
# Check for error injection conditions first
case ErrorInjector.check_error_conditions(pdu, state) do
:continue ->
# Process the PDU normally
response = process_snmp_pdu(pdu, state)
{:reply, {:ok, response}, state}
{:error, error_type} ->
# Handle error injection
{:reply, {:error, error_type}, state}
end
end
@impl true
def handle_call({:update_counter, oid, increment}, _from, state) do
new_counters = Map.update(state.counters, oid, increment, &(&1 + increment))
new_state = %{state | counters: new_counters}
{:reply, :ok, new_state}
end
@impl true
def handle_call({:set_gauge, oid, value}, _from, state) do
new_gauges = Map.put(state.gauges, oid, value)
new_state = %{state | gauges: new_gauges}
{:reply, :ok, new_state}
end
@impl true
def handle_call({:get_oid, oid}, _from, state) do
result = SnmpKit.SnmpSim.Device.OidHandler.get_oid_value(oid, state)
formatted_result =
case result do
# Return 3-tuple format consistently
{:ok, {oid_string, type, value}} -> {:ok, {oid_string, type, value}}
# Return {oid, type, value} for other formats
{:ok, {type, value}} -> {:ok, {oid, type, value}}
# Default to octet_string type
{:ok, value} -> {:ok, {oid, :octet_string, value}}
error -> error
end
new_state = %{state | last_access: System.monotonic_time(:millisecond)}
{:reply, formatted_result, new_state}
end
@impl true
def handle_call({:get_next_oid, oid}, _from, state) do
result = SnmpKit.SnmpSim.Device.OidHandler.get_next_oid_value(state.device_type, oid, state)
formatted_result =
case result do
{:ok, {oid, type, value}} -> {:ok, {OidHandler.oid_to_string(oid), type, value}}
{:ok, {oid, value}} -> {:ok, {OidHandler.oid_to_string(oid), :octet_string, value}}
{:ok, value} -> {:ok, value}
error -> error
end
{:reply, formatted_result, state}
end
@impl true
def handle_call({:get_bulk_oid, oids, non_repeaters, max_repetitions}, _from, state) do
# Convert OIDs to varbind format expected by process_get_bulk_varbinds
varbinds =
case oids do
oid when is_binary(oid) ->
[{oid, :null}]
oid_list when is_list(oid_list) ->
Enum.map(oid_list, fn oid -> {oid, :null} end)
_ ->
[{oids, :null}]
end
result =
process_get_bulk_varbinds(varbinds, non_repeaters, max_repetitions, state.device_type)
IO.inspect(result, label: "GETBULK result from process_get_bulk_varbinds")
formatted_result =
Enum.map(result, fn
{oid, type, value} -> {OidHandler.oid_to_string(oid), type, value}
{oid, value} -> {OidHandler.oid_to_string(oid), :octet_string, value}
end)
IO.inspect(formatted_result, label: "GETBULK formatted_result")
{:reply, {:ok, formatted_result}, state}
end
@impl true
def handle_call({:walk_oid, oid}, _from, state) do
case OidHandler.walk_oid_values(oid, state) do
{:ok, results} ->
# Convert OIDs to strings and sort them properly using numerical comparison
sorted_results =
results
|> Enum.map(fn {oid, {type, value}} ->
oid_string = OidHandler.oid_to_string(oid)
{oid_string, type, value}
end)
|> Enum.sort_by(fn {oid_string, _type, _value} ->
# Convert OID string to list of integers for proper numerical sorting
oid_string
|> String.split(".")
|> Enum.map(&String.to_integer/1)
end)
|> Enum.map(fn {oid_string, type, value} -> {oid_string, type, value} end)
new_state = %{state | last_access: System.monotonic_time(:millisecond)}
{:reply, {:ok, sorted_results}, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_call(:reboot, _from, state) do
Logger.info("Device #{state.device_id} rebooting")
new_state = %{
state
| uptime_start: :erlang.monotonic_time(),
counters: %{},
gauges: %{},
status_vars: %{},
error_conditions: %{},
has_walk_data: false
}
{:ok, initialized_state} = initialize_device_state(new_state)
{:reply, :ok, initialized_state}
end
# Error injection message handlers
@impl true
def handle_info({:error_injection, :timeout, config}, state) do
Logger.debug("Applying timeout error injection to device #{state.device_id}")
new_error_conditions = Map.put(state.error_conditions, :timeout, config)
{:noreply, %{state | error_conditions: new_error_conditions}}
end
@impl true
def handle_info({:error_injection, :packet_loss, config}, state) do
Logger.debug("Applying packet loss error injection to device #{state.device_id}")
new_error_conditions = Map.put(state.error_conditions, :packet_loss, config)
{:noreply, %{state | error_conditions: new_error_conditions}}
end
@impl true
def handle_info({:error_injection, :snmp_error, config}, state) do
Logger.debug("Applying SNMP error injection to device #{state.device_id}")
new_error_conditions = Map.put(state.error_conditions, :snmp_error, config)
{:noreply, %{state | error_conditions: new_error_conditions}}
end
@impl true
def handle_info({:error_injection, :malformed, config}, state) do
Logger.debug("Applying malformed response error injection to device #{state.device_id}")
new_error_conditions = Map.put(state.error_conditions, :malformed, config)
{:noreply, %{state | error_conditions: new_error_conditions}}
end
@impl true
def handle_info({:error_injection, :device_failure, config}, state) do
Logger.info(
"Applying device failure error injection to device #{state.device_id}: #{config.failure_type}"
)
new_error_conditions = Map.put(state.error_conditions, :device_failure, config)
case config.failure_type do
:reboot ->
# Schedule device recovery after duration
Process.send_after(self(), {:error_injection, :recovery, config}, config.duration_ms)
{:noreply, %{state | error_conditions: new_error_conditions}}
:power_failure ->
# Simulate complete power loss - device becomes unreachable
# down
new_status_vars = Map.put(state.status_vars, "oper_status", 2)
{:noreply,
%{state | error_conditions: new_error_conditions, status_vars: new_status_vars}}
:network_disconnect ->
# Simulate network connectivity loss
# administratively down
new_status_vars = Map.put(state.status_vars, "admin_status", 2)
{:noreply,
%{state | error_conditions: new_error_conditions, status_vars: new_status_vars}}
_ ->
{:noreply, %{state | error_conditions: new_error_conditions}}
end
end
@impl true
def handle_info({:error_injection, :recovery, config}, state) do
Logger.info("Device #{state.device_id} recovering from #{config.failure_type}")
# Remove device failure condition
new_error_conditions = Map.delete(state.error_conditions, :device_failure)
# Restore device status based on recovery behavior
case config[:recovery_behavior] do
:reset_counters ->
# Reset counters and restore status
new_state = %{
state
| error_conditions: new_error_conditions,
counters: %{},
status_vars: %{"admin_status" => 1, "oper_status" => 1, "last_change" => 0},
has_walk_data: false
}
{:noreply, new_state}
:gradual ->
# Gradual recovery - restore status but keep some impact
new_status_vars =
Map.merge(state.status_vars, %{
"admin_status" => 1,
"oper_status" => 1,
"last_change" => calculate_uptime(state)
})
{:noreply,
%{state | error_conditions: new_error_conditions, status_vars: new_status_vars}}
_ ->
# Normal recovery
new_status_vars =
Map.merge(state.status_vars, %{
"admin_status" => 1,
"oper_status" => 1
})
{:noreply,
%{state | error_conditions: new_error_conditions, status_vars: new_status_vars}}
end
end
@impl true
def handle_info({:error_injection, :clear_all}, state) do
Logger.info("Clearing all error conditions for device #{state.device_id}")
{:noreply, %{state | error_conditions: %{}}}
end
# Modem upgrade messages
@impl true
def handle_info({:modem_upgrade, {:set, field, value}}, state) do
new_upgrade = SnmpKit.SnmpSim.Device.ModemUpgrade.apply_set(field, value, state.upgrade)
{:noreply, %{state | upgrade: new_upgrade}}
end
@impl true
def handle_info({:modem_upgrade, :trigger}, state) do
# Start the upgrade state machine if enabled and not already running
{maybe_msgs, new_upgrade} =
SnmpKit.SnmpSim.Device.ModemUpgrade.trigger(state.upgrade,
invalid_server_regex: Map.get(state.upgrade, :invalid_server_regex)
)
# Schedule phase messages
Enum.each(maybe_msgs, fn {delay_ms, msg} -> Process.send_after(self(), msg, delay_ms) end)
{:noreply, %{state | upgrade: new_upgrade}}
end
@impl true
def handle_info({:modem_upgrade, {:phase, phase}}, state) do
{maybe_msgs, new_upgrade} =
SnmpKit.SnmpSim.Device.ModemUpgrade.advance_phase(state.upgrade, phase)
Enum.each(maybe_msgs, fn {delay_ms, msg} -> Process.send_after(self(), msg, delay_ms) end)
{:noreply, %{state | upgrade: new_upgrade}}
end
@impl true
def handle_info(msg, state) do
Logger.debug("Device #{state.device_id} received unhandled message: #{inspect(msg)}")
{:noreply, state}
end
@impl true
def terminate(reason, %{server_pid: server_pid, device_id: device_id} = _state)
when is_pid(server_pid) do
GenServer.stop(server_pid)
Logger.info("Device #{device_id} terminated: #{inspect(reason)}")
:ok
end
def terminate(reason, %{device_id: device_id} = _state) do
Logger.info("Device #{device_id} terminated: #{inspect(reason)}")
:ok
end
def terminate(reason, state) do
Logger.info(
"Device terminated with invalid state: #{inspect(reason)}, state: #{inspect(state)}"
)
:ok
end
# Private functions
defp initialize_device_state(state) do
# Manual devices force no shared profile usage
if Map.get(state, :manual_device, false) do
Logger.info("Device #{state.device_id} initialized as manual device with basic profile")
counters = %{
"1.3.6.1.2.1.2.2.1.10.1" => 0,
"1.3.6.1.2.1.2.2.1.16.1" => 0
}
gauges = %{
"1.3.6.1.2.1.2.2.1.5.1" => 100_000_000,
"1.3.6.1.2.1.2.2.1.4.1" => 1500
}
status_vars = initialize_status_vars(state)
{:ok,
%{
state
| counters: counters,
gauges: gauges,
status_vars: status_vars,
error_conditions: state.error_conditions,
has_walk_data: false
}}
else
# Try to load actual profile data from SharedProfiles
profiles = SnmpKit.SnmpSim.MIB.SharedProfiles.list_profiles()
Logger.info(
"Device #{state.device_id} checking profiles: #{inspect(profiles)} for device_type: #{inspect(state.device_type)}"
)
case Enum.member?(profiles, state.device_type) do
true ->
Logger.info(
"Device #{state.device_id} initialized with profile for device type: #{state.device_type}"
)
# Profile exists in SharedProfiles, device will use it via OidHandler
{:ok, %{state | has_walk_data: true}}
false ->
# Fallback to basic implementation for testing
Logger.info("Device #{state.device_id} initialized with basic profile for testing")
# Initialize basic counters and gauges for testing
counters = %{
# ifInOctets
"1.3.6.1.2.1.2.2.1.10.1" => 0,
# ifOutOctets
"1.3.6.1.2.1.2.2.1.16.1" => 0
}
gauges = %{
# ifSpeed
"1.3.6.1.2.1.2.2.1.5.1" => 100_000_000,
# ifMtu
"1.3.6.1.2.1.2.2.1.4.1" => 1500
}
status_vars = initialize_status_vars(state)
{:ok,
%{
state
| counters: counters,
gauges: gauges,
status_vars: status_vars,
error_conditions: state.error_conditions,
has_walk_data: false
}}
end
end
end
defp generate_mac_address(device_type, port) do
# Generate MAC address using DeviceDistribution module
DeviceDistribution.generate_device_id(device_type, port, format: :mac_based)
end
defp initialize_status_vars(_state) do
# Initialize device-specific status variables
%{
# up
"admin_status" => 1,
# up
"oper_status" => 1,
"last_change" => 0
}
end
defp process_get_bulk_varbinds(varbinds, non_repeaters, max_repetitions, device_type) do
IO.inspect({varbinds, non_repeaters, max_repetitions, device_type},
label: "GETBULK input params"
)
{non_repeater_vars, repeater_vars} = Enum.split(varbinds, non_repeaters)
IO.inspect({non_repeater_vars, repeater_vars}, label: "GETBULK split varbinds")
# For GETBULK, we need to get the NEXT OIDs, not the exact OIDs
non_repeater_results =
non_repeater_vars
|> Enum.map(fn {oid, _} ->
device_state = %{device_type: device_type, uptime: 0}
case SnmpKit.SnmpSim.Device.OidHandler.get_next_oid_value(device_type, oid, device_state) do
{:ok, {next_oid, type, value}} -> {next_oid, type, value}
{:ok, {next_oid, value}} -> {next_oid, :octet_string, value}
{:ok, value} -> {oid, :octet_string, value}
{:error, _} -> {oid, :no_such_name, :null}
end
end)
IO.inspect(non_repeater_results, label: "GETBULK non_repeater_results")
repeater_results =
repeater_vars
|> Enum.flat_map(fn {oid, _} ->
1..max_repetitions
|> Enum.reduce_while([], fn _i, acc ->
current_oid = if acc == [], do: oid, else: elem(List.last(acc), 0)
IO.inspect(current_oid, label: "GETBULK current_oid for iteration")
device_state = %{device_type: device_type, uptime: 0}
case SnmpKit.SnmpSim.Device.OidHandler.get_next_oid_value(
device_type,
current_oid,
device_state
) do
{:ok, {next_oid, type, value}} ->
result = {next_oid, type, value}
IO.inspect(result, label: "GETBULK got next_oid result")
{:cont, acc ++ [result]}
{:ok, {next_oid, value}} ->
result = {next_oid, :octet_string, value}
IO.inspect(result, label: "GETBULK got next_oid result (2-tuple)")
{:cont, acc ++ [result]}
{:ok, value} ->
result = {current_oid, :octet_string, value}
IO.inspect(result, label: "GETBULK got value result")
{:cont, acc ++ [result]}
{:error, reason} ->
IO.inspect(reason, label: "GETBULK error, halting")
{:halt, acc}
end
end)
end)
IO.inspect(repeater_results, label: "GETBULK repeater_results")
non_repeater_results ++ repeater_results
end
end