From 7c660169b720e342d8125fe7b7bd96318b8d9fb7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 21 Jan 2026 10:07:07 -0600 Subject: [PATCH] better mib handling and tests --- lib/towerops/snmp/deferred_discovery.ex | 222 +++++++++++++ lib/towerops/snmp/discovery.ex | 108 +++++-- lib/towerops/snmp/mib_translator.ex | 122 ++++++- lib/towerops/snmp/profiles/base.ex | 126 +++++++- mix.lock | 3 +- .../towerops/snmp/deferred_discovery_test.exs | 298 ++++++++++++++++++ test/towerops/snmp/discovery_test.exs | 74 ++--- test/towerops/snmp/mib_translator_test.exs | 105 ++++++ test/towerops/snmp/profile_behaviour_test.exs | 4 +- test/towerops/snmp/profiles/base_test.exs | 59 ++-- .../workers/discovery_worker_test.exs | 8 +- test/towerops_web/live/agent_live_test.exs | 11 +- 12 files changed, 1015 insertions(+), 125 deletions(-) create mode 100644 lib/towerops/snmp/deferred_discovery.ex create mode 100644 test/towerops/snmp/deferred_discovery_test.exs diff --git a/lib/towerops/snmp/deferred_discovery.ex b/lib/towerops/snmp/deferred_discovery.ex new file mode 100644 index 00000000..99e28397 --- /dev/null +++ b/lib/towerops/snmp/deferred_discovery.ex @@ -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 diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 9b46d22b..29e85382 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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. diff --git a/lib/towerops/snmp/mib_translator.ex b/lib/towerops/snmp/mib_translator.ex index 59d20afb..3ba90d50 100644 --- a/lib/towerops/snmp/mib_translator.ex +++ b/lib/towerops/snmp/mib_translator.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index 626613ad..b6997cb1 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -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() diff --git a/mix.lock b/mix.lock index b4ea237b..f52186dd 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/towerops/snmp/deferred_discovery_test.exs b/test/towerops/snmp/deferred_discovery_test.exs new file mode 100644 index 00000000..d534c005 --- /dev/null +++ b/test/towerops/snmp/deferred_discovery_test.exs @@ -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 diff --git a/test/towerops/snmp/discovery_test.exs b/test/towerops/snmp/discovery_test.exs index f8f77d35..07f4e7df 100644 --- a/test/towerops/snmp/discovery_test.exs +++ b/test/towerops/snmp/discovery_test.exs @@ -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) diff --git a/test/towerops/snmp/mib_translator_test.exs b/test/towerops/snmp/mib_translator_test.exs index 872c5017..4c25ffc6 100644 --- a/test/towerops/snmp/mib_translator_test.exs +++ b/test/towerops/snmp/mib_translator_test.exs @@ -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 diff --git a/test/towerops/snmp/profile_behaviour_test.exs b/test/towerops/snmp/profile_behaviour_test.exs index eafde02b..108a302a 100644 --- a/test/towerops/snmp/profile_behaviour_test.exs +++ b/test/towerops/snmp/profile_behaviour_test.exs @@ -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) diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs index 7d5bb17e..93a97df6 100644 --- a/test/towerops/snmp/profiles/base_test.exs +++ b/test/towerops/snmp/profiles/base_test.exs @@ -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} diff --git a/test/towerops/workers/discovery_worker_test.exs b/test/towerops/workers/discovery_worker_test.exs index ea94d22a..0a79e17e 100644 --- a/test/towerops/workers/discovery_worker_test.exs +++ b/test/towerops/workers/discovery_worker_test.exs @@ -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]} diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs index 118325dd..73e65adf 100644 --- a/test/towerops_web/live/agent_live_test.exs +++ b/test/towerops_web/live/agent_live_test.exs @@ -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