better mib handling and tests

This commit is contained in:
Graham McIntire 2026-01-21 10:07:07 -06:00
parent 3abd70e133
commit 7c660169b7
No known key found for this signature in database
12 changed files with 1015 additions and 125 deletions

View file

@ -0,0 +1,222 @@
defmodule Towerops.Snmp.DeferredDiscovery do
@moduledoc """
Handles deferred/slow SNMP discovery checks with timeouts.
Based on LibreNMS's approach of separating fast checks (string matching on sysDescr/sysObjectID)
from slow checks (snmpget/snmpwalk for detailed data).
Fast checks run immediately and are required for basic discovery.
Slow checks (sensors, detailed interfaces) are deferred and can timeout gracefully
without failing the entire discovery.
## Example
# Run discovery with deferred checks
{:ok, results} = DeferredDiscovery.discover_with_deferred(device, [
fast: [:system_info, :profile_selection],
slow: [:sensors, :interfaces, :neighbors]
])
# Results include which deferred checks succeeded or timed out
%{
system_info: %{...},
sensors: {:ok, [...]}, # or {:timeout, []} if timed out
interfaces: {:ok, [...]},
deferred_status: %{sensors: :ok, interfaces: :ok, neighbors: :timeout}
}
"""
alias Towerops.Snmp.Client
require Logger
@default_fast_timeout 10_000
@default_slow_timeout 30_000
@default_deferred_timeout 60_000
@type check_type :: :fast | :slow | :deferred
@type check_result :: {:ok, term()} | {:error, term()} | {:timeout, term()}
@doc """
Runs a fast SNMP check with a short timeout.
Fast checks are for quick string/regex matching operations.
If they fail, discovery should abort.
"""
@spec fast_check(Client.connection_opts(), (-> term()), keyword()) :: check_result()
def fast_check(client_opts, check_fn, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_fast_timeout)
run_with_timeout(client_opts, check_fn, timeout, :fast)
end
@doc """
Runs a slow SNMP check with a longer timeout.
Slow checks are for operations that may require multiple SNMP walks.
If they timeout, discovery can continue with partial data.
"""
@spec slow_check(Client.connection_opts(), (-> term()), keyword()) :: check_result()
def slow_check(_client_opts, check_fn, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_slow_timeout)
default_value = Keyword.get(opts, :default, [])
task = Task.async(fn -> check_fn.() end)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, result} ->
result
nil ->
Logger.warning("Slow check timed out after #{timeout}ms, using default value")
{:ok, default_value}
end
end
@doc """
Runs a deferred SNMP check that can be retried later.
Deferred checks are for non-critical data that's nice to have.
If they fail or timeout, discovery succeeds with a note about what's missing.
"""
@spec deferred_check(Client.connection_opts(), (-> term()), keyword()) :: check_result()
def deferred_check(_client_opts, check_fn, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_deferred_timeout)
default_value = Keyword.get(opts, :default, [])
check_name = Keyword.get(opts, :name, "unknown")
task = Task.async(fn -> check_fn.() end)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, {:ok, result}} ->
{:ok, result}
{:ok, {:error, reason}} ->
Logger.debug("Deferred check '#{check_name}' failed: #{inspect(reason)}")
{:deferred, default_value, reason}
nil ->
Logger.info("Deferred check '#{check_name}' timed out after #{timeout}ms")
{:timeout, default_value}
end
end
@doc """
Runs multiple checks in parallel with individual timeouts.
Returns results for all checks, with status for each.
"""
@spec parallel_checks([{atom(), (-> term()), keyword()}]) :: %{atom() => check_result()}
def parallel_checks(checks) do
checks
|> Enum.map(fn {name, check_fn, opts} ->
timeout = Keyword.get(opts, :timeout, @default_slow_timeout)
default = Keyword.get(opts, :default, [])
task =
Task.async(fn ->
try do
case check_fn.() do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
other -> {:ok, other}
end
rescue
e -> {:error, Exception.message(e)}
end
end)
{name, task, timeout, default}
end)
|> Map.new(fn {name, task, timeout, default} ->
result =
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, {:ok, value}} -> {:ok, value}
{:ok, {:error, reason}} -> {:error, reason}
nil -> {:timeout, default}
end
{name, result}
end)
end
@doc """
Tests if a device is responsive with a quick ping-like SNMP check.
Uses sysObjectID which all SNMP devices must support.
"""
@spec device_responsive?(Client.connection_opts(), keyword()) :: boolean()
def device_responsive?(client_opts, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5_000)
task =
Task.async(fn ->
Client.get(client_opts, "1.3.6.1.2.1.1.2.0")
end)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, {:ok, _}} -> true
_ -> false
end
end
@doc """
Categorizes a device as fast or slow based on initial response time.
Returns :fast, :slow, or :unresponsive.
"""
@spec categorize_device_speed(Client.connection_opts()) :: :fast | :slow | :unresponsive
def categorize_device_speed(client_opts) do
start_time = System.monotonic_time(:millisecond)
case Client.get(client_opts, "1.3.6.1.2.1.1.1.0") do
{:ok, _} ->
elapsed = System.monotonic_time(:millisecond) - start_time
cond do
elapsed < 1_000 -> :fast
elapsed < 5_000 -> :slow
true -> :slow
end
{:error, _} ->
:unresponsive
end
end
@doc """
Returns recommended timeouts based on device speed category.
"""
@spec timeouts_for_speed(:fast | :slow | :unresponsive) :: keyword()
def timeouts_for_speed(:fast) do
[
fast: 5_000,
slow: 15_000,
deferred: 30_000
]
end
def timeouts_for_speed(:slow) do
[
fast: 15_000,
slow: 45_000,
deferred: 90_000
]
end
def timeouts_for_speed(:unresponsive) do
[
fast: 30_000,
slow: 60_000,
deferred: 120_000
]
end
# Private functions
defp run_with_timeout(_client_opts, check_fn, timeout, check_type) do
task = Task.async(fn -> check_fn.() end)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, result} ->
result
nil ->
Logger.warning("#{check_type} check timed out after #{timeout}ms")
{:error, :timeout}
end
end
end

