384 lines
12 KiB
Elixir
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
|