towerops/lib/towerops/snmp/profiles/base.ex

384 lines
12 KiB
Elixir

defmodule Towerops.Snmp.Profiles.Base do
@moduledoc """
Base SNMP device profile for generic device discovery.
Uses standard MIBs: SNMPv2-MIB, IF-MIB, ENTITY-MIB, ENTITY-SENSOR-MIB.
This profile works with any SNMP-capable device and discovers:
- System information (sysDescr, sysObjectID, sysName, etc.)
- Network interfaces from IF-MIB
- Generic sensors from ENTITY-SENSOR-MIB (if available)
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
require Logger
# Standard MIB OIDs
@system_oids %{
# SNMPv2-MIB::system group
sys_descr: "1.3.6.1.2.1.1.1.0",
sys_object_id: "1.3.6.1.2.1.1.2.0",
sys_uptime: "1.3.6.1.2.1.1.3.0",
sys_contact: "1.3.6.1.2.1.1.4.0",
sys_name: "1.3.6.1.2.1.1.5.0",
sys_location: "1.3.6.1.2.1.1.6.0"
}
@interface_oids %{
# IF-MIB::ifTable - Standard interface table
if_index: "1.3.6.1.2.1.2.2.1.1",
if_descr: "1.3.6.1.2.1.2.2.1.2",
if_type: "1.3.6.1.2.1.2.2.1.3",
if_speed: "1.3.6.1.2.1.2.2.1.5",
if_phys_address: "1.3.6.1.2.1.2.2.1.6",
if_admin_status: "1.3.6.1.2.1.2.2.1.7",
if_oper_status: "1.3.6.1.2.1.2.2.1.8",
# 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"
}
@entity_sensor_oids %{
# 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_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"
}
@doc """
Discovers system information from SNMPv2-MIB.
Returns a map with device metadata.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(client_opts) do
oids = Map.values(@system_oids)
case Client.get_multiple(client_opts, oids) do
{:ok, values} ->
system_info =
@system_oids
|> Map.keys()
|> Enum.zip(values)
|> Map.new()
|> parse_system_info()
{:ok, system_info}
{:error, reason} = error ->
Logger.error("Failed to discover system info: #{inspect(reason)}")
error
end
end
@doc """
Discovers network interfaces from IF-MIB.
Returns a list of interface maps.
"""
@spec discover_interfaces(Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(client_opts) do
with {:ok, if_indices} <- Client.walk(client_opts, @interface_oids.if_index) do
# Extract interface indices
indices = if_indices |> Map.values() |> Enum.map(&parse_integer/1)
# Fetch all interface data in parallel
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
interface_data =
indices
|> Task.async_stream(
fn index -> fetch_interface_data(client_opts, index) end,
max_concurrency: 10,
timeout: 40_000
)
|> Enum.map(fn
{:ok, {:ok, interface}} -> interface
_ -> nil
end)
|> Enum.reject(&is_nil/1)
{:ok, interface_data}
end
end
@doc """
Discovers sensors from ENTITY-SENSOR-MIB.
Returns a list of sensor maps.
"""
@spec discover_sensors(Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
def discover_sensors(client_opts) do
# Try to walk the sensor type OID to see if device supports ENTITY-SENSOR-MIB
case Client.walk(client_opts, @entity_sensor_oids.ent_phys_sensor_type) do
{:ok, sensor_types} when map_size(sensor_types) > 0 ->
# Device supports ENTITY-SENSOR-MIB
sensor_indices = extract_indices_from_oids(sensor_types)
sensors =
sensor_indices
|> Enum.map(fn index ->
build_sensor_from_entity_mib(client_opts, index)
end)
|> Enum.reject(&is_nil/1)
{:ok, sensors}
_ ->
# Device doesn't support ENTITY-SENSOR-MIB, return empty list
Logger.debug("Device does not support ENTITY-SENSOR-MIB")
{:ok, []}
end
end
@doc """
Identifies device manufacturer and model from sysDescr and sysObjectID.
Can be overridden by vendor-specific profiles.
"""
@spec identify_device(Discovery.system_info()) :: Discovery.device_info()
def identify_device(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
sys_object_id = Map.get(system_info, :sys_object_id, "")
{manufacturer, model} = parse_sys_descr(sys_descr, sys_object_id)
Map.merge(system_info, %{
manufacturer: manufacturer,
model: model
})
end
# Private functions
defp parse_system_info(raw_info) do
%{
sys_descr: Map.get(raw_info, :sys_descr),
sys_object_id: parse_oid(Map.get(raw_info, :sys_object_id)),
sys_name: Map.get(raw_info, :sys_name),
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)
}
end
defp parse_oid(oid) when is_list(oid) do
Enum.map_join(oid, ".", &to_string/1)
end
defp parse_oid(oid) when is_binary(oid), do: oid
defp parse_oid(_), do: ""
defp fetch_interface_data(client_opts, index) do
# Fetch required IF-MIB fields (all devices should support these)
required_oids = [
@interface_oids.if_descr <> ".#{index}",
@interface_oids.if_type <> ".#{index}",
@interface_oids.if_speed <> ".#{index}",
@interface_oids.if_phys_address <> ".#{index}",
@interface_oids.if_admin_status <> ".#{index}",
@interface_oids.if_oper_status <> ".#{index}"
]
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)
# 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}")
{:ok,
%{
if_index: index,
if_descr: if_descr,
if_name: if_name,
if_alias: if_alias,
if_type: parse_integer(if_type),
if_speed: parse_integer(if_speed),
if_phys_address: format_mac_address(if_phys_addr),
if_admin_status: parse_if_status(if_admin),
if_oper_status: parse_if_status(if_oper)
}}
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
{:ok, value} -> value
{:error, _} -> nil
end
end
defp build_sensor_from_entity_mib(client_opts, index) do
oids = [
@entity_sensor_oids.ent_phys_sensor_type <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_scale <> ".#{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]} ->
%{
sensor_type: parse_sensor_type(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),
last_value: parse_float(value),
status: parse_sensor_status(status)
}
_ ->
nil
end
end
defp extract_indices_from_oids(oid_map) do
oid_map
|> Map.keys()
|> Enum.map(fn oid_string ->
# Extract the last part of the OID which is the index
oid_string
|> String.split(".")
|> List.last()
|> String.to_integer()
end)
end
defp parse_sys_descr(sys_descr, _sys_object_id) do
cond do
String.contains?(sys_descr, ["Cisco", "IOS"]) ->
{"Cisco", extract_cisco_model(sys_descr)}
String.contains?(sys_descr, "Linux") ->
{"Linux", "Server"}
String.contains?(sys_descr, "Windows") ->
{"Microsoft", "Windows Server"}
true ->
{"Unknown", "Generic Device"}
end
end
defp extract_cisco_model(sys_descr) do
# Try to extract model from sysDescr
# Example: "Cisco IOS Software, C2960 Software..."
case Regex.run(~r/\b(C\d+\w*|WS-C\d+\w*)\b/, sys_descr) do
[_, model] -> model
_ -> "Unknown Model"
end
end
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(""), do: nil
defp parse_integer(value) when is_binary(value), do: String.to_integer(value)
defp parse_integer(_), do: nil
defp parse_float(value) when is_float(value), do: value
defp parse_float(value) when is_integer(value), do: value / 1.0
defp parse_float(value) when is_binary(value), do: String.to_float(value)
defp parse_float(_), do: nil
defp parse_if_status(1), do: "up"
defp parse_if_status(2), do: "down"
defp parse_if_status(3), do: "testing"
defp parse_if_status(_), do: "unknown"
defp format_mac_address(<<>>) when is_binary(<<>>), do: nil
defp format_mac_address(nil), do: nil
defp format_mac_address(mac) when is_binary(mac) do
mac
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
defp format_mac_address(_), do: nil
# ENTITY-SENSOR-MIB sensor types
defp parse_sensor_type(1), do: "other"
defp parse_sensor_type(2), do: "unknown"
defp parse_sensor_type(3), do: "volts"
defp parse_sensor_type(4), do: "volts"
defp parse_sensor_type(5), do: "amperes"
defp parse_sensor_type(6), do: "watts"
defp parse_sensor_type(7), do: "hertz"
defp parse_sensor_type(8), do: "celsius"
defp parse_sensor_type(9), do: "percent"
defp parse_sensor_type(10), do: "rpm"
defp parse_sensor_type(11), do: "cmm"
defp parse_sensor_type(_), do: "unknown"
defp sensor_type_to_unit("celsius"), do: "°C"
defp sensor_type_to_unit("volts"), do: "V"
defp sensor_type_to_unit("amperes"), do: "A"
defp sensor_type_to_unit("watts"), do: "W"
defp sensor_type_to_unit("hertz"), do: "Hz"
defp sensor_type_to_unit("percent"), do: "%"
defp sensor_type_to_unit("rpm"), do: "RPM"
defp sensor_type_to_unit(_), do: ""
# ENTITY-SENSOR-MIB scale values (10^scale)
defp scale_to_divisor(-24), do: 1_000_000_000_000_000_000_000_000
defp scale_to_divisor(-9), do: 1_000_000_000
defp scale_to_divisor(-3), do: 1_000
defp scale_to_divisor(0), do: 1
defp scale_to_divisor(_), do: 1
defp parse_sensor_status(1), do: "ok"
defp parse_sensor_status(2), do: "unavailable"
defp parse_sensor_status(3), do: "nonoperational"
defp parse_sensor_status(_), do: "unknown"
@doc """
Collects raw debug data for troubleshooting.
Includes system OIDs and interface/sensor tables.
"""
def collect_raw_debug_data(client_opts) do
# Collect system OID values
system_oids =
Map.new(@system_oids, fn {key, oid} ->
value =
case Client.get(client_opts, oid) do
{:ok, val} -> val
{:error, reason} -> "ERROR: #{inspect(reason)}"
end
{key, %{oid: oid, value: value}}
end)
# Walk interface tables
if_table =
case Client.walk(client_opts, "1.3.6.1.2.1.2.2") do
{:ok, results} -> results
{:error, _} -> %{}
end
ifx_table =
case Client.walk(client_opts, "1.3.6.1.2.1.31.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
# Walk entity sensor table
entity_sensor_table =
case Client.walk(client_opts, "1.3.6.1.2.1.99.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
%{
timestamp: DateTime.utc_now(),
system_oids: system_oids,
if_table: if_table,
ifx_table: ifx_table,
entity_sensor_table: entity_sensor_table
}
end
end