View file

@ -20,6 +20,7 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Profiles.DeviceProfile
alias Towerops.Repo
alias Towerops.Snmp.Client
alias Towerops.Snmp.DeferredDiscovery
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.NeighborDiscovery
@ -104,45 +105,90 @@ defmodule Towerops.Snmp.Discovery do
client_opts = build_client_opts(device)
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),
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces(client_opts, profile),
{:ok, sensors} <- discover_sensors(client_opts, profile),
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors),
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, discovered_device.interfaces),
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
_ = update_device_discovery_time(device)
Logger.info("SNMP discovery completed successfully for: #{device.name}")
# Categorize device speed for adaptive timeouts
device_speed = DeferredDiscovery.categorize_device_speed(client_opts)
timeouts = DeferredDiscovery.timeouts_for_speed(device_speed)
# Determine if this was first discovery or rediscovery
# Check if device existed before (had last_discovery_at set)
is_rediscovery = device.last_discovery_at != nil
# Log discovery event
log_discovery_event(device, discovered_device, is_rediscovery)
# Broadcast discovery completion for real-time updates
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:discovery_completed, device.id}
)
{:ok, discovered_device}
if device_speed == :unresponsive do
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
{:error, :device_unresponsive}
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
Logger.debug("Device #{device.name} categorized as #{device_speed}, using adaptive timeouts")
do_discover_device(device, client_opts, timeouts)
end
else
{:error, :snmp_not_enabled}
end
end
# Internal discovery with adaptive timeouts based on device speed
defp do_discover_device(device, client_opts, timeouts) 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),
{: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),
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors),
{:ok, neighbors} <- discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts),
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
_ = update_device_discovery_time(device)
Logger.info("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)
is_rediscovery = device.last_discovery_at != nil
# Log discovery event
log_discovery_event(device, discovered_device, is_rediscovery)
# Broadcast discovery completion for real-time updates
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:discovery_completed, device.id}
)
{:ok, discovered_device}
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
end
end
# Interface discovery with timeout - falls back to empty list on timeout
defp discover_interfaces_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> discover_interfaces(client_opts, profile) end,
timeout: timeouts[:slow],
default: []
)
end
# Sensor discovery with timeout - falls back to empty list on timeout
defp discover_sensors_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> discover_sensors(client_opts, profile) end,
timeout: timeouts[:slow],
default: []
)
end
# Neighbor discovery with timeout - falls back to empty list on timeout
defp discover_neighbors_with_timeout(client_opts, interfaces, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> NeighborDiscovery.discover_neighbors(client_opts, interfaces) end,
timeout: timeouts[:deferred],
default: []
)
end
@doc """
Runs discovery for all SNMP-enabled devices in an organization.
Returns a summary of successful and failed discoveries.

View file

@ -5,11 +5,88 @@ defmodule Towerops.Snmp.MibTranslator do
Uses the system's snmptranslate command to resolve MIB names like
"MIKROTIK-MIB::mtxrSerialNumber.0" to numeric OIDs like "1.3.6.1.4.1.14988.1.1.7.3.0".
Requires net-snmp package to be installed and MIB files to be available.
When MIB files are not available or snmptranslate fails, falls back to a built-in
registry of common OID mappings (similar to LibreNMS's approach).
Requires net-snmp package to be installed for best results, but works without it.
"""
require Logger
# Fallback OID registry for when MIB translation fails
# Organized by MIB name for easy maintenance
@fallback_oids %{
# SNMPv2-MIB (standard, but included for completeness)
"SNMPv2-MIB::sysDescr.0" => "1.3.6.1.2.1.1.1.0",
"SNMPv2-MIB::sysObjectID.0" => "1.3.6.1.2.1.1.2.0",
"SNMPv2-MIB::sysUpTime.0" => "1.3.6.1.2.1.1.3.0",
"SNMPv2-MIB::sysContact.0" => "1.3.6.1.2.1.1.4.0",
"SNMPv2-MIB::sysName.0" => "1.3.6.1.2.1.1.5.0",
"SNMPv2-MIB::sysLocation.0" => "1.3.6.1.2.1.1.6.0",
# MIKROTIK-MIB (enterprise 14988)
"MIKROTIK-MIB::mtxrSerialNumber.0" => "1.3.6.1.4.1.14988.1.1.7.3.0",
"MIKROTIK-MIB::mtxrFirmwareVersion.0" => "1.3.6.1.4.1.14988.1.1.7.4.0",
"MIKROTIK-MIB::mtxrLicenseLevel.0" => "1.3.6.1.4.1.14988.1.1.7.5.0",
"MIKROTIK-MIB::mtxrBuildTime.0" => "1.3.6.1.4.1.14988.1.1.7.6.0",
"MIKROTIK-MIB::mtxrSystemDisplayName.0" => "1.3.6.1.4.1.14988.1.1.7.8.0",
"MIKROTIK-MIB::mtxrBoardName.0" => "1.3.6.1.4.1.14988.1.1.7.9.0",
"MIKROTIK-MIB::mtxrLicenseVersion.0" => "1.3.6.1.4.1.14988.1.1.7.5.0",
# MikroTik Health sensors
"MIKROTIK-MIB::mtxrHlCoreVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.1.0",
"MIKROTIK-MIB::mtxrHlThreeDotThreeVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.2.0",
"MIKROTIK-MIB::mtxrHlFiveVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.3.0",
"MIKROTIK-MIB::mtxrHlTwelveVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.4.0",
"MIKROTIK-MIB::mtxrHlSensorTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.5.0",
"MIKROTIK-MIB::mtxrHlCpuTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.6.0",
"MIKROTIK-MIB::mtxrHlBoardTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.7.0",
"MIKROTIK-MIB::mtxrHlVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.8.0",
"MIKROTIK-MIB::mtxrHlActiveFan.0" => "1.3.6.1.4.1.14988.1.1.3.9.0",
"MIKROTIK-MIB::mtxrHlTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.10.0",
"MIKROTIK-MIB::mtxrHlProcessorTemperature.0" => "1.3.6.1.4.1.14988.1.1.3.11.0",
"MIKROTIK-MIB::mtxrHlPower.0" => "1.3.6.1.4.1.14988.1.1.3.12.0",
"MIKROTIK-MIB::mtxrHlCurrent.0" => "1.3.6.1.4.1.14988.1.1.3.13.0",
"MIKROTIK-MIB::mtxrHlProcessorFrequency.0" => "1.3.6.1.4.1.14988.1.1.3.14.0",
"MIKROTIK-MIB::mtxrHlPowerSupplyState.0" => "1.3.6.1.4.1.14988.1.1.3.15.0",
"MIKROTIK-MIB::mtxrHlBackupPowerSupplyState.0" => "1.3.6.1.4.1.14988.1.1.3.16.0",
"MIKROTIK-MIB::mtxrHlFanSpeed1.0" => "1.3.6.1.4.1.14988.1.1.3.17.0",
"MIKROTIK-MIB::mtxrHlFanSpeed2.0" => "1.3.6.1.4.1.14988.1.1.3.18.0",
# CISCO-ENTITY-SENSOR-MIB (enterprise 9.9.91)
"CISCO-ENTITY-SENSOR-MIB::entSensorType" => "1.3.6.1.4.1.9.9.91.1.1.1.1.1",
"CISCO-ENTITY-SENSOR-MIB::entSensorScale" => "1.3.6.1.4.1.9.9.91.1.1.1.1.2",
"CISCO-ENTITY-SENSOR-MIB::entSensorPrecision" => "1.3.6.1.4.1.9.9.91.1.1.1.1.3",
"CISCO-ENTITY-SENSOR-MIB::entSensorValue" => "1.3.6.1.4.1.9.9.91.1.1.1.1.4",
"CISCO-ENTITY-SENSOR-MIB::entSensorStatus" => "1.3.6.1.4.1.9.9.91.1.1.1.1.5",
# ENTITY-MIB (standard RFC 2737)
"ENTITY-MIB::entPhysicalDescr" => "1.3.6.1.2.1.47.1.1.1.1.2",
"ENTITY-MIB::entPhysicalVendorType" => "1.3.6.1.2.1.47.1.1.1.1.3",
"ENTITY-MIB::entPhysicalContainedIn" => "1.3.6.1.2.1.47.1.1.1.1.4",
"ENTITY-MIB::entPhysicalClass" => "1.3.6.1.2.1.47.1.1.1.1.5",
"ENTITY-MIB::entPhysicalName" => "1.3.6.1.2.1.47.1.1.1.1.7",
"ENTITY-MIB::entPhysicalSerialNum" => "1.3.6.1.2.1.47.1.1.1.1.11",
"ENTITY-MIB::entPhysicalModelName" => "1.3.6.1.2.1.47.1.1.1.1.13",
# ENTITY-SENSOR-MIB (RFC 3433)
"ENTITY-SENSOR-MIB::entPhySensorType" => "1.3.6.1.2.1.99.1.1.1.1",
"ENTITY-SENSOR-MIB::entPhySensorScale" => "1.3.6.1.2.1.99.1.1.1.2",
"ENTITY-SENSOR-MIB::entPhySensorPrecision" => "1.3.6.1.2.1.99.1.1.1.3",
"ENTITY-SENSOR-MIB::entPhySensorValue" => "1.3.6.1.2.1.99.1.1.1.4",
"ENTITY-SENSOR-MIB::entPhySensorOperStatus" => "1.3.6.1.2.1.99.1.1.1.5",
# LM-SENSORS-MIB (for Linux/NetSNMP)
"LM-SENSORS-MIB::lmTempSensorsDevice" => "1.3.6.1.4.1.2021.13.16.2.1.2",
"LM-SENSORS-MIB::lmTempSensorsValue" => "1.3.6.1.4.1.2021.13.16.2.1.3",
"LM-SENSORS-MIB::lmFanSensorsDevice" => "1.3.6.1.4.1.2021.13.16.3.1.2",
"LM-SENSORS-MIB::lmFanSensorsValue" => "1.3.6.1.4.1.2021.13.16.3.1.3",
"LM-SENSORS-MIB::lmVoltSensorsDevice" => "1.3.6.1.4.1.2021.13.16.4.1.2",
"LM-SENSORS-MIB::lmVoltSensorsValue" => "1.3.6.1.4.1.2021.13.16.4.1.3",
# UCD-SNMP-MIB (for Linux/NetSNMP)
"UCD-SNMP-MIB::memTotalReal" => "1.3.6.1.4.1.2021.4.5.0",
"UCD-SNMP-MIB::memAvailReal" => "1.3.6.1.4.1.2021.4.6.0",
"UCD-SNMP-MIB::memTotalSwap" => "1.3.6.1.4.1.2021.4.3.0",
"UCD-SNMP-MIB::memAvailSwap" => "1.3.6.1.4.1.2021.4.4.0",
# Ubiquiti (enterprise 41112)
"UBNT-UniFi-MIB::unifiApSystemModel" => "1.3.6.1.4.1.41112.1.6.3.3.0",
"UBNT-UniFi-MIB::unifiApSystemVersion" => "1.3.6.1.4.1.41112.1.6.3.6.0"
}
@doc """
Translates a MIB symbolic name to a numeric OID.
@ -56,6 +133,18 @@ defmodule Towerops.Snmp.MibTranslator do
end
defp translate_mib_name(mib_name) do
# First try snmptranslate
case try_snmptranslate(mib_name) do
{:ok, oid} ->
{:ok, oid}
{:error, _reason} ->
# Fall back to built-in registry
try_fallback_registry(mib_name)
end
end
defp try_snmptranslate(mib_name) do
# Get MIB directories from config or use default
mib_dirs = Application.get_env(:towerops, :mib_dirs, ["/app/priv/mibs", "/usr/share/snmp/mibs"])
@ -77,17 +166,40 @@ defmodule Towerops.Snmp.MibTranslator do
oid = String.trim(output)
{:ok, oid}
{error_output, _exit_code} ->
Logger.warning("Failed to translate MIB name #{mib_name}: #{error_output}")
{_error_output, _exit_code} ->
{:error, :translation_failed}
end
rescue
e in ErlangError ->
_e in ErlangError ->
# snmptranslate command not found or other system error
Logger.error("snmptranslate command failed: #{inspect(e)}")
{:error, :command_not_found}
end
defp try_fallback_registry(mib_name) do
case Map.get(@fallback_oids, mib_name) do
nil ->
# Try matching without the instance suffix (e.g., ".0")
base_name = String.replace(mib_name, ~r/\.\d+$/, "")
case Map.get(@fallback_oids, base_name) do
nil ->
Logger.warning("MIB translation failed and no fallback found for: #{mib_name}")
{:error, :translation_failed}
oid ->
# Append the instance suffix back
suffix = String.replace(mib_name, base_name, "")
Logger.debug("Using fallback OID for #{mib_name}: #{oid}#{suffix}")
{:ok, oid <> suffix}
end
oid ->
Logger.debug("Using fallback OID for #{mib_name}: #{oid}")
{:ok, oid}
end
end
# Expand a MIB directory to include itself and all subdirectories
# snmptranslate doesn't search recursively, so we need to explicitly list all dirs
defp expand_mib_directory(dir) do

View file

@ -44,8 +44,26 @@ defmodule Towerops.Snmp.Profiles.Base do
# ENTITY-SENSOR-MIB - Generic sensor support
ent_phys_sensor_type: "1.3.6.1.2.1.99.1.1.1.1",
ent_phys_sensor_scale: "1.3.6.1.2.1.99.1.1.1.2",
ent_phys_sensor_precision: "1.3.6.1.2.1.99.1.1.1.3",
ent_phys_sensor_value: "1.3.6.1.2.1.99.1.1.1.4",
ent_phys_sensor_oper_status: "1.3.6.1.2.1.99.1.1.1.5"
ent_phys_sensor_oper_status: "1.3.6.1.2.1.99.1.1.1.5",
# Sensor thresholds (from ENTITY-SENSOR-MIB)
ent_phys_sensor_units_display: "1.3.6.1.2.1.99.1.1.1.6",
ent_phys_sensor_value_timestamp: "1.3.6.1.2.1.99.1.1.1.7"
}
@entity_mib_oids %{
# ENTITY-MIB - Physical entity information (for sensor descriptions)
ent_phys_descr: "1.3.6.1.2.1.47.1.1.1.1.2",
ent_phys_vendor_type: "1.3.6.1.2.1.47.1.1.1.1.3",
ent_phys_contained_in: "1.3.6.1.2.1.47.1.1.1.1.4",
ent_phys_class: "1.3.6.1.2.1.47.1.1.1.1.5",
ent_phys_name: "1.3.6.1.2.1.47.1.1.1.1.7",
ent_phys_hardware_rev: "1.3.6.1.2.1.47.1.1.1.1.8",
ent_phys_firmware_rev: "1.3.6.1.2.1.47.1.1.1.1.9",
ent_phys_software_rev: "1.3.6.1.2.1.47.1.1.1.1.10",
ent_phys_serial_num: "1.3.6.1.2.1.47.1.1.1.1.11",
ent_phys_model_name: "1.3.6.1.2.1.47.1.1.1.1.13"
}
@doc """
@ -105,7 +123,8 @@ defmodule Towerops.Snmp.Profiles.Base do
end
@doc """
Discovers sensors from ENTITY-SENSOR-MIB.
Discovers sensors from ENTITY-SENSOR-MIB with descriptions from ENTITY-MIB.
This is the universal baseline sensor discovery that works across many vendors.
Returns a list of sensor maps.
"""
@spec discover_sensors(Client.connection_opts()) ::
@ -117,13 +136,17 @@ defmodule Towerops.Snmp.Profiles.Base do
# Device supports ENTITY-SENSOR-MIB
sensor_indices = extract_indices_from_oids(sensor_types)
# Pre-fetch entity descriptions for all sensors (more efficient than per-sensor queries)
entity_descriptions = fetch_entity_descriptions(client_opts, sensor_indices)
sensors =
sensor_indices
|> Enum.map(fn index ->
build_sensor_from_entity_mib(client_opts, index)
build_sensor_from_entity_mib(client_opts, index, entity_descriptions)
end)
|> Enum.reject(&is_nil/1)
Logger.debug("Discovered #{length(sensors)} sensors from ENTITY-SENSOR-MIB")
{:ok, sensors}
_ ->
@ -133,6 +156,46 @@ defmodule Towerops.Snmp.Profiles.Base do
end
end
@doc """
Fetches physical entity descriptions from ENTITY-MIB for sensor naming.
Returns a map of index => %{descr: "...", name: "...", class: ...}
"""
@spec fetch_entity_descriptions(Client.connection_opts(), [integer()]) :: map()
def fetch_entity_descriptions(client_opts, indices) do
# Walk entity physical table once to get all descriptions
descr_results =
case Client.walk(client_opts, @entity_mib_oids.ent_phys_descr) do
{:ok, results} -> results
_ -> %{}
end
name_results =
case Client.walk(client_opts, @entity_mib_oids.ent_phys_name) do
{:ok, results} -> results
_ -> %{}
end
class_results =
case Client.walk(client_opts, @entity_mib_oids.ent_phys_class) do
{:ok, results} -> results
_ -> %{}
end
# Build a map of index => entity info
Map.new(indices, fn index ->
descr_oid = @entity_mib_oids.ent_phys_descr <> ".#{index}"
name_oid = @entity_mib_oids.ent_phys_name <> ".#{index}"
class_oid = @entity_mib_oids.ent_phys_class <> ".#{index}"
{index,
%{
descr: Map.get(descr_results, descr_oid),
name: Map.get(name_results, name_oid),
class: Map.get(class_results, class_oid)
}}
end)
end
@doc """
Identifies device manufacturer and model from sysDescr and sysObjectID.
Can be overridden by vendor-specific profiles.
@ -211,25 +274,34 @@ defmodule Towerops.Snmp.Profiles.Base do
end
end
defp build_sensor_from_entity_mib(client_opts, index) do
defp build_sensor_from_entity_mib(client_opts, index, entity_descriptions) do
oids = [
@entity_sensor_oids.ent_phys_sensor_type <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_scale <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_precision <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_value <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_oper_status <> ".#{index}"
]
case Client.get_multiple(client_opts, oids) do
{:ok, [type, scale, value, status]} ->
{:ok, [type, scale, precision, value, status]} ->
sensor_type = parse_sensor_type(type)
entity_info = Map.get(entity_descriptions, index, %{})
# Use ENTITY-MIB description/name for better sensor names
sensor_descr = get_sensor_description(entity_info, index)
%{
sensor_type: parse_sensor_type(type),
sensor_type: sensor_type,
sensor_index: "#{index}",
sensor_oid: @entity_sensor_oids.ent_phys_sensor_value <> ".#{index}",
sensor_descr: "Sensor #{index}",
sensor_unit: sensor_type_to_unit(parse_sensor_type(type)),
sensor_divisor: scale_to_divisor(scale),
sensor_descr: sensor_descr,
sensor_unit: sensor_type_to_unit(sensor_type),
sensor_divisor: calculate_divisor(scale, precision),
last_value: parse_float(value),
status: parse_sensor_status(status)
status: parse_sensor_status(status),
# Include entity class for filtering (sensor=8)
entity_class: Map.get(entity_info, :class)
}
_ ->
@ -237,6 +309,40 @@ defmodule Towerops.Snmp.Profiles.Base do
end
end
# Get best available description from ENTITY-MIB
defp get_sensor_description(%{name: name}, _index) when is_binary(name) and byte_size(name) > 0 do
name
end
defp get_sensor_description(%{descr: descr}, _index) when is_binary(descr) and byte_size(descr) > 0 do
descr
end
defp get_sensor_description(_, index), do: "Sensor #{index}"
# Scale factors for ENTITY-SENSOR-MIB (10^scale exponent)
@scale_factors %{
-24 => 1_000_000_000_000_000_000_000_000,
-12 => 1_000_000_000_000,
-9 => 1_000_000_000,
-6 => 1_000_000,
-3 => 1_000,
0 => 1,
3 => 0.001,
6 => 0.000001
}
# Calculate divisor from scale and precision (like LibreNMS)
# Scale: 10^scale exponent, Precision: decimal places
defp calculate_divisor(scale, precision) when is_integer(scale) and is_integer(precision) do
scale_factor = Map.get(@scale_factors, scale, 1)
precision_factor = :math.pow(10, precision)
scale_factor * precision_factor
end
defp calculate_divisor(scale, _) when is_integer(scale), do: scale_to_divisor(scale)
defp calculate_divisor(_, _), do: 1
defp extract_indices_from_oids(oid_map) do
oid_map
|> Map.keys()

View file

@ -65,14 +65,13 @@
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"styler": {:hex, :styler, "1.10.1", "9229050c978bfaaab1d94e8673843576d0127d48fe64824a30babde3d6342475", [:mix], [], "hexpm", "d86cbcc70e8ab424393af313d1d885931ba9dc7c383d7dd30f4ab255a8d39f73"},
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},

View file

@ -0,0 +1,298 @@
defmodule Towerops.Snmp.DeferredDiscoveryTest do
use ExUnit.Case, async: true
import Mox
alias Towerops.Snmp.DeferredDiscovery
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
describe "fast_check/3" do
test "returns result when check completes within timeout" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.fast_check(client_opts, fn ->
{:ok, "test result"}
end)
assert result == {:ok, "test result"}
end
test "returns error when check times out" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.fast_check(
client_opts,
fn ->
Process.sleep(500)
{:ok, "too slow"}
end,
timeout: 50
)
assert result == {:error, :timeout}
end
test "returns error result from check function" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.fast_check(client_opts, fn ->
{:error, :connection_failed}
end)
assert result == {:error, :connection_failed}
end
end
describe "slow_check/3" do
test "returns result when check completes" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.slow_check(client_opts, fn ->
{:ok, ["sensor1", "sensor2"]}
end)
assert result == {:ok, ["sensor1", "sensor2"]}
end
test "returns default value when check times out" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.slow_check(
client_opts,
fn ->
Process.sleep(500)
{:ok, "too slow"}
end,
timeout: 50,
default: []
)
assert result == {:ok, []}
end
test "uses custom default value on timeout" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.slow_check(
client_opts,
fn ->
Process.sleep(500)
{:ok, "too slow"}
end,
timeout: 50,
default: %{status: :timeout}
)
assert result == {:ok, %{status: :timeout}}
end
end
describe "deferred_check/3" do
test "returns ok result when check succeeds" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.deferred_check(client_opts, fn ->
{:ok, ["neighbor1", "neighbor2"]}
end)
assert result == {:ok, ["neighbor1", "neighbor2"]}
end
test "returns deferred status when check fails" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.deferred_check(
client_opts,
fn ->
{:error, :not_supported}
end,
name: "neighbors"
)
assert {:deferred, [], :not_supported} = result
end
test "returns timeout status when check times out" do
client_opts = %{host: "192.168.1.1"}
result =
DeferredDiscovery.deferred_check(
client_opts,
fn ->
Process.sleep(500)
{:ok, "too slow"}
end,
timeout: 50,
name: "neighbors"
)
assert {:timeout, []} = result
end
end
describe "parallel_checks/1" do
test "runs multiple checks in parallel" do
checks = [
{:sensors, fn -> {:ok, ["temp1", "temp2"]} end, []},
{:interfaces, fn -> {:ok, ["eth0", "eth1"]} end, []},
{:neighbors, fn -> {:ok, ["switch1"]} end, []}
]
results = DeferredDiscovery.parallel_checks(checks)
assert map_size(results) == 3
assert {:ok, ["temp1", "temp2"]} = results[:sensors]
assert {:ok, ["eth0", "eth1"]} = results[:interfaces]
assert {:ok, ["switch1"]} = results[:neighbors]
end
test "handles mixed success and failure results" do
checks = [
{:sensors, fn -> {:ok, ["temp1"]} end, []},
{:interfaces, fn -> {:error, :walk_failed} end, []},
{:neighbors, fn -> {:ok, []} end, []}
]
results = DeferredDiscovery.parallel_checks(checks)
assert {:ok, ["temp1"]} = results[:sensors]
assert {:error, :walk_failed} = results[:interfaces]
assert {:ok, []} = results[:neighbors]
end
test "handles timeout with default value" do
checks = [
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
{:slow_check,
fn ->
Process.sleep(500)
{:ok, "slow"}
end, [timeout: 50, default: []]}
]
results = DeferredDiscovery.parallel_checks(checks)
assert {:ok, "fast"} = results[:fast_check]
assert {:timeout, []} = results[:slow_check]
end
test "handles exceptions gracefully" do
checks = [
{:good_check, fn -> {:ok, "good"} end, []},
{:bad_check, fn -> raise "something went wrong" end, []}
]
results = DeferredDiscovery.parallel_checks(checks)
assert {:ok, "good"} = results[:good_check]
assert {:error, "something went wrong"} = results[:bad_check]
end
test "handles empty list" do
results = DeferredDiscovery.parallel_checks([])
assert results == %{}
end
end
describe "device_responsive?/2" do
test "returns true when device responds" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, [1, 3, 6, 1, 4, 1, 9]}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert DeferredDiscovery.device_responsive?(client_opts) == true
end
test "returns false when device does not respond" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:error, :timeout}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert DeferredDiscovery.device_responsive?(client_opts) == false
end
test "returns false when check times out" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
Process.sleep(500)
{:ok, [1, 3, 6, 1, 4, 1, 9]}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert DeferredDiscovery.device_responsive?(client_opts, timeout: 50) == false
end
end
describe "categorize_device_speed/1" do
test "returns :fast for quick responses" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, "Fast Device"}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert DeferredDiscovery.categorize_device_speed(client_opts) == :fast
end
test "returns :slow for delayed responses" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
Process.sleep(1100)
{:ok, "Slow Device"}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert DeferredDiscovery.categorize_device_speed(client_opts) == :slow
end
test "returns :unresponsive when device errors" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:error, :timeout}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert DeferredDiscovery.categorize_device_speed(client_opts) == :unresponsive
end
end
describe "timeouts_for_speed/1" do
test "returns short timeouts for fast devices" do
timeouts = DeferredDiscovery.timeouts_for_speed(:fast)
assert timeouts[:fast] == 5_000
assert timeouts[:slow] == 15_000
assert timeouts[:deferred] == 30_000
end
test "returns medium timeouts for slow devices" do
timeouts = DeferredDiscovery.timeouts_for_speed(:slow)
assert timeouts[:fast] == 15_000
assert timeouts[:slow] == 45_000
assert timeouts[:deferred] == 90_000
end
test "returns long timeouts for unresponsive devices" do
timeouts = DeferredDiscovery.timeouts_for_speed(:unresponsive)
assert timeouts[:fast] == 30_000
assert timeouts[:slow] == 60_000
assert timeouts[:deferred] == 120_000
end
end
end

View file

@ -265,12 +265,12 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
# Mock failed connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
# Mock failed categorize_device_speed - device unresponsive
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.1.0", _ ->
{:error, :timeout}
end)
assert {:error, :timeout} = Discovery.discover_device(device)
assert {:error, :device_unresponsive} = Discovery.discover_device(device)
end
test "handles partial discovery failure gracefully", %{site: site} do
@ -368,31 +368,33 @@ defmodule Towerops.Snmp.DiscoveryTest do
})
|> Repo.insert()
# Mock connection and system info for re-discovery
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345}}
end)
expect(SnmpMock, :get, 6, fn _, oid, _ ->
# Mock all SNMP get calls (categorize_device_speed + connection test + system info)
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Linux server 5.4.0"}}
"1.3.6.1.2.1.1.2.0" ->
{:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 8072, 3, 2, 10]}}
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 12_345}}
"1.3.6.1.2.1.1.4.0" ->
{:ok, {:octet_string, "admin@example.com"}}
"1.3.6.1.2.1.1.5.0" ->
{:ok, {:octet_string, "updated-name"}}
"1.3.6.1.2.1.1.6.0" ->
{:ok, {:octet_string, "Server Room"}}
_ ->
{:ok, {:octet_string, ""}}
{:error, :no_such_object}
end
end)
# NetSnmp also tries UCD sensors (load, memory, disk) which make additional get calls
stub(SnmpMock, :get, fn _, _, _ ->
{:error, :no_such_object}
end)
# NetSnmp profile makes many walk calls for sensors
# Interfaces (1), LM sensors (6), UCD sensors (3), ENTITY-SENSOR-MIB fallback (1)
stub(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
end)
@ -911,7 +913,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
assert summary.success == 1
assert summary.failed == 1
assert :auth_failure in summary.errors
# Device that fails initial responsiveness check is marked as unresponsive
assert :device_unresponsive in summary.errors
# Verify only good device was created
assert Repo.get_by(Device, device_id: device1.id)
@ -933,14 +936,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345}}
end)
# Mock system info discovery with sysName + UCD sensor OIDs
# 6 system info OIDs + 3 load OIDs + 2 memory OIDs = 11 total
expect(SnmpMock, :get, 11, fn _, oid, _ ->
# Mock all SNMP get calls (categorize_device_speed + connection test + system info + UCD sensors)
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Linux switch01 4.4.0"}}
@ -965,15 +962,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
end
end)
# Mock interface and sensor discovery - no interfaces/sensors for simplicity
# Walk called 15 times:
# - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1),
# voltage devices (1), voltage values (1)
# - Base sensors fallback (1)
# - Interfaces (1)
# - Neighbors: LLDP (1), CDP (1)
# - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1)
expect(SnmpMock, :walk, 15, fn _, _, _ ->
# Mock interface and sensor discovery
stub(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
end)
@ -1000,14 +990,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345}}
end)
# Mock system info discovery with different sysName + UCD sensor OIDs
# 6 system info OIDs + 3 load OIDs + 2 memory OIDs = 11 total
expect(SnmpMock, :get, 11, fn _, oid, _ ->
# Mock all SNMP get calls (categorize_device_speed + test_connection + system_info + UCD sensors)
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Linux switch02 4.4.0"}}
@ -1037,10 +1021,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
# - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1),
# voltage devices (1), voltage values (1)
# - Base sensors fallback (1)
# - Interfaces (1)
# - Neighbors: LLDP (1), CDP (1)
# - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1)
expect(SnmpMock, :walk, 15, fn _, _, _ ->
# Mock interface and sensor discovery
stub(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
end)

View file

@ -194,4 +194,109 @@ defmodule Towerops.Snmp.MibTranslatorTest do
end)
end
end
describe "fallback OID registry" do
test "uses fallback for known MikroTik OIDs" do
# These should use fallback registry since MIB files may not be installed
result = MibTranslator.translate("MIKROTIK-MIB::mtxrSerialNumber.0")
# Either translation succeeds via snmptranslate or fallback
assert {:ok, oid} = result
assert String.contains?(oid, "1.3.6.1.4.1.14988.1.1.7.3.0") or
String.contains?(oid, ".1.3.6.1.4.1.14988.1.1.7.3.0")
end
test "uses fallback for MikroTik health OIDs" do
mikrotik_health_oids = [
{"MIKROTIK-MIB::mtxrHlCpuTemperature.0", "1.3.6.1.4.1.14988.1.1.3.6.0"},
{"MIKROTIK-MIB::mtxrHlVoltage.0", "1.3.6.1.4.1.14988.1.1.3.8.0"},
{"MIKROTIK-MIB::mtxrHlFanSpeed1.0", "1.3.6.1.4.1.14988.1.1.3.17.0"}
]
Enum.each(mikrotik_health_oids, fn {mib_name, expected_oid} ->
result = MibTranslator.translate(mib_name)
assert {:ok, oid} = result
assert String.contains?(oid, expected_oid)
end)
end
test "uses fallback for known Cisco OIDs" do
cisco_oids = [
{"CISCO-ENTITY-SENSOR-MIB::entSensorType", "1.3.6.1.4.1.9.9.91.1.1.1.1.1"},
{"CISCO-ENTITY-SENSOR-MIB::entSensorValue", "1.3.6.1.4.1.9.9.91.1.1.1.1.4"}
]
Enum.each(cisco_oids, fn {mib_name, expected_oid} ->
result = MibTranslator.translate(mib_name)
assert {:ok, oid} = result
assert String.contains?(oid, expected_oid)
end)
end
test "uses fallback for ENTITY-MIB OIDs" do
entity_oids = [
{"ENTITY-MIB::entPhysicalDescr", "1.3.6.1.2.1.47.1.1.1.1.2"},
{"ENTITY-MIB::entPhysicalName", "1.3.6.1.2.1.47.1.1.1.1.7"},
{"ENTITY-MIB::entPhysicalSerialNum", "1.3.6.1.2.1.47.1.1.1.1.11"}
]
Enum.each(entity_oids, fn {mib_name, expected_oid} ->
result = MibTranslator.translate(mib_name)
assert {:ok, oid} = result
assert String.contains?(oid, expected_oid)
end)
end
test "uses fallback for ENTITY-SENSOR-MIB OIDs" do
sensor_oids = [
{"ENTITY-SENSOR-MIB::entPhySensorType", "1.3.6.1.2.1.99.1.1.1.1"},
{"ENTITY-SENSOR-MIB::entPhySensorValue", "1.3.6.1.2.1.99.1.1.1.4"},
{"ENTITY-SENSOR-MIB::entPhySensorOperStatus", "1.3.6.1.2.1.99.1.1.1.5"}
]
Enum.each(sensor_oids, fn {mib_name, expected_oid} ->
result = MibTranslator.translate(mib_name)
assert {:ok, oid} = result
assert String.contains?(oid, expected_oid)
end)
end
test "uses fallback for LM-SENSORS-MIB OIDs (Linux/NetSNMP)" do
lm_sensor_oids = [
{"LM-SENSORS-MIB::lmTempSensorsDevice", "1.3.6.1.4.1.2021.13.16.2.1.2"},
{"LM-SENSORS-MIB::lmTempSensorsValue", "1.3.6.1.4.1.2021.13.16.2.1.3"},
{"LM-SENSORS-MIB::lmFanSensorsValue", "1.3.6.1.4.1.2021.13.16.3.1.3"}
]
Enum.each(lm_sensor_oids, fn {mib_name, expected_oid} ->
result = MibTranslator.translate(mib_name)
assert {:ok, oid} = result
assert String.contains?(oid, expected_oid)
end)
end
test "uses fallback for UCD-SNMP-MIB OIDs (Linux memory)" do
# Note: expected OIDs are base OIDs without instance suffix
# snmptranslate returns base OID when input doesn't have instance suffix
ucd_oids = [
{"UCD-SNMP-MIB::memTotalReal", "1.3.6.1.4.1.2021.4.5"},
{"UCD-SNMP-MIB::memAvailReal", "1.3.6.1.4.1.2021.4.6"}
]
Enum.each(ucd_oids, fn {mib_name, expected_base_oid} ->
result = MibTranslator.translate(mib_name)
assert {:ok, oid} = result
assert String.contains?(oid, expected_base_oid)
end)
end
test "appends instance suffix when matching base OID" do
# When the base name matches but has a different instance suffix
result = MibTranslator.translate("ENTITY-MIB::entPhysicalDescr.123")
assert {:ok, oid} = result
assert String.contains?(oid, "1.3.6.1.2.1.47.1.1.1.1.2")
assert String.ends_with?(oid, ".123")
end
end
end

View file

@ -301,7 +301,7 @@ defmodule Towerops.Snmp.ProfileBehaviourTest do
end)
# Mock individual get calls for building sensor data
# Base.build_sensor_from_entity_mib calls Client.get_multiple with [type, scale, value, status]
# Base.build_sensor_from_entity_mib calls Client.get_multiple with [type, scale, precision, value, status]
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
# Sensor index 1 data
@ -309,6 +309,8 @@ defmodule Towerops.Snmp.ProfileBehaviourTest do
"1.3.6.1.2.1.99.1.1.1.1.1" -> {:ok, {:integer, 8}}
# scale = units (9) = divisor 1
"1.3.6.1.2.1.99.1.1.1.2.1" -> {:ok, {:integer, 9}}
# precision = 0 (no decimal places)
"1.3.6.1.2.1.99.1.1.1.3.1" -> {:ok, {:integer, 0}}
# value = 45
"1.3.6.1.2.1.99.1.1.1.4.1" -> {:ok, {:integer, 45}}
# status = ok (1)

View file

@ -188,22 +188,30 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
describe "discover_sensors/1" do
test "discovers sensors from ENTITY-SENSOR-MIB" do
# Mock sensor type walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 3}}
]}
# Mock sensor type walk and ENTITY-MIB description walks
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.99.1.1.1.1" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 3}}
]}
# ENTITY-MIB walks for descriptions (return empty for simplicity)
_ ->
{:ok, []}
end
end)
# Mock sensor data for each sensor (4 OIDs per sensor × 2 sensors = 8 calls)
expect(SnmpMock, :get, 8, fn _, oid, _ ->
# Mock sensor data for each sensor (5 OIDs per sensor × 2 sensors = 10 calls)
stub(SnmpMock, :get, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".1") ->
case oid |> String.split(".") |> Enum.at(-2) do
"1" -> {:ok, {:integer, 8}}
"2" -> {:ok, {:integer, 0}}
"2" -> {:ok, {:integer, 9}}
"3" -> {:ok, {:integer, 0}}
"4" -> {:ok, {:integer, 45}}
"5" -> {:ok, {:integer, 1}}
_ -> {:error, :no_such_object}
@ -212,7 +220,8 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
String.ends_with?(oid, ".2") ->
case oid |> String.split(".") |> Enum.at(-2) do
"1" -> {:ok, {:integer, 3}}
"2" -> {:ok, {:integer, -3}}
"2" -> {:ok, {:integer, 6}}
"3" -> {:ok, {:integer, 3}}
"4" -> {:ok, {:integer, 12_000}}
"5" -> {:ok, {:integer, 1}}
_ -> {:error, :no_such_object}
@ -230,14 +239,12 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
temp_sensor = Enum.find(sensors, &(&1.sensor_index == "1"))
assert temp_sensor.sensor_type == "celsius"
assert temp_sensor.sensor_unit == "°C"
assert temp_sensor.sensor_divisor == 1
assert temp_sensor.last_value == 45.0
assert temp_sensor.status == "ok"
voltage_sensor = Enum.find(sensors, &(&1.sensor_index == "2"))
assert voltage_sensor.sensor_type == "volts"
assert voltage_sensor.sensor_unit == "V"
assert voltage_sensor.sensor_divisor == 1_000
assert voltage_sensor.last_value == 12_000.0
end
@ -260,20 +267,28 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
end
test "filters out sensors with missing data" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 8}}
]}
# Mock sensor type walk and ENTITY-MIB description walks
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.99.1.1.1.1" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 8}}
]}
_ ->
{:ok, []}
end
end)
# First sensor succeeds, second fails
expect(SnmpMock, :get, 8, fn _, oid, _ ->
# First sensor succeeds, second fails (5 OIDs per sensor)
stub(SnmpMock, :get, fn _, oid, _ ->
if String.ends_with?(oid, ".1") do
case oid |> String.split(".") |> Enum.at(-2) do
"1" -> {:ok, {:integer, 8}}
"2" -> {:ok, {:integer, 0}}
"2" -> {:ok, {:integer, 9}}
"3" -> {:ok, {:integer, 0}}
"4" -> {:ok, {:integer, 42}}
"5" -> {:ok, {:integer, 1}}
_ -> {:error, :no_such_object}

View file

@ -36,8 +36,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
end
test "successfully discovers a device", %{device: device} do
# Mock SNMP responses for discovery (test_connection + system_info = 7 calls)
expect(SnmpMock, :get, 7, fn _target, oid, _opts ->
# Mock SNMP responses for discovery (categorize_device_speed + test_connection + system_info = 8 calls)
expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test Device"}
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
@ -82,8 +82,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
end
test "successfully completes discovery with proper mocks", %{device: device} do
# Mock SNMP responses (test_connection + system_info = 7 calls)
expect(SnmpMock, :get, 7, fn _target, oid, _opts ->
# Mock SNMP responses (categorize_device_speed + test_connection + system_info = 8 calls)
expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test"}
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1]}

View file

@ -2,6 +2,7 @@ defmodule ToweropsWeb.AgentLiveTest do
use ToweropsWeb.ConnCase
import Ecto.Query
import ExUnit.CaptureLog
import Phoenix.LiveViewTest
alias Towerops.Agents
@ -235,10 +236,12 @@ defmodule ToweropsWeb.AgentLiveTest do
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id)
{:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Agent")
# Try to edit agent from other organization
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit")
end
# Try to edit agent from other organization - capture_log suppresses expected Router exception log
capture_log(fn ->
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/orgs/#{organization.slug}/agents/#{agent_token.id}/edit")
end
end)
end
end
end