sanitize snmp data and fix some cpu things

This commit is contained in:
Graham McIntire 2026-01-22 16:53:01 -06:00
parent 2856a0ccda
commit 96fc488023
No known key found for this signature in database
48 changed files with 2230 additions and 77 deletions

View file

@ -25,6 +25,6 @@ defmodule Towerops.Monitoring.Check do
check
|> cast(attrs, [:device_id, :status, :response_time_ms, :checked_at])
|> validate_required([:device_id, :status, :checked_at])
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:device_id, match: :suffix)
end
end

View file

@ -441,8 +441,17 @@ defmodule Towerops.Profiles.YamlProfiles do
default
descr when is_binary(descr) ->
# Remove template variables like {{ MIKROTIK-MIB::mtxrOpticalName }}
cleaned = descr |> String.replace(~r/\{\{ [^}]+ \}\}/, "") |> String.trim()
if cleaned == "", do: default, else: cleaned
# If the cleaned descr is a MIB symbolic name (contains "::"), use the default
# MIB names like "MIKROTIK-MIB::mtxrOpticalName" should be resolved to actual values
# by vendor modules, not used as literal descriptions
cond do
cleaned == "" -> default
String.contains?(cleaned, "::") -> default
true -> cleaned
end
_ ->
default

View file

@ -14,7 +14,10 @@ defmodule Towerops.Snmp do
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Processor
alias Towerops.Snmp.ProcessorReading
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.StateSensor
@ -96,7 +99,7 @@ defmodule Towerops.Snmp do
def get_device_with_associations(device_id) do
Device
|> where([d], d.device_id == ^device_id)
|> preload([:interfaces, :sensors, :state_sensors])
|> preload([:interfaces, :sensors, :state_sensors, :processors])
|> Repo.one()
end
@ -539,4 +542,95 @@ defmodule Towerops.Snmp do
def get_vlan(vlan_id) do
Repo.get(Vlan, vlan_id)
end
# IP Address queries
@doc """
Lists all IP addresses for a device (across all its interfaces).
"""
def list_ip_addresses(snmp_device_id) do
IpAddress
|> join(:inner, [ip], i in Interface, on: ip.snmp_interface_id == i.id)
|> where([ip, i], i.snmp_device_id == ^snmp_device_id)
|> preload(:snmp_interface)
|> order_by([ip], [ip.ip_type, ip.ip_address])
|> Repo.all()
end
@doc """
Lists IP addresses for a specific interface.
"""
def list_interface_ip_addresses(interface_id) do
IpAddress
|> where([ip], ip.snmp_interface_id == ^interface_id)
|> order_by([ip], [ip.ip_type, ip.ip_address])
|> Repo.all()
end
# Processor queries
@doc """
Lists all processors for a device.
"""
def list_processors(snmp_device_id) do
Processor
|> where([p], p.snmp_device_id == ^snmp_device_id)
|> order_by([p], p.processor_index)
|> Repo.all()
end
@doc """
Gets a specific processor.
"""
def get_processor(processor_id) do
Repo.get(Processor, processor_id)
end
@doc """
Updates a processor.
"""
def update_processor(processor, attrs) do
processor
|> Processor.changeset(attrs)
|> Repo.update()
end
# Processor reading queries
@doc """
Gets recent processor readings for a processor.
## Options
- `:limit` - Maximum number of readings to return (default: 100)
- `:since` - Only return readings after this datetime
"""
def get_processor_readings(processor_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
since = Keyword.get(opts, :since)
query =
ProcessorReading
|> where([r], r.processor_id == ^processor_id)
|> order_by([r], desc: r.checked_at)
|> limit(^limit)
query =
if since do
where(query, [r], r.checked_at >= ^since)
else
query
end
Repo.all(query)
end
@doc """
Records a new processor reading.
"""
def create_processor_reading(attrs) do
%ProcessorReading{}
|> ProcessorReading.changeset(attrs)
|> Repo.insert()
end
end

View file

@ -12,6 +12,7 @@ defmodule Towerops.Snmp.Device do
alias Ecto.Association.NotLoaded
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Processor
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.StateSensor
@ -34,6 +35,7 @@ defmodule Towerops.Snmp.Device do
belongs_to :device, DeviceSchema
has_many :interfaces, Interface, foreign_key: :snmp_device_id
has_many :processors, Processor, foreign_key: :snmp_device_id
has_many :sensors, Sensor, foreign_key: :snmp_device_id
has_many :state_sensors, StateSensor, foreign_key: :snmp_device_id
@ -57,6 +59,7 @@ defmodule Towerops.Snmp.Device do
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | DeviceSchema.t(),
interfaces: NotLoaded.t() | [Interface.t()],
processors: NotLoaded.t() | [Processor.t()],
sensors: NotLoaded.t() | [Sensor.t()],
state_sensors: NotLoaded.t() | [StateSensor.t()],
inserted_at: DateTime.t(),

View file

@ -22,7 +22,9 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Snmp.DeferredDiscovery
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.Processor
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Dynamic
alias Towerops.Snmp.Sensor
@ -127,7 +129,11 @@ defmodule Towerops.Snmp.Discovery do
{:ok, interfaces} <- discover_interfaces_with_timeout(client_opts, profile, timeouts),
{:ok, sensors} <- discover_sensors_with_timeout(client_opts, profile, timeouts),
{:ok, vlans} <- discover_vlans_with_timeout(client_opts, profile, timeouts),
{:ok, ip_addresses} <- discover_ip_addresses_with_timeout(client_opts, timeouts),
{:ok, processors} <- discover_processors_with_timeout(client_opts, timeouts),
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors, vlans),
:ok <- sync_ip_addresses(discovered_device, ip_addresses),
:ok <- sync_processors(discovered_device, processors),
{: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)
@ -196,6 +202,26 @@ defmodule Towerops.Snmp.Discovery do
)
end
# IP address discovery with timeout - falls back to empty list on timeout
defp discover_ip_addresses_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_all_ip_addresses(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
# Processor discovery with timeout - falls back to empty list on timeout
defp discover_processors_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_processors(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
@doc """
Runs discovery for all SNMP-enabled devices in an organization.
Returns a summary of successful and failed discoveries.
@ -614,6 +640,128 @@ defmodule Towerops.Snmp.Discovery do
end)
end
@spec sync_ip_addresses(Device.t(), [map()]) :: :ok
defp sync_ip_addresses(device, discovered_ip_addresses) do
# Build a map of if_index to interface ID from the device's interfaces
if_index_to_interface =
Map.new(device.interfaces, fn iface -> {iface.if_index, iface.id} end)
# Get existing IP addresses for all interfaces of this device
interface_ids = Map.values(if_index_to_interface)
existing_ip_addresses =
from(ip in IpAddress, where: ip.snmp_interface_id in ^interface_ids)
|> Repo.all()
|> Map.new(fn ip -> {{ip.snmp_interface_id, ip.ip_address}, ip} end)
# Track which IP addresses we've seen
discovered_keys =
discovered_ip_addresses
|> Enum.map(fn ip_data ->
interface_id = Map.get(if_index_to_interface, ip_data.if_index)
{interface_id, ip_data.ip_address}
end)
|> Enum.reject(fn {iface_id, _} -> is_nil(iface_id) end)
|> MapSet.new()
# Delete IP addresses that no longer exist
existing_keys = MapSet.new(Map.keys(existing_ip_addresses))
removed_keys = MapSet.difference(existing_keys, discovered_keys)
if MapSet.size(removed_keys) > 0 do
removed_list = MapSet.to_list(removed_keys)
Enum.each(removed_list, fn {interface_id, ip_address} ->
Repo.delete_all(
from ip in IpAddress,
where: ip.snmp_interface_id == ^interface_id and ip.ip_address == ^ip_address
)
end)
Logger.debug("Deleted #{MapSet.size(removed_keys)} removed IP addresses")
end
# Upsert each discovered IP address
Enum.each(discovered_ip_addresses, fn ip_data ->
upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses)
end)
Logger.info("Synced #{length(discovered_ip_addresses)} IP addresses for device")
:ok
end
defp upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses) do
interface_id = Map.get(if_index_to_interface, ip_data.if_index)
if interface_id do
ip_attrs = Map.put(ip_data, :snmp_interface_id, interface_id)
do_upsert_ip_address(ip_attrs, existing_ip_addresses, interface_id, ip_data.ip_address)
end
end
defp do_upsert_ip_address(ip_attrs, existing_ip_addresses, interface_id, ip_address) do
case Map.get(existing_ip_addresses, {interface_id, ip_address}) do
nil ->
# New IP address - insert
%IpAddress{}
|> IpAddress.changeset(ip_attrs)
|> Repo.insert!()
existing ->
# Existing IP address - update
existing
|> IpAddress.changeset(ip_attrs)
|> Repo.update!()
end
end
@spec sync_processors(Device.t(), [map()]) :: :ok
defp sync_processors(device, discovered_processors) do
# Get existing processors for this device, indexed by processor_index
existing_processors =
from(p in Processor, where: p.snmp_device_id == ^device.id)
|> Repo.all()
|> Map.new(&{&1.processor_index, &1})
# Get the set of discovered processor_indices
discovered_indices = MapSet.new(discovered_processors, & &1.processor_index)
# Delete processors that no longer exist on the device
existing_indices = MapSet.new(Map.keys(existing_processors))
removed_indices = MapSet.difference(existing_indices, discovered_indices)
if MapSet.size(removed_indices) > 0 do
removed_list = MapSet.to_list(removed_indices)
Repo.delete_all(
from p in Processor,
where: p.snmp_device_id == ^device.id and p.processor_index in ^removed_list
)
Logger.debug("Deleted #{MapSet.size(removed_indices)} removed processors")
end
# Upsert each discovered processor
Enum.each(discovered_processors, fn processor_data ->
case Map.get(existing_processors, processor_data.processor_index) do
nil ->
# New processor - insert
%Processor{}
|> Processor.changeset(Map.put(processor_data, :snmp_device_id, device.id))
|> Repo.insert!()
existing ->
# Existing processor - update
existing
|> Processor.changeset(processor_data)
|> Repo.update!()
end
end)
Logger.info("Synced #{length(discovered_processors)} processors for device")
:ok
end
@spec update_device_discovery_time(DeviceSchema.t()) ::
{:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
defp update_device_discovery_time(device) do

View file

@ -8,6 +8,7 @@ defmodule Towerops.Snmp.NeighborDiscovery do
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Sanitizer
require Logger
@ -125,36 +126,19 @@ defmodule Towerops.Snmp.NeighborDiscovery do
protocol: protocol,
interface_id: interface.id,
device_id: interface.device_id,
remote_chassis_id: sanitize_string_field(neighbor_map[:remote_chassis_id]),
remote_system_name: sanitize_string_field(neighbor_map[:remote_system_name]),
remote_system_description: sanitize_string_field(neighbor_map[:remote_system_description]),
remote_platform: sanitize_string_field(neighbor_map[:remote_platform]),
remote_port_id: sanitize_string_field(neighbor_map[:remote_port_id]),
remote_port_description: sanitize_string_field(neighbor_map[:remote_port_description]),
remote_address: sanitize_string_field(neighbor_map[:remote_address]),
remote_chassis_id: Sanitizer.sanitize_string(neighbor_map[:remote_chassis_id]),
remote_system_name: Sanitizer.sanitize_string(neighbor_map[:remote_system_name]),
remote_system_description: Sanitizer.sanitize_string(neighbor_map[:remote_system_description]),
remote_platform: Sanitizer.sanitize_string(neighbor_map[:remote_platform]),
remote_port_id: Sanitizer.sanitize_string(neighbor_map[:remote_port_id]),
remote_port_description: Sanitizer.sanitize_string(neighbor_map[:remote_port_description]),
remote_address: Sanitizer.sanitize_string(neighbor_map[:remote_address]),
remote_capabilities: neighbor_map[:remote_capabilities] || [],
last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second)
}
end
end
# Ensure all string fields are actually strings, not raw binaries
defp sanitize_string_field(nil), do: nil
defp sanitize_string_field(value) when is_binary(value) do
if String.valid?(value) and String.printable?(value) do
String.trim(value)
else
# Convert non-printable binary to hex string
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
end
defp sanitize_string_field(value), do: to_string(value)
# CDP Discovery (Cisco proprietary)
@cdp_cache_table_oid "1.3.6.1.4.1.9.9.23.1.2.1.1"

View file

@ -166,6 +166,9 @@ defmodule Towerops.Snmp.PollerWorker do
end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_neighbors(device, snmp_device, client_opts)
end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_processors(device, snmp_device, client_opts, now)
end)
]
@ -263,6 +266,122 @@ defmodule Towerops.Snmp.PollerWorker do
Logger.error("Error polling neighbors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_device_processors(device, snmp_device, client_opts, now) do
processors = snmp_device.processors || []
if processors != [] do
poll_processors(processors, client_opts, now)
Logger.debug("Polled processors for #{device.name}")
# Broadcast processor update event to device-specific topic
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:processors_updated, device.id}
)
end
rescue
error ->
Logger.error("Error polling processors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_processors(processors, client_opts, timestamp) do
# Poll processors in parallel with max_concurrency
processors
|> Task.async_stream(
fn processor ->
result = poll_processor_value(processor, client_opts)
handle_processor_poll_result(processor, result, timestamp)
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
end
defp poll_processor_value(processor, client_opts) do
# Different OIDs for different processor types
oid =
case processor.processor_type do
"hr_processor" ->
# HOST-RESOURCES-MIB hrProcessorLoad
"1.3.6.1.2.1.25.3.3.1.2.#{processor.processor_index}"
"cisco_cpu" ->
# CISCO-PROCESS-MIB cpmCPUTotal5minRev
"1.3.6.1.4.1.9.9.109.1.1.1.1.8.#{processor.processor_index}"
"ucd_cpu" ->
# UCD-SNMP-MIB - need to calculate from user/system/idle
# For simplicity, use ssCpuUser + ssCpuSystem as approximation
poll_ucd_cpu(processor, client_opts)
_ ->
{:error, :unknown_processor_type}
end
case oid do
{:ok, value} -> {:ok, value}
{:error, _} = error -> error
oid_string when is_binary(oid_string) -> poll_snmp_load(client_opts, oid_string)
end
end
defp poll_snmp_load(client_opts, oid) do
case Client.get(client_opts, oid) do
{:ok, value} when is_integer(value) ->
{:ok, value / 1.0}
{:ok, _non_integer} ->
{:error, :non_integer}
{:error, reason} ->
{:error, reason}
end
end
defp poll_ucd_cpu(_processor, client_opts) do
# UCD-SNMP-MIB: ssCpuUser + ssCpuSystem = CPU usage
user_oid = "1.3.6.1.4.1.2021.11.9.0"
system_oid = "1.3.6.1.4.1.2021.11.10.0"
with {:ok, user} <- Client.get(client_opts, user_oid),
{:ok, system} <- Client.get(client_opts, system_oid),
true <- is_integer(user) and is_integer(system) do
{:ok, (user + system) / 1.0}
else
_ -> {:error, :failed_to_calculate}
end
end
defp handle_processor_poll_result(processor, {:ok, load_percent}, timestamp) do
# Create a reading
Snmp.create_processor_reading(%{
processor_id: processor.id,
load_percent: load_percent,
status: "ok",
checked_at: timestamp
})
# Update processor with latest value
Snmp.update_processor(processor, %{
load_percent: load_percent,
last_checked_at: timestamp
})
end
defp handle_processor_poll_result(processor, {:error, reason}, timestamp) do
Logger.debug("Failed to poll processor #{processor.description}: #{inspect(reason)}")
Snmp.create_processor_reading(%{
processor_id: processor.id,
load_percent: nil,
status: "error",
checked_at: timestamp
})
end
defp poll_sensors(sensors, client_opts, timestamp) do
# Poll sensors in parallel with max_concurrency to avoid overwhelming the device
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
@ -796,24 +915,73 @@ defmodule Towerops.Snmp.PollerWorker do
defp format_speed(_), do: "Unknown"
defp get_interface_stats(client_opts, if_index) do
oids = [
# Try High-Capacity 64-bit counters first (ifHCInOctets, ifHCOutOctets)
# These are essential for 10G+ interfaces to avoid counter wrapping
# HC counters are from IF-MIB::ifXTable (RFC 2863)
hc_oids = [
if_in_octets: "1.3.6.1.2.1.31.1.1.1.6.#{if_index}",
if_out_octets: "1.3.6.1.2.1.31.1.1.1.10.#{if_index}"
]
# Standard 32-bit counters (fallback)
std_oids = [
if_in_octets: "1.3.6.1.2.1.2.2.1.10.#{if_index}",
if_out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}",
if_out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}"
]
# Error/discard counters (always 32-bit, no HC versions)
error_oids = [
if_in_errors: "1.3.6.1.2.1.2.2.1.14.#{if_index}",
if_out_errors: "1.3.6.1.2.1.2.2.1.20.#{if_index}",
if_in_discards: "1.3.6.1.2.1.2.2.1.13.#{if_index}",
if_out_discards: "1.3.6.1.2.1.2.2.1.19.#{if_index}"
]
results =
Enum.map(oids, fn {key, oid} ->
# Try HC counters first for octet counts
octet_results = fetch_octet_counters(client_opts, hc_oids, std_oids)
# Fetch error/discard counters
error_results =
Enum.map(error_oids, fn {key, oid} ->
case Client.get(client_opts, oid) do
{:ok, value} -> {key, decode_snmp_value(value)}
_ -> {key, nil}
end
end)
{:ok, Map.new(results)}
{:ok, Map.new(octet_results ++ error_results)}
end
# Try HC (64-bit) counters first, fall back to standard 32-bit if not available
defp fetch_octet_counters(client_opts, hc_oids, std_oids) do
hc_results = Enum.map(hc_oids, &try_fetch_hc_counter(client_opts, &1))
hc_available = Enum.all?(hc_results, fn {_key, value} -> value != :not_available end)
if hc_available do
Enum.map(hc_results, fn {key, value} -> {key, value} end)
else
Logger.debug("HC counters not available, using standard 32-bit counters")
Enum.map(std_oids, &fetch_standard_counter(client_opts, &1))
end
end
defp try_fetch_hc_counter(client_opts, {key, oid}) do
case Client.get(client_opts, oid) do
{:ok, value} ->
decoded = decode_snmp_value(value)
if decoded == nil, do: {key, :not_available}, else: {key, decoded}
_ ->
{key, :not_available}
end
end
defp fetch_standard_counter(client_opts, {key, oid}) do
case Client.get(client_opts, oid) do
{:ok, value} -> {key, decode_snmp_value(value)}
_ -> {key, nil}
end
end
# Decode SNMP values, handling Counter64 and other binary types

View file

@ -0,0 +1,79 @@
defmodule Towerops.Snmp.Processor do
@moduledoc """
SNMP processor schema for tracking CPU/processor information.
Processors are discovered via:
- HOST-RESOURCES-MIB hrProcessorTable
- CISCO-PROCESS-MIB cpmCPUTotalTable
- UCD-SNMP-MIB laTable (for system load averages)
Processor types:
- "hr_processor" - HOST-RESOURCES-MIB hrProcessorTable entry
- "cisco_cpu" - Cisco CPU from CISCO-PROCESS-MIB
- "ucd_cpu" - UCD-SNMP-MIB CPU statistics
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_processor_types ~w(hr_processor cisco_cpu ucd_cpu)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_processors" do
field :processor_index, :string
field :description, :string
field :processor_type, :string
field :load_percent, :float
field :last_checked_at, :utc_datetime
field :metadata, :map, default: %{}
belongs_to :snmp_device, Device
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
processor_index: String.t(),
description: String.t() | nil,
processor_type: String.t(),
load_percent: float() | nil,
last_checked_at: DateTime.t() | nil,
metadata: map(),
snmp_device_id: Ecto.UUID.t(),
snmp_device: NotLoaded.t() | Device.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(processor, attrs) do
processor
|> cast(attrs, [
:snmp_device_id,
:processor_index,
:description,
:processor_type,
:load_percent,
:last_checked_at,
:metadata
])
|> validate_required([:snmp_device_id, :processor_index, :processor_type])
|> validate_inclusion(:processor_type, @valid_processor_types)
|> validate_load_percent()
|> unique_constraint([:snmp_device_id, :processor_index])
|> foreign_key_constraint(:snmp_device_id, match: :suffix)
end
defp validate_load_percent(changeset) do
case get_field(changeset, :load_percent) do
nil -> changeset
load when load >= 0 and load <= 100 -> changeset
_ -> add_error(changeset, :load_percent, "must be between 0 and 100")
end
end
end

View file

@ -0,0 +1,44 @@
defmodule Towerops.Snmp.ProcessorReading do
@moduledoc """
Time-series processor reading schema.
Stores individual processor load readings collected during SNMP polling cycles.
Similar to SensorReading but specifically for processor/CPU load data.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Snmp.Processor
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_processor_readings" do
field :load_percent, :float
field :status, :string
field :checked_at, :utc_datetime
belongs_to :processor, Processor
timestamps(type: :utc_datetime, updated_at: false)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
load_percent: float() | nil,
status: String.t(),
checked_at: DateTime.t(),
processor_id: Ecto.UUID.t(),
processor: Ecto.Association.NotLoaded.t() | Processor.t(),
inserted_at: DateTime.t()
}
@doc false
def changeset(reading, attrs) do
reading
|> cast(attrs, [:processor_id, :load_percent, :status, :checked_at])
|> validate_required([:processor_id, :status, :checked_at])
|> validate_inclusion(:status, ["ok", "warning", "critical", "error"])
|> foreign_key_constraint(:processor_id)
end
end

View file

@ -11,6 +11,7 @@ defmodule Towerops.Snmp.Profiles.Base do
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Sanitizer
require Logger
@ -37,7 +38,12 @@ defmodule Towerops.Snmp.Profiles.Base do
# IF-MIB::ifXTable - Extended interface table (64-bit counters, names)
if_name: "1.3.6.1.2.1.31.1.1.1.1",
if_alias: "1.3.6.1.2.1.31.1.1.1.18"
if_high_speed: "1.3.6.1.2.1.31.1.1.1.15",
if_alias: "1.3.6.1.2.1.31.1.1.1.18",
# IF-MIB::ifXTable - High-Capacity 64-bit counters (for 10G+ interfaces)
if_hc_in_octets: "1.3.6.1.2.1.31.1.1.1.6",
if_hc_out_octets: "1.3.6.1.2.1.31.1.1.1.10"
}
@entity_sensor_oids %{
@ -79,11 +85,26 @@ defmodule Towerops.Snmp.Profiles.Base do
}
@ip_mib_oids %{
# IP-MIB - IPv4 address discovery
# IP-MIB - IPv4 address discovery (ipAddrTable, deprecated but widely supported)
ip_ad_ent_if_index: "1.3.6.1.2.1.4.20.1.2",
ip_ad_ent_net_mask: "1.3.6.1.2.1.4.20.1.3"
}
@ipv6_mib_oids %{
# IPV6-MIB - IPv6 address discovery (RFC 2465)
# ipv6AddrTable indexed by: ifIndex.ipv6Address (16 octets)
ipv6_addr_pfx_length: "1.3.6.1.2.1.55.1.8.1.2",
ipv6_addr_type: "1.3.6.1.2.1.55.1.8.1.3"
}
@ip_address_mib_oids %{
# IP-MIB ipAddressTable (RFC 4293) - unified IPv4/IPv6 table
# Indexed by: addressType.address (1=ipv4, 2=ipv6)
ip_address_if_index: "1.3.6.1.2.1.4.34.1.3",
ip_address_prefix: "1.3.6.1.2.1.4.34.1.5",
ip_address_type: "1.3.6.1.2.1.4.34.1.4"
}
@host_resources_oids %{
# HOST-RESOURCES-MIB - hrStorageTable for memory and storage
hr_storage_type: "1.3.6.1.2.1.25.2.3.1.2",
@ -93,6 +114,31 @@ defmodule Towerops.Snmp.Profiles.Base do
hr_storage_used: "1.3.6.1.2.1.25.2.3.1.6"
}
@hr_processor_oids %{
# HOST-RESOURCES-MIB - hrProcessorTable for CPU discovery
# hrProcessorFrwID: 1.3.6.1.2.1.25.3.3.1.1 (reference to hrDeviceEntry)
hr_processor_load: "1.3.6.1.2.1.25.3.3.1.2"
}
@hr_device_oids %{
# HOST-RESOURCES-MIB - hrDeviceTable for device descriptions
hr_device_descr: "1.3.6.1.2.1.25.3.2.1.3"
}
@cisco_cpu_oids %{
# CISCO-PROCESS-MIB - cpmCPUTotalTable
cpm_cpu_total_5sec: "1.3.6.1.4.1.9.9.109.1.1.1.1.3",
cpm_cpu_total_1min: "1.3.6.1.4.1.9.9.109.1.1.1.1.4",
cpm_cpu_total_5min: "1.3.6.1.4.1.9.9.109.1.1.1.1.5"
}
@ucd_cpu_oids %{
# UCD-SNMP-MIB - System CPU statistics
ss_cpu_user: "1.3.6.1.4.1.2021.11.9.0",
ss_cpu_system: "1.3.6.1.4.1.2021.11.10.0",
ss_cpu_idle: "1.3.6.1.4.1.2021.11.11.0"
}
# HOST-RESOURCES-MIB storage type OIDs mapping
# Memory types (for discover_memory_pools)
@memory_type_oids %{
@ -440,6 +486,231 @@ defmodule Towerops.Snmp.Profiles.Base do
{:ok, ip_addresses}
end
@doc """
Discovers IPv6 addresses from IPV6-MIB or IP-MIB ipAddressTable.
Returns a list of IP address maps with ip_address, if_index, prefix_length, and ip_type.
"""
@spec discover_ipv6_addresses(Client.connection_opts()) :: {:ok, [map()]}
def discover_ipv6_addresses(client_opts) do
# Try modern IP-MIB ipAddressTable first (RFC 4293, supports both IPv4/IPv6)
case discover_from_ip_address_table(client_opts) do
{:ok, [_ | _] = addresses} ->
{:ok, addresses}
_ ->
# Fall back to IPV6-MIB ipv6AddrTable (RFC 2465)
discover_from_ipv6_addr_table(client_opts)
end
end
# Discover from RFC 4293 ipAddressTable (unified IPv4/IPv6)
defp discover_from_ip_address_table(client_opts) do
case Client.walk(client_opts, @ip_address_mib_oids.ip_address_if_index) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
do_discover_from_ip_address_table(client_opts, results)
{:ok, _} ->
Logger.debug("No addresses found in ipAddressTable")
{:ok, []}
{:error, reason} ->
Logger.debug("ipAddressTable walk failed: #{inspect(reason)}")
{:ok, []}
end
end
defp do_discover_from_ip_address_table(client_opts, if_index_results) do
# Fetch prefix lengths
prefix_map =
case Client.walk(client_opts, @ip_address_mib_oids.ip_address_prefix) do
{:ok, results} when is_map(results) -> results
_ -> %{}
end
# Build address list - only IPv6 (type 2)
addresses =
if_index_results
|> Enum.map(fn {oid, if_index} ->
{ip_type, ip_address} = parse_ip_address_table_oid(oid)
if ip_type == "ipv6" do
prefix_length = extract_prefix_from_map(prefix_map, oid)
%{
ip_address: ip_address,
if_index: if_index,
subnet_mask: nil,
prefix_length: prefix_length,
ip_type: "ipv6",
last_checked_at: DateTime.utc_now()
}
end
end)
|> Enum.reject(&is_nil/1)
Logger.debug("Discovered #{length(addresses)} IPv6 addresses from ipAddressTable")
{:ok, addresses}
end
# Discover from RFC 2465 IPV6-MIB ipv6AddrTable
defp discover_from_ipv6_addr_table(client_opts) do
case Client.walk(client_opts, @ipv6_mib_oids.ipv6_addr_pfx_length) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
do_discover_from_ipv6_addr_table(results)
{:ok, _} ->
Logger.debug("No IPv6 addresses found in IPV6-MIB")
{:ok, []}
{:error, reason} ->
Logger.debug("IPV6-MIB walk failed: #{inspect(reason)}")
{:ok, []}
end
end
defp do_discover_from_ipv6_addr_table(prefix_results) do
# ipv6AddrTable is indexed by ifIndex.ipv6Address (16 octets)
addresses =
prefix_results
|> Enum.map(fn {oid, prefix_length} ->
{if_index, ipv6_address} = parse_ipv6_addr_table_oid(oid)
if ipv6_address do
%{
ip_address: ipv6_address,
if_index: if_index,
subnet_mask: nil,
prefix_length: prefix_length,
ip_type: "ipv6",
last_checked_at: DateTime.utc_now()
}
end
end)
|> Enum.reject(&is_nil/1)
Logger.debug("Discovered #{length(addresses)} IPv6 addresses from IPV6-MIB")
{:ok, addresses}
end
# Parse ipAddressTable OID to extract address type and address
# OID format: base.addressType.addressLength.address...
# addressType: 1=ipv4, 2=ipv6
defp parse_ip_address_table_oid(oid) when is_binary(oid) do
parts = String.split(oid, ".")
# Find where the address starts (after the base OID)
# The format is: base.type.length.addr1.addr2...
case find_address_in_oid(parts) do
{:ipv4, octets} when length(octets) == 4 ->
{"ipv4", Enum.join(octets, ".")}
{:ipv6, octets} when length(octets) == 16 ->
{"ipv6", format_ipv6_address(octets)}
_ ->
{nil, nil}
end
end
defp parse_ip_address_table_oid(_), do: {nil, nil}
defp find_address_in_oid(parts) do
# Look for type indicator (1 or 2) followed by length
# Pattern: ...1.4.a.b.c.d (IPv4) or ...2.16.a.b.c... (IPv6)
parts
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find_value(fn
["1", "4" | _] ->
idx = Enum.find_index(parts, &(&1 == "1"))
if idx do
octets = Enum.slice(parts, idx + 2, 4)
{:ipv4, octets}
end
["2", "16" | _] ->
idx = Enum.find_index(parts, &(&1 == "2"))
if idx do
octets = Enum.slice(parts, idx + 2, 16)
{:ipv6, octets}
end
_ ->
nil
end)
end
# Parse ipv6AddrTable OID to extract ifIndex and IPv6 address
# OID format: base.ifIndex.ipv6Addr (16 octets)
defp parse_ipv6_addr_table_oid(oid) when is_binary(oid) do
parts = String.split(oid, ".")
# Last 17 parts: ifIndex + 16 address octets
if length(parts) >= 17 do
suffix = Enum.take(parts, -17)
[if_index_str | addr_octets] = suffix
if_index = String.to_integer(if_index_str)
ipv6_address = format_ipv6_address(addr_octets)
{if_index, ipv6_address}
else
{nil, nil}
end
rescue
_ -> {nil, nil}
end
defp parse_ipv6_addr_table_oid(_), do: {nil, nil}
# Format IPv6 address from decimal octets to standard notation
defp format_ipv6_address(octets) when is_list(octets) and length(octets) == 16 do
octets
|> Enum.map(&String.to_integer/1)
|> Enum.chunk_every(2)
|> Enum.map_join(":", fn [hi, lo] ->
(hi * 256 + lo)
|> Integer.to_string(16)
|> String.downcase()
end)
rescue
_ -> nil
end
defp format_ipv6_address(_), do: nil
defp extract_prefix_from_map(prefix_map, oid) do
# Try to find matching prefix entry
# The OID suffix should match
Enum.find_value(prefix_map, fn {prefix_oid, value} ->
if String.ends_with?(prefix_oid, String.slice(oid, -20..-1)) do
value
end
end)
end
@doc """
Discovers all IP addresses (both IPv4 and IPv6) from SNMP.
Combines results from discover_ip_addresses/1 and discover_ipv6_addresses/1.
"""
@spec discover_all_ip_addresses(Client.connection_opts()) :: {:ok, [map()]}
def discover_all_ip_addresses(client_opts) do
ipv4_result = discover_ip_addresses(client_opts)
ipv6_result = discover_ipv6_addresses(client_opts)
ipv4_addresses = elem(ipv4_result, 1)
ipv6_addresses = elem(ipv6_result, 1)
all_addresses = ipv4_addresses ++ ipv6_addresses
Logger.debug(
"Discovered #{length(all_addresses)} total IP addresses (#{length(ipv4_addresses)} IPv4, #{length(ipv6_addresses)} IPv6)"
)
{:ok, all_addresses}
end
@doc """
Discovers memory pools from HOST-RESOURCES-MIB hrStorageTable.
Returns a list of memory pool maps (RAM, swap/virtual memory).
@ -535,10 +806,11 @@ defmodule Towerops.Snmp.Profiles.Base do
allocation_units = Map.get(alloc_map, index, 1)
size_units = Map.get(size_map, index, 0)
used_units = Map.get(used_map, index, 0)
raw_description = Map.get(descr_map, index)
%{
storage_index: index,
description: Map.get(descr_map, index),
description: Sanitizer.sanitize_string(raw_description),
storage_type: storage_type,
allocation_units: allocation_units,
total_bytes: size_units * allocation_units,
@ -693,6 +965,181 @@ defmodule Towerops.Snmp.Profiles.Base do
defp to_string_or_nil(value) when is_binary(value), do: value
defp to_string_or_nil(value), do: to_string(value)
@doc """
Discovers processor/CPU information from multiple MIBs.
Tries in order:
1. HOST-RESOURCES-MIB hrProcessorTable (standard, most compatible)
2. CISCO-PROCESS-MIB cpmCPUTotalTable (Cisco-specific)
3. UCD-SNMP-MIB CPU statistics (Linux/Unix systems)
Returns a list of processor maps with processor_index, description, processor_type, and load_percent.
"""
@spec discover_processors(Client.connection_opts()) :: {:ok, [map()]}
def discover_processors(client_opts) do
# Try HOST-RESOURCES-MIB first (most widely supported)
case discover_hr_processors(client_opts) do
{:ok, [_ | _] = processors} ->
{:ok, processors}
_ ->
# Try Cisco-specific OIDs
case discover_cisco_processors(client_opts) do
{:ok, [_ | _] = processors} ->
{:ok, processors}
_ ->
# Try UCD-SNMP-MIB
discover_ucd_cpu(client_opts)
end
end
end
# Discover processors from HOST-RESOURCES-MIB hrProcessorTable
defp discover_hr_processors(client_opts) do
case Client.walk(client_opts, @hr_processor_oids.hr_processor_load) do
{:ok, results} when map_size(results) > 0 ->
do_discover_hr_processors(client_opts, results)
{:ok, _} ->
Logger.debug("No processors found in HOST-RESOURCES-MIB")
{:ok, []}
{:error, reason} ->
Logger.debug("hrProcessorTable walk failed: #{inspect(reason)}")
{:ok, []}
end
end
defp do_discover_hr_processors(client_opts, load_results) do
# Get device descriptions for processors
descr_map = walk_hr_device_descr(client_opts)
processors =
load_results
|> Enum.map(fn {oid, load_value} ->
index = extract_processor_index(oid)
raw_description = Map.get(descr_map, index, "CPU #{index}")
description = Sanitizer.sanitize_string(raw_description)
load_percent = parse_integer(load_value)
%{
processor_index: "hr_#{index}",
description: description,
processor_type: "hr_processor",
load_percent: load_percent && load_percent * 1.0,
last_checked_at: DateTime.utc_now()
}
end)
|> Enum.reject(fn p -> is_nil(p.processor_index) end)
Logger.debug("Discovered #{length(processors)} processors from HOST-RESOURCES-MIB")
{:ok, processors}
end
defp walk_hr_device_descr(client_opts) do
case Client.walk(client_opts, @hr_device_oids.hr_device_descr) do
{:ok, results} ->
Map.new(results, fn {oid, value} ->
index = extract_processor_index(oid)
{index, extract_string_value(value)}
end)
_ ->
%{}
end
end
# Discover processors from CISCO-PROCESS-MIB cpmCPUTotalTable
defp discover_cisco_processors(client_opts) do
case Client.walk(client_opts, @cisco_cpu_oids.cpm_cpu_total_5min) do
{:ok, results} when map_size(results) > 0 ->
do_discover_cisco_processors(results)
{:ok, _} ->
Logger.debug("No Cisco CPU data found")
{:ok, []}
{:error, reason} ->
Logger.debug("CISCO-PROCESS-MIB walk failed: #{inspect(reason)}")
{:ok, []}
end
end
defp do_discover_cisco_processors(cpu_results) do
processors =
Enum.map(cpu_results, fn {oid, load_value} ->
index = extract_processor_index(oid)
load_percent = parse_integer(load_value)
%{
processor_index: "cisco_#{index}",
description: "Cisco CPU #{index}",
processor_type: "cisco_cpu",
load_percent: load_percent && load_percent * 1.0,
last_checked_at: DateTime.utc_now()
}
end)
Logger.debug("Discovered #{length(processors)} Cisco CPUs")
{:ok, processors}
end
# Discover CPU stats from UCD-SNMP-MIB
defp discover_ucd_cpu(client_opts) do
# Get user, system, and idle percentages
oids = [@ucd_cpu_oids.ss_cpu_user, @ucd_cpu_oids.ss_cpu_system, @ucd_cpu_oids.ss_cpu_idle]
case Client.get_multiple(client_opts, oids) do
{:ok, values} when is_list(values) and length(values) == 3 ->
[user, system, _idle] = values
user_pct = parse_integer(user) || 0
system_pct = parse_integer(system) || 0
total_load = user_pct + system_pct
processors = [
%{
processor_index: "ucd_0",
description: "System CPU",
processor_type: "ucd_cpu",
load_percent: total_load * 1.0,
last_checked_at: DateTime.utc_now(),
metadata: %{
user_percent: user_pct,
system_percent: system_pct
}
}
]
Logger.debug("Discovered UCD-SNMP CPU with #{total_load}% load")
{:ok, processors}
{:ok, _} ->
Logger.debug("No UCD-SNMP CPU data found")
{:ok, []}
{:error, reason} ->
Logger.debug("UCD-SNMP-MIB get failed: #{inspect(reason)}")
{:ok, []}
end
end
defp extract_processor_index(oid) when is_binary(oid) do
oid
|> String.split(".")
|> List.last()
|> String.to_integer()
rescue
_ -> nil
end
defp extract_processor_index(_), do: nil
# Extract string value from SNMP result (could be binary, charlist, or other)
defp extract_string_value(value) when is_binary(value), do: value
defp extract_string_value(value) when is_list(value), do: List.to_string(value)
defp extract_string_value(value), do: to_string(value)
@doc """
Identifies device manufacturer and model from sysDescr and sysObjectID.
Can be overridden by vendor-specific profiles.
@ -714,12 +1161,12 @@ defmodule Towerops.Snmp.Profiles.Base do
defp parse_system_info(raw_info) do
%{
sys_descr: Map.get(raw_info, :sys_descr),
sys_descr: raw_info |> Map.get(:sys_descr) |> Sanitizer.sanitize_string(),
sys_object_id: parse_oid(Map.get(raw_info, :sys_object_id)),
sys_name: Map.get(raw_info, :sys_name),
sys_name: raw_info |> Map.get(:sys_name) |> Sanitizer.sanitize_string(),
sys_uptime: parse_integer(Map.get(raw_info, :sys_uptime)),
sys_contact: Map.get(raw_info, :sys_contact),
sys_location: Map.get(raw_info, :sys_location)
sys_contact: raw_info |> Map.get(:sys_contact) |> Sanitizer.sanitize_string(),
sys_location: raw_info |> Map.get(:sys_location) |> Sanitizer.sanitize_string()
}
end
@ -743,26 +1190,54 @@ defmodule Towerops.Snmp.Profiles.Base do
with {:ok, [if_descr, if_type, if_speed, if_phys_addr, if_admin, if_oper]} <-
Client.get_multiple(client_opts, required_oids) do
# Try to fetch optional IF-MIB extension fields (ifName, ifAlias)
# Try to fetch optional IF-MIB extension fields (ifName, ifAlias, ifHighSpeed)
# These are from RFC 2863 and not all devices support them
if_name = fetch_optional_field(client_opts, @interface_oids.if_name <> ".#{index}")
if_alias = fetch_optional_field(client_opts, @interface_oids.if_alias <> ".#{index}")
if_high_speed = fetch_optional_field(client_opts, @interface_oids.if_high_speed <> ".#{index}")
# Use ifHighSpeed (Mbps) for 10G+ interfaces where ifSpeed (bps) maxes out at ~4.3Gbps
# ifHighSpeed is in Mbps, convert to bps for consistency
actual_speed = calculate_interface_speed(if_speed, if_high_speed)
{:ok,
%{
if_index: index,
if_descr: if_descr,
if_name: if_name,
if_alias: if_alias,
if_descr: Sanitizer.sanitize_string(if_descr),
if_name: Sanitizer.sanitize_string(if_name),
if_alias: Sanitizer.sanitize_string(if_alias),
if_type: parse_integer(if_type),
if_speed: parse_integer(if_speed),
if_phys_address: format_mac_address(if_phys_addr),
if_speed: actual_speed,
if_phys_address: Sanitizer.sanitize_mac(if_phys_addr) || format_mac_address(if_phys_addr),
if_admin_status: parse_if_status(if_admin),
if_oper_status: parse_if_status(if_oper)
}}
end
end
# Calculate actual interface speed, preferring ifHighSpeed for 10G+ interfaces
# ifSpeed maxes out at 4,294,967,295 bps (~4.3 Gbps) due to 32-bit gauge
# ifHighSpeed is in Mbps and supports up to 4.3 Tbps
defp calculate_interface_speed(if_speed, if_high_speed) do
speed_bps = parse_integer(if_speed)
high_speed_mbps = parse_integer(if_high_speed)
cond do
# If ifHighSpeed is available and indicates 10G+ (or ifSpeed maxed out)
high_speed_mbps != nil and high_speed_mbps >= 10_000 ->
# Convert Mbps to bps
high_speed_mbps * 1_000_000
# If ifSpeed is at or near max (4.3 Gbps), use ifHighSpeed if available
speed_bps != nil and speed_bps >= 4_000_000_000 and high_speed_mbps != nil ->
high_speed_mbps * 1_000_000
# Otherwise use standard ifSpeed
true ->
speed_bps
end
end
# Fetch an optional SNMP field - returns nil if not supported
defp fetch_optional_field(client_opts, oid) do
case Client.get(client_opts, oid) do
@ -806,13 +1281,13 @@ defmodule Towerops.Snmp.Profiles.Base do
end
end
# Get best available description from ENTITY-MIB
# Get best available description from ENTITY-MIB (sanitized)
defp get_sensor_description(%{name: name}, _index) when is_binary(name) and byte_size(name) > 0 do
name
Sanitizer.sanitize_string(name)
end
defp get_sensor_description(%{descr: descr}, _index) when is_binary(descr) and byte_size(descr) > 0 do
descr
Sanitizer.sanitize_string(descr)
end
defp get_sensor_description(_, index), do: "Sensor #{index}"
@ -1152,7 +1627,25 @@ defmodule Towerops.Snmp.Profiles.Base do
"#{a}.#{b}.#{c}.#{d}"
end
defp format_ip_address(value) when is_binary(value), do: value
# Handle raw 4-byte binary (IPv4 address bytes) - common from SNMP
defp format_ip_address(<<a, b, c, d>>) do
"#{a}.#{b}.#{c}.#{d}"
end
# Handle already-formatted string, but sanitize non-printable characters
defp format_ip_address(value) when is_binary(value) do
if String.printable?(value) do
value
else
# Try to convert as raw bytes
bytes = :binary.bin_to_list(value)
if length(bytes) == 4 and Enum.all?(bytes, &(&1 >= 0 and &1 <= 255)) do
Enum.join(bytes, ".")
end
end
end
defp format_ip_address(_), do: nil
defp subnet_mask_to_prefix(nil), do: nil

View file

@ -292,6 +292,15 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
defp build_sensor_descr(sensor_def, idx) do
base_descr = sensor_def[:sensor_descr] || sensor_def[:oid_name] || sensor_def[:sensor_type]
# If the description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName"),
# use the sensor_type as fallback since we can't resolve MIB names to actual values here
base_descr =
if is_binary(base_descr) and String.contains?(base_descr, "::") do
sensor_def[:sensor_type] || "sensor"
else
base_descr
end
# If there's only one instance, don't add index
if idx == 1 do
String.capitalize(to_string(base_descr))
@ -363,14 +372,24 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
defp discover_vendor_wireless_sensors(_profile, _client_opts), do: []
# Deduplicate sensors by OID - when multiple sensors share the same OID,
# prefer the one with a proper description (not containing "MIB::")
# prefer the one with a proper description (not containing MIB symbolic names)
defp deduplicate_by_oid(sensors) do
sensors
|> Enum.group_by(& &1.sensor_oid)
|> Enum.map(fn {_oid, group} ->
# Prefer sensor with proper description (no MIB name)
Enum.find(group, fn s -> not String.contains?(s.sensor_descr, "MIB::") end) ||
# Prefer sensor with proper description (no MIB symbolic name like "MIKROTIK-MIB::xxx")
# Check case-insensitively since build_sensor_descr capitalizes only first letter
Enum.find(group, fn s -> not has_mib_symbolic_name?(s.sensor_descr) end) ||
List.last(group)
end)
end
# Check if a description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName")
# These should be filtered out in favor of actual sensor names from the device
defp has_mib_symbolic_name?(descr) when is_binary(descr) do
# MIB symbolic names contain "::" pattern (case-insensitive check)
String.contains?(String.downcase(descr), "::")
end
defp has_mib_symbolic_name?(_), do: false
end

View file

@ -0,0 +1,183 @@
defmodule Towerops.Snmp.Sanitizer do
@moduledoc """
Sanitizes SNMP data to ensure it's safe to store in the database.
SNMP devices may return:
- Non-UTF8 binary data
- Non-printable characters
- Malformed strings
- Raw binary values (MAC addresses, IP addresses)
This module provides sanitization functions to convert these to safe,
printable strings suitable for PostgreSQL text/varchar columns.
"""
@doc """
Sanitizes a string field from SNMP data.
- Returns nil for nil input
- Returns printable strings as-is (trimmed)
- Converts non-printable binaries to colon-separated hex format
- Converts other values to strings
## Examples
iex> Sanitizer.sanitize_string(nil)
nil
iex> Sanitizer.sanitize_string("Hello World")
"Hello World"
iex> Sanitizer.sanitize_string(<<255, 255, 255, 0>>)
"ff:ff:ff:00"
"""
@spec sanitize_string(any()) :: String.t() | nil
def sanitize_string(nil), do: nil
def sanitize_string(value) when is_binary(value) do
if String.valid?(value) and String.printable?(value) do
String.trim(value)
else
# Convert non-printable binary to hex string
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
end
def sanitize_string(value) when is_list(value) do
# Erlang charlists from SNMP
value
|> List.to_string()
|> sanitize_string()
rescue
_ -> inspect(value)
end
def sanitize_string(value), do: to_string(value)
@doc """
Sanitizes an IPv4 address from various SNMP formats.
Handles:
- Tuple format: {a, b, c, d}
- Raw 4-byte binary: <<a, b, c, d>>
- String format: "a.b.c.d"
- Longer binary (takes first 4 bytes as IP)
Returns dotted-decimal string or nil if invalid.
"""
@spec sanitize_ipv4(any()) :: String.t() | nil
def sanitize_ipv4({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do
"#{a}.#{b}.#{c}.#{d}"
end
def sanitize_ipv4(<<a, b, c, d>>) do
"#{a}.#{b}.#{c}.#{d}"
end
def sanitize_ipv4(value) when is_binary(value) do
cond do
# Already formatted string - valid IP
String.printable?(value) and String.match?(value, ~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) ->
value
# Printable string that doesn't match IP format - invalid
String.printable?(value) ->
nil
# Raw bytes - try to convert (only for non-printable binary data)
byte_size(value) >= 4 ->
<<a, b, c, d, _rest::binary>> = value
"#{a}.#{b}.#{c}.#{d}"
true ->
nil
end
end
def sanitize_ipv4(_), do: nil
@doc """
Sanitizes an IPv6 address from various SNMP formats.
Handles:
- Raw 16-byte binary
- String format (already formatted)
- Tuple format (8 16-bit values)
Returns formatted IPv6 string or nil if invalid.
"""
@spec sanitize_ipv6(any()) :: String.t() | nil
def sanitize_ipv6(value) when is_binary(value) and byte_size(value) == 16 do
# Raw 16-byte IPv6 address
bytes = :binary.bin_to_list(value)
bytes
|> Enum.chunk_every(2)
|> Enum.map_join(":", fn [high, low] ->
(high * 256 + low)
|> Integer.to_string(16)
|> String.downcase()
end)
end
def sanitize_ipv6(value) when is_binary(value) do
# Already formatted - validate and return
if String.printable?(value) and String.contains?(value, ":") do
String.trim(value)
end
end
def sanitize_ipv6(_), do: nil
@doc """
Sanitizes a MAC address from various SNMP formats.
Handles:
- Raw 6-byte binary
- Colon-separated hex string
- Dash-separated hex string
Returns colon-separated lowercase hex format or nil if invalid.
"""
@spec sanitize_mac(any()) :: String.t() | nil
def sanitize_mac(value) when is_binary(value) and byte_size(value) == 6 do
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
def sanitize_mac(value) when is_binary(value) do
# Already formatted - validate pattern
cleaned = String.replace(value, "-", ":")
if String.match?(cleaned, ~r/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/) do
String.downcase(cleaned)
end
end
def sanitize_mac(_), do: nil
@doc """
Sanitizes all string fields in a map recursively.
Applies sanitize_string/1 to all string values in the map.
"""
@spec sanitize_map(map()) :: map()
def sanitize_map(map) when is_map(map) do
Map.new(map, fn
{key, value} when is_binary(value) ->
{key, sanitize_string(value)}
{key, value} when is_map(value) ->
{key, sanitize_map(value)}
{key, value} ->
{key, value}
end)
end
end

View file

@ -342,7 +342,7 @@ defmodule ToweropsWeb.Layouts do
<.link
navigate={@navigate}
class={[
"inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium",
"inline-flex items-center border-b-2 px-1 pt-1 pb-4 text-sm font-medium",
if(@active,
do: "border-gray-900 text-gray-900 dark:border-white dark:text-white",
else:

View file

@ -113,6 +113,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:processors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:monitoring_check_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
@ -134,8 +139,10 @@ defmodule ToweropsWeb.DeviceLive.Show do
events = Devices.list_devices_events(device_id, 100)
neighbors = Snmp.list_neighbors_with_equipment(device_id)
vlans = load_vlans(snmp_data.device)
ip_addresses = load_ip_addresses(snmp_data.device)
processors = load_processors(snmp_data.device)
latency_chart_data = load_latency_chart_data(device_id)
cpu_chart_data = load_sensor_chart_data(device_id, ["cpu_load"])
processor_chart_data = load_processor_chart_data(processors)
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
traffic_chart_data = load_overall_traffic_chart_data(device_id)
@ -176,9 +183,12 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign(:neighbors, neighbors)
|> assign(:vlans, vlans)
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|> assign(:processors, processors)
|> assign(:events, events)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:cpu_chart_data, cpu_chart_data)
|> assign(:processor_chart_data, processor_chart_data)
|> assign(:memory_chart_data, memory_chart_data)
|> assign(:storage_chart_data, storage_chart_data)
|> assign(:traffic_chart_data, traffic_chart_data)
@ -349,6 +359,55 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp load_vlans(nil), do: []
defp load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id)
defp load_ip_addresses(nil), do: []
defp load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id)
defp load_processors(nil), do: []
defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id)
defp load_processor_chart_data([]), do: nil
defp load_processor_chart_data(processors) do
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
datasets =
processors
|> Enum.map(&processor_to_chart_dataset(&1, twenty_four_hours_ago))
|> Enum.reject(&Enum.empty?(&1.data))
if Enum.empty?(datasets) do
nil
else
Jason.encode!(%{datasets: datasets})
end
end
defp processor_to_chart_dataset(processor, since) do
readings =
processor.id
|> Snmp.get_processor_readings(since: since, limit: 1000)
|> Enum.reverse()
label =
if processor.description do
processor.description
else
"CPU #{processor.processor_index}"
end
%{
label: label,
data: Enum.map(readings, &processor_reading_to_data_point/1)
}
end
defp processor_reading_to_data_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.load_percent, do: Float.round(reading.load_percent, 1))
}
end
defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil

View file

@ -100,6 +100,22 @@
</.link>
<% end %>
<%= if length(@ipv4_addresses) > 0 || length(@ipv6_addresses) > 0 do %>
<.link
patch={~p"/devices/#{@device.id}?tab=ip_addresses"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "ip_addresses" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
end
]}
>
IP Addresses
</.link>
<% end %>
<.link
patch={~p"/devices/#{@device.id}?tab=logs"}
class={[
@ -304,8 +320,17 @@
</div>
<!-- Right Column: Graphs and Metrics -->
<div class="space-y-6">
<!-- CPU Usage Chart -->
<%= if @cpu_chart_data do %>
<!-- CPU / Processors -->
<%= if @processor_chart_data || (@processors && length(@processors) > 0) do %>
<% processor_count = if @processors, do: length(@processors), else: 0
avg_load =
if processor_count > 0 do
loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1)
if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil
else
nil
end %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<.link
navigate={~p"/devices/#{@device.id}/graph/processors"}
@ -313,21 +338,61 @@
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Processors
<%= if processor_count > 0 do %>
{processor_count} {if processor_count == 1,
do: "Processor",
else: "Processors"}
<% else %>
Processors
<% end %>
</h3>
<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
</div>
</.link>
<div class="p-4">
<div
id="cpu-chart"
phx-hook="SensorChart"
data-chart={@cpu_chart_data}
style="height: 300px;"
>
<canvas></canvas>
<!-- Show chart if we have time-series data -->
<%= if @processor_chart_data do %>
<div class="p-4">
<div
id="processor-chart"
phx-hook="SensorChart"
data-chart={@processor_chart_data}
data-unit="%"
style="height: 200px;"
>
<canvas></canvas>
</div>
</div>
</div>
<% end %>
<!-- Show combined processor load -->
<%= if avg_load do %>
<div class={[
"p-4",
@processor_chart_data && "border-t border-gray-200 dark:border-white/10"
]}>
<div class="flex items-center justify-between gap-4">
<span class="text-sm text-gray-700 dark:text-gray-300">
Average Load
</span>
<div class="flex items-center gap-3 flex-1 max-w-md">
<div class="flex-1 bg-gray-200 rounded-full h-3 dark:bg-gray-700">
<div
class={[
"h-3 rounded-full transition-all",
avg_load < 50 && "bg-green-500",
avg_load >= 50 && avg_load < 80 && "bg-yellow-500",
avg_load >= 80 && "bg-red-500"
]}
style={"width: #{min(avg_load, 100)}%"}
>
</div>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white w-12 text-right">
{Float.round(avg_load, 0)}%
</span>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
<!-- Memory Usage Chart -->
@ -787,6 +852,123 @@
</div>
</div>
<% end %>
<% "ip_addresses" -> %>
<div class="space-y-6">
<!-- IPv4 Addresses -->
<%= if length(@ipv4_addresses) > 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 mr-2">
IPv4
</span>
Addresses
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@ipv4_addresses)})
</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 text-left font-medium">IP Address</th>
<th class="px-4 py-3 text-left font-medium">Subnet</th>
<th class="px-4 py-3 text-left font-medium">Interface</th>
<th class="px-4 py-3 text-left font-medium">Primary</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for ip <- @ipv4_addresses do %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 text-gray-900 dark:text-white font-mono">
{ip.ip_address}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
<%= if ip.prefix_length do %>
/{ip.prefix_length}
<% else %>
{ip.subnet_mask || "-"}
<% end %>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{(ip.snmp_interface &&
(ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"}
</td>
<td class="px-4 py-3">
<%= if ip.is_primary do %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Primary
</span>
<% else %>
<span class="text-gray-400 dark:text-gray-500">-</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<!-- IPv6 Addresses -->
<%= if length(@ipv6_addresses) > 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 mr-2">
IPv6
</span>
Addresses
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@ipv6_addresses)})
</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 text-left font-medium">IP Address</th>
<th class="px-4 py-3 text-left font-medium">Prefix</th>
<th class="px-4 py-3 text-left font-medium">Interface</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for ip <- @ipv6_addresses do %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 text-gray-900 dark:text-white font-mono text-xs">
{ip.ip_address}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
/{ip.prefix_length || "-"}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{(ip.snmp_interface &&
(ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<!-- No IP addresses found -->
<%= if length(@ipv4_addresses) == 0 && length(@ipv6_addresses) == 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
<div class="text-center">
<.icon name="hero-globe-alt" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No IP addresses discovered
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
This device doesn't have any IP addresses discovered, or IP discovery hasn't run yet.
</p>
</div>
</div>
<% end %>
</div>
<% "logs" -> %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">

View file

@ -98,7 +98,8 @@ defmodule ToweropsWeb.GraphLive.Show do
defp get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"]
defp get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"]
defp get_sensor_types_for_chart("traffic"), do: nil
defp get_sensor_types_for_chart(_), do: []
# For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.)
defp get_sensor_types_for_chart(sensor_type), do: [sensor_type]
defp get_chart_config("latency"), do: {"ICMP Latency", "ms", true}
defp get_chart_config("processors"), do: {"Processor Usage", "%", false}
@ -107,7 +108,18 @@ defmodule ToweropsWeb.GraphLive.Show do
defp get_chart_config("temperature"), do: {"Temperature", "°C", true}
defp get_chart_config("voltage"), do: {"Voltage", "V", true}
defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
defp get_chart_config(_), do: {"Sensor Data", "", false}
defp get_chart_config("count"), do: {"Count", "", true}
defp get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true}
defp get_chart_config("connections"), do: {"Connections", "", true}
# Humanize unknown sensor types for title
defp get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true}
defp humanize_sensor_type(sensor_type) do
sensor_type
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp load_sensor_chart_data(device_id, sensor_types, range) do
case Snmp.get_device_with_associations(device_id) do

View file

@ -0,0 +1,24 @@
defmodule Towerops.Repo.Migrations.CreateSnmpProcessors do
use Ecto.Migration
def change do
create table(:snmp_processors, primary_key: false) do
add :id, :binary_id, primary_key: true
add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all),
null: false
add :processor_index, :string, null: false
add :description, :string
add :processor_type, :string, null: false
add :load_percent, :float
add :last_checked_at, :utc_datetime
add :metadata, :map, default: %{}
timestamps(type: :utc_datetime)
end
create index(:snmp_processors, [:snmp_device_id])
create unique_index(:snmp_processors, [:snmp_device_id, :processor_index])
end
end

View file

@ -0,0 +1,22 @@
defmodule Towerops.Repo.Migrations.AddProcessorReadings do
use Ecto.Migration
def change do
create table(:snmp_processor_readings, primary_key: false) do
add :id, :binary_id, primary_key: true
add :processor_id, references(:snmp_processors, type: :binary_id, on_delete: :delete_all),
null: false
add :load_percent, :float
add :status, :string, null: false, default: "ok"
add :checked_at, :utc_datetime, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:snmp_processor_readings, [:processor_id])
create index(:snmp_processor_readings, [:checked_at])
create index(:snmp_processor_readings, [:processor_id, :checked_at])
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
priv/static/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,5 +1,6 @@
defmodule Towerops.Monitoring.CheckTest do
use Towerops.DataCase, async: true
# async: false to prevent deadlocks when testing foreign key constraints
use Towerops.DataCase, async: false
import Towerops.AccountsFixtures

View file

@ -0,0 +1,214 @@
defmodule Towerops.Snmp.ProcessorTest do
use Towerops.DataCase, async: true
alias Towerops.Snmp.Processor
describe "changeset/2" do
setup do
# Create a valid snmp_device_id for testing
snmp_device_id = Ecto.UUID.generate()
{:ok, snmp_device_id: snmp_device_id}
end
test "valid changeset with required fields", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "hr_1",
processor_type: "hr_processor"
}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
assert get_change(changeset, :processor_index) == "hr_1"
assert get_change(changeset, :processor_type) == "hr_processor"
end
test "valid changeset with all fields", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "cisco_1",
description: "Cisco CPU Core 1",
processor_type: "cisco_cpu",
load_percent: 45.5,
last_checked_at: DateTime.utc_now(),
metadata: %{cores: 4}
}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
assert get_change(changeset, :description) == "Cisco CPU Core 1"
assert get_change(changeset, :load_percent) == 45.5
assert get_change(changeset, :metadata) == %{cores: 4}
end
test "requires snmp_device_id" do
attrs = %{
processor_index: "hr_1",
processor_type: "hr_processor"
}
changeset = Processor.changeset(%Processor{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).snmp_device_id
end
test "requires processor_index", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_type: "hr_processor"
}
changeset = Processor.changeset(%Processor{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).processor_index
end
test "requires processor_type", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "hr_1"
}
changeset = Processor.changeset(%Processor{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).processor_type
end
test "validates processor_type inclusion", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "invalid_1",
processor_type: "invalid_type"
}
changeset = Processor.changeset(%Processor{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).processor_type
end
test "accepts all valid processor types", %{snmp_device_id: snmp_device_id} do
for processor_type <- ~w(hr_processor cisco_cpu ucd_cpu) do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "test_1",
processor_type: processor_type
}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?, "Expected #{processor_type} to be valid"
end
end
test "validates load_percent is between 0 and 100", %{snmp_device_id: snmp_device_id} do
# Valid at 0
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "hr_1",
processor_type: "hr_processor",
load_percent: 0.0
}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
# Valid at 100
attrs = %{attrs | load_percent: 100.0}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
# Valid in middle
attrs = %{attrs | load_percent: 50.5}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
# Invalid above 100
attrs = %{attrs | load_percent: 100.1}
changeset = Processor.changeset(%Processor{}, attrs)
refute changeset.valid?
assert "must be between 0 and 100" in errors_on(changeset).load_percent
# Invalid below 0
attrs = %{attrs | load_percent: -1.0}
changeset = Processor.changeset(%Processor{}, attrs)
refute changeset.valid?
assert "must be between 0 and 100" in errors_on(changeset).load_percent
end
test "allows nil load_percent", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "hr_1",
processor_type: "hr_processor",
load_percent: nil
}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
end
test "description can be nil", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "hr_1",
processor_type: "hr_processor",
description: nil
}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
end
test "metadata defaults to empty map", %{snmp_device_id: snmp_device_id} do
attrs = %{
snmp_device_id: snmp_device_id,
processor_index: "ucd_0",
processor_type: "ucd_cpu"
}
changeset = Processor.changeset(%Processor{}, attrs)
assert changeset.valid?
# Default comes from schema, not changeset
processor = %Processor{}
assert processor.metadata == %{}
end
end
describe "schema" do
test "has correct fields" do
fields = Processor.__schema__(:fields)
assert :id in fields
assert :snmp_device_id in fields
assert :processor_index in fields
assert :description in fields
assert :processor_type in fields
assert :load_percent in fields
assert :last_checked_at in fields
assert :metadata in fields
assert :inserted_at in fields
assert :updated_at in fields
end
test "belongs to snmp_device" do
assocs = Processor.__schema__(:associations)
assert :snmp_device in assocs
assoc = Processor.__schema__(:association, :snmp_device)
assert assoc.queryable == Towerops.Snmp.Device
end
test "uses binary_id for primary key" do
assert Processor.__schema__(:type, :id) == :binary_id
end
test "uses binary_id for foreign keys" do
assert Processor.__schema__(:type, :snmp_device_id) == :binary_id
end
end
end

View file

@ -89,8 +89,9 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
]}
end)
# Mock interface data for each interface (8 OIDs per interface × 3 interfaces = 24 calls)
expect(SnmpMock, :get, 24, fn _, oid, _ ->
# Mock interface data for each interface (9 OIDs per interface × 3 interfaces = 27 calls)
# OIDs: ifDescr, ifType, ifSpeed, ifPhysAddress, ifAdminStatus, ifOperStatus, ifName, ifHighSpeed, ifAlias
expect(SnmpMock, :get, 27, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".1") ->
parts = String.split(oid, ".")
@ -104,6 +105,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth0"}}
second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 1000}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}}
true -> {:error, :no_such_object}
end
@ -120,6 +122,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 2}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth1"}}
second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 100}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}}
true -> {:error, :no_such_object}
end
@ -136,6 +139,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "lo"}}
second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 10}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "Loopback"}}
true -> {:error, :no_such_object}
end
@ -884,6 +888,122 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
end
end
describe "discover_ipv6_addresses/1" do
test "discovers IPv6 addresses from IPV6-MIB ipv6AddrTable" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# ipAddressIfIndex - modern RFC 4293 table (empty, fallback to IPV6-MIB)
"1.3.6.1.2.1.4.34.1.3" ->
{:ok, []}
# ipv6AddrPfxLength - indexed by ifIndex.ipv6Address
# Example: interface 2 with address 2001:db8::1 (16 octets)
"1.3.6.1.2.1.55.1.8.1.2" ->
{:ok,
[
# ifIndex=2, IPv6=2001:0db8:0000:0000:0000:0000:0000:0001
%{
oid: "1.3.6.1.2.1.55.1.8.1.2.2.32.1.13.184.0.0.0.0.0.0.0.0.0.0.0.1",
value: {:integer, 64}
},
# ifIndex=3, IPv6=fe80::1
%{
oid: "1.3.6.1.2.1.55.1.8.1.2.3.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1",
value: {:integer, 64}
}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts)
assert length(ip_addresses) == 2
ipv6_1 = Enum.find(ip_addresses, &(&1.if_index == 2))
assert ipv6_1.ip_address == "2001:db8:0:0:0:0:0:1"
assert ipv6_1.prefix_length == 64
assert ipv6_1.ip_type == "ipv6"
ipv6_2 = Enum.find(ip_addresses, &(&1.if_index == 3))
assert ipv6_2.ip_address == "fe80:0:0:0:0:0:0:1"
assert ipv6_2.prefix_length == 64
end
test "returns empty list when IPV6-MIB not supported" do
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts)
assert ip_addresses == []
end
test "handles walk errors gracefully" do
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts)
assert ip_addresses == []
end
end
describe "discover_all_ip_addresses/1" do
test "combines IPv4 and IPv6 addresses" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# IPv4 from IP-MIB
"1.3.6.1.2.1.4.20.1.2" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.4.20.1.2.192.168.1.1", value: {:integer, 1}}
]}
"1.3.6.1.2.1.4.20.1.3" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.4.20.1.3.192.168.1.1", value: {:ip_address, {255, 255, 255, 0}}}
]}
# IPv6 from modern ipAddressTable (empty, fallback)
"1.3.6.1.2.1.4.34.1.3" ->
{:ok, []}
# IPv6 from IPV6-MIB
"1.3.6.1.2.1.55.1.8.1.2" ->
{:ok,
[
%{
oid: "1.3.6.1.2.1.55.1.8.1.2.1.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1",
value: {:integer, 64}
}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts)
assert length(all_addresses) == 2
ipv4 = Enum.find(all_addresses, &(&1.ip_type == "ipv4"))
assert ipv4.ip_address == "192.168.1.1"
assert ipv4.prefix_length == 24
ipv6 = Enum.find(all_addresses, &(&1.ip_type == "ipv6"))
assert ipv6.ip_address == "fe80:0:0:0:0:0:0:1"
assert ipv6.prefix_length == 64
end
test "returns empty list when no addresses found" do
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts)
assert all_addresses == []
end
end
describe "discover_memory_pools/1" do
test "discovers memory pools from HOST-RESOURCES-MIB" do
stub(SnmpMock, :walk, fn _, oid, _ ->
@ -1326,4 +1446,153 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
end
end
end
describe "discover_processors/1" do
test "discovers processors from HOST-RESOURCES-MIB" do
# Mock hrProcessorLoad walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 25}},
%{oid: "1.3.6.1.2.1.25.3.3.1.2.769", value: {:integer, 45}}
]}
end)
# Mock hrDeviceDescr walk for processor descriptions
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.3.2.1.3.768", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}},
%{oid: "1.3.6.1.2.1.25.3.2.1.3.769", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}}
]}
end)
assert {:ok, processors} = Base.discover_processors(@client_opts)
assert length(processors) == 2
cpu1 = Enum.find(processors, &(&1.processor_index == "hr_768"))
assert cpu1.description == "Intel Core i7-9700 @ 3.00GHz"
assert cpu1.processor_type == "hr_processor"
assert cpu1.load_percent == 25.0
cpu2 = Enum.find(processors, &(&1.processor_index == "hr_769"))
assert cpu2.description == "Intel Core i7-9700 @ 3.00GHz"
assert cpu2.processor_type == "hr_processor"
assert cpu2.load_percent == 45.0
end
test "falls back to CISCO-PROCESS-MIB when HOST-RESOURCES-MIB returns empty" do
# HR-MIB returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
{:ok, []}
end)
# Cisco CPU walk returns data
expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 15}},
%{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.2", value: {:integer, 22}}
]}
end)
assert {:ok, processors} = Base.discover_processors(@client_opts)
assert length(processors) == 2
cpu1 = Enum.find(processors, &(&1.processor_index == "cisco_1"))
assert cpu1.description == "Cisco CPU 1"
assert cpu1.processor_type == "cisco_cpu"
assert cpu1.load_percent == 15.0
cpu2 = Enum.find(processors, &(&1.processor_index == "cisco_2"))
assert cpu2.description == "Cisco CPU 2"
assert cpu2.processor_type == "cisco_cpu"
assert cpu2.load_percent == 22.0
end
test "falls back to UCD-SNMP-MIB when other MIBs return empty" do
# HR-MIB returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
{:ok, []}
end)
# Cisco CPU returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ ->
{:ok, []}
end)
# UCD-SNMP-MIB CPU stats
expect(SnmpMock, :get, 3, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.2021.11.9.0" -> {:ok, {:integer, 10}}
"1.3.6.1.4.1.2021.11.10.0" -> {:ok, {:integer, 5}}
"1.3.6.1.4.1.2021.11.11.0" -> {:ok, {:integer, 85}}
end
end)
assert {:ok, processors} = Base.discover_processors(@client_opts)
assert length(processors) == 1
cpu = hd(processors)
assert cpu.processor_index == "ucd_0"
assert cpu.description == "System CPU"
assert cpu.processor_type == "ucd_cpu"
assert cpu.load_percent == 15.0
assert cpu.metadata.user_percent == 10
assert cpu.metadata.system_percent == 5
end
test "returns empty list when no CPU MIBs are supported" do
# All walks return empty
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
assert {:ok, processors} = Base.discover_processors(@client_opts)
assert processors == []
end
test "handles HOST-RESOURCES-MIB walk errors gracefully" do
# HR-MIB walk fails
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
{:error, :timeout}
end)
# Falls back to Cisco
expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 30}}
]}
end)
assert {:ok, processors} = Base.discover_processors(@client_opts)
assert length(processors) == 1
assert hd(processors).processor_type == "cisco_cpu"
end
test "uses default description when hrDeviceDescr is missing" do
# HR processor load
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 50}}
]}
end)
# HR device description returns empty
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ ->
{:ok, []}
end)
assert {:ok, processors} = Base.discover_processors(@client_opts)
assert length(processors) == 1
cpu = hd(processors)
assert cpu.description == "CPU 768"
assert cpu.load_percent == 50.0
end
end
end

View file

@ -0,0 +1,137 @@
defmodule Towerops.Snmp.SanitizerTest do
use ExUnit.Case, async: true
alias Towerops.Snmp.Sanitizer
describe "sanitize_string/1" do
test "returns nil for nil input" do
assert Sanitizer.sanitize_string(nil) == nil
end
test "returns trimmed string for printable input" do
assert Sanitizer.sanitize_string(" Hello World ") == "Hello World"
end
test "passes through normal strings" do
assert Sanitizer.sanitize_string("test string") == "test string"
end
test "converts non-printable binary to hex string" do
# Raw binary bytes (e.g., MAC address)
assert Sanitizer.sanitize_string(<<255, 0, 128, 64>>) == "ff:00:80:40"
end
test "converts erlang charlist to string" do
assert Sanitizer.sanitize_string(~c"hello") == "hello"
end
test "converts integers to string" do
assert Sanitizer.sanitize_string(123) == "123"
end
test "handles mixed printable/non-printable as hex" do
# String with null byte - not printable
assert Sanitizer.sanitize_string(<<"test", 0>>) == "74:65:73:74:00"
end
end
describe "sanitize_ipv4/1" do
test "formats tuple as dotted decimal" do
assert Sanitizer.sanitize_ipv4({192, 168, 1, 1}) == "192.168.1.1"
end
test "formats 4-byte binary as dotted decimal" do
assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1>>) == "192.168.1.1"
end
test "passes through already-formatted string" do
assert Sanitizer.sanitize_ipv4("10.0.0.1") == "10.0.0.1"
end
test "extracts first 4 bytes from longer binary" do
assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1, 0, 0>>) == "192.168.1.1"
end
test "returns nil for invalid input" do
assert Sanitizer.sanitize_ipv4("not an ip") == nil
assert Sanitizer.sanitize_ipv4(nil) == nil
assert Sanitizer.sanitize_ipv4(123) == nil
end
test "handles subnet mask with 0xff bytes" do
# This was the original bug - 0xff is not valid UTF-8
assert Sanitizer.sanitize_ipv4(<<255, 255, 255, 0>>) == "255.255.255.0"
end
end
describe "sanitize_ipv6/1" do
test "formats 16-byte binary as IPv6" do
# ::1 (localhost)
bytes = <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1>>
assert Sanitizer.sanitize_ipv6(bytes) == "0:0:0:0:0:0:0:1"
end
test "passes through already-formatted string" do
assert Sanitizer.sanitize_ipv6("2001:db8::1") == "2001:db8::1"
end
test "returns nil for invalid input" do
assert Sanitizer.sanitize_ipv6("not an ipv6") == nil
assert Sanitizer.sanitize_ipv6(nil) == nil
end
end
describe "sanitize_mac/1" do
test "formats 6-byte binary as colon-separated hex" do
assert Sanitizer.sanitize_mac(<<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>) == "aa:bb:cc:dd:ee:ff"
end
test "normalizes dash-separated to colon-separated" do
assert Sanitizer.sanitize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff"
end
test "passes through already-formatted lowercase" do
assert Sanitizer.sanitize_mac("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff"
end
test "lowercases uppercase input" do
assert Sanitizer.sanitize_mac("AA:BB:CC:DD:EE:FF") == "aa:bb:cc:dd:ee:ff"
end
test "returns nil for invalid input" do
assert Sanitizer.sanitize_mac("not a mac") == nil
assert Sanitizer.sanitize_mac(nil) == nil
assert Sanitizer.sanitize_mac(<<1, 2, 3>>) == nil
end
end
describe "sanitize_map/1" do
test "sanitizes all string values in map" do
input = %{
name: " Test Name ",
value: 123,
binary: <<255, 0>>
}
result = Sanitizer.sanitize_map(input)
assert result.name == "Test Name"
assert result.value == 123
assert result.binary == "ff:00"
end
test "recursively sanitizes nested maps" do
input = %{
outer: " outer ",
nested: %{
inner: " inner "
}
}
result = Sanitizer.sanitize_map(input)
assert result.outer == "outer"
assert result.nested.inner == "inner"
end
end
end

View file

@ -36,16 +36,20 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
end
test "successfully discovers a device", %{device: device} do
# Mock SNMP responses for discovery (categorize_device_speed + test_connection + system_info = 8 calls)
expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
# Mock SNMP responses - use stub for flexibility as discovery calls vary
stub(SnmpMock, :get, 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]}
# sysUpTime (also used by test_connection)
"1.3.6.1.2.1.1.3.0" -> {:ok, 12_345}
"1.3.6.1.2.1.1.4.0" -> {:ok, "admin@test.com"}
"1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"}
"1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"}
# UCD-SNMP-MIB CPU stats (processor discovery fallback)
"1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10}
"1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5}
"1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85}
_ -> {:error, :timeout}
end
end)
@ -82,8 +86,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
end
test "successfully completes discovery with proper mocks", %{device: device} do
# Mock SNMP responses (categorize_device_speed + test_connection + system_info = 8 calls)
expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
# Mock SNMP responses - use stub for flexibility as discovery calls vary
stub(SnmpMock, :get, 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]}
@ -91,6 +95,11 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
"1.3.6.1.2.1.1.4.0" -> {:ok, ""}
"1.3.6.1.2.1.1.5.0" -> {:ok, "test"}
"1.3.6.1.2.1.1.6.0" -> {:ok, ""}
# UCD-SNMP-MIB CPU stats (processor discovery fallback)
"1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10}
"1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5}
"1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85}
_ -> {:error, :timeout}
end
end)