towerops/lib/towerops/snmp/profiles/cisco.ex
Graham McIntire f8c417a338
feat: add Cisco VTP VLAN discovery
- Add discover_vlans/1 to Cisco profile for VTP MIB discovery
- VTP OIDs: vtpVlanName, vtpVlanState, vtpVlanType
- Falls back to Q-BRIDGE-MIB when VTP not available
- Maps VTP states (operational, suspended, mtuTooBig)
- Maps VTP types (ethernet, fddi, tokenRing, etc.)
- Validates VLAN ID range (1-4094)
2026-01-21 11:32:18 -06:00

379 lines
12 KiB
Elixir

defmodule Towerops.Snmp.Profiles.Cisco do
@moduledoc """
Cisco-specific SNMP device profile.
Extends Base profile with Cisco-specific sensor discovery from CISCO-ENTITY-SENSOR-MIB.
Discovers additional sensors:
- Temperature sensors (chassis, modules, power supplies)
- Power supply sensors
- Fan speed sensors
- Voltage sensors
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Profiles.Base
require Logger
@cisco_sensor_oids %{
# CISCO-ENTITY-SENSOR-MIB
ent_sensor_type: "1.3.6.1.4.1.9.9.91.1.1.1.1.1",
ent_sensor_scale: "1.3.6.1.4.1.9.9.91.1.1.1.1.2",
ent_sensor_precision: "1.3.6.1.4.1.9.9.91.1.1.1.1.3",
ent_sensor_value: "1.3.6.1.4.1.9.9.91.1.1.1.1.4",
ent_sensor_status: "1.3.6.1.4.1.9.9.91.1.1.1.1.5",
# ENTITY-MIB for physical descriptions
ent_phys_descr: "1.3.6.1.2.1.47.1.1.1.1.2",
ent_phys_name: "1.3.6.1.2.1.47.1.1.1.1.7"
}
@cisco_vtp_oids %{
# CISCO-VTP-MIB - VTP VLAN discovery
vtp_vlan_state: "1.3.6.1.4.1.9.9.46.1.3.1.1.2",
vtp_vlan_type: "1.3.6.1.4.1.9.9.46.1.3.1.1.3",
vtp_vlan_name: "1.3.6.1.4.1.9.9.46.1.3.1.1.4"
}
@doc """
Discovers system information.
Cisco profile doesn't query additional system OIDs beyond Base profile.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(_client_opts) do
# No additional system info beyond Base profile
{:ok, %{}}
end
@doc """
Discovers network interfaces using Base profile.
"""
@spec discover_interfaces(Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(client_opts) do
Base.discover_interfaces(client_opts)
end
@doc """
Discovers sensors from both standard ENTITY-SENSOR-MIB and CISCO-ENTITY-SENSOR-MIB.
Cisco-specific sensors provide more detailed temperature, power, and fan monitoring.
"""
@spec discover_sensors(Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
def discover_sensors(client_opts) do
# Try Cisco-specific sensors first
case discover_cisco_sensors(client_opts) do
{:ok, [_ | _] = sensors} ->
{:ok, sensors}
_ ->
# Fall back to standard ENTITY-SENSOR-MIB
Logger.debug("Cisco-specific sensors not available, using standard discovery")
Base.discover_sensors(client_opts)
end
end
@doc """
Discovers VLANs from Cisco VTP MIB, falling back to Q-BRIDGE-MIB if VTP not available.
"""
@spec discover_vlans(Client.connection_opts()) :: {:ok, [map()]}
def discover_vlans(client_opts) do
# Try Cisco VTP MIB first
case Client.walk(client_opts, @cisco_vtp_oids.vtp_vlan_name) do
{:ok, name_results} when is_map(name_results) and map_size(name_results) > 0 ->
do_discover_vtp_vlans(client_opts, name_results)
_ ->
# Fall back to Q-BRIDGE-MIB
Logger.debug("Cisco VTP MIB not available, falling back to Q-BRIDGE-MIB")
Base.discover_vlans(client_opts)
end
end
defp do_discover_vtp_vlans(client_opts, name_results) do
# Fetch VLAN states
state_map =
case Client.walk(client_opts, @cisco_vtp_oids.vtp_vlan_state) do
{:ok, results} when is_map(results) -> build_vtp_vlan_index_map(results)
_ -> %{}
end
# Fetch VLAN types
type_map =
case Client.walk(client_opts, @cisco_vtp_oids.vtp_vlan_type) do
{:ok, results} when is_map(results) -> build_vtp_vlan_index_map(results)
_ -> %{}
end
# Build VLAN list from names (name_results is a map of OID -> name)
vlans =
name_results
|> Enum.map(fn {oid, name} ->
vlan_id = extract_vtp_vlan_id_from_oid(oid)
state = Map.get(state_map, vlan_id, 1)
type = Map.get(type_map, vlan_id, 1)
if valid_vlan_id?(vlan_id) do
%{
vlan_id: vlan_id,
vlan_name: name,
vlan_type: vtp_vlan_type_to_string(type),
status: vtp_vlan_state_to_status(state),
last_checked_at: DateTime.utc_now()
}
end
end)
|> Enum.reject(&is_nil/1)
Logger.debug("Discovered #{length(vlans)} VLANs from Cisco VTP MIB")
{:ok, vlans}
end
defp build_vtp_vlan_index_map(results) when is_map(results) do
Map.new(results, fn {oid, value} ->
vlan_id = extract_vtp_vlan_id_from_oid(oid)
{vlan_id, value}
end)
end
# VTP VLAN OIDs are indexed by managementDomainIndex.vlanIndex
# e.g., 1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.100 = domain 1, VLAN 100
defp extract_vtp_vlan_id_from_oid(oid) when is_binary(oid) do
oid
|> String.split(".")
|> List.last()
|> String.to_integer()
rescue
_ -> 0
end
defp extract_vtp_vlan_id_from_oid(_), do: 0
defp valid_vlan_id?(vlan_id) when is_integer(vlan_id) do
vlan_id >= 1 and vlan_id <= 4094
end
defp valid_vlan_id?(_), do: false
# CISCO-VTP-MIB vtpVlanState values:
# 1=operational, 2=suspended, 3=mtuTooBigForDevice, 4=mtuTooBigForTrunk
defp vtp_vlan_state_to_status(1), do: "active"
defp vtp_vlan_state_to_status(_), do: "suspended"
# CISCO-VTP-MIB vtpVlanType values:
# 1=ethernet, 2=fddi, 3=tokenRing, 4=fddiNet, 5=trNet, 6=deprecated
defp vtp_vlan_type_to_string(1), do: "ethernet"
defp vtp_vlan_type_to_string(2), do: "fddi"
defp vtp_vlan_type_to_string(3), do: "tokenRing"
defp vtp_vlan_type_to_string(4), do: "fddiNet"
defp vtp_vlan_type_to_string(5), do: "trNet"
defp vtp_vlan_type_to_string(_), do: "ethernet"
@doc """
Identifies Cisco device model more accurately from sysDescr.
"""
@spec identify_device(Discovery.system_info()) :: Discovery.device_info()
def identify_device(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
model = extract_cisco_model_detailed(sys_descr)
Map.merge(system_info, %{
manufacturer: "Cisco",
model: model,
firmware_version: extract_firmware_version(sys_descr)
})
end
# Private functions
defp discover_cisco_sensors(client_opts) do
case Client.walk(client_opts, @cisco_sensor_oids.ent_sensor_type) do
{:ok, sensor_types} when map_size(sensor_types) > 0 ->
sensor_indices = extract_indices_from_oids(sensor_types)
sensors =
sensor_indices
|> Enum.map(fn index ->
build_cisco_sensor(client_opts, index)
end)
|> Enum.reject(&is_nil/1)
{:ok, sensors}
_ ->
{:error, :cisco_sensors_not_available}
end
end
defp build_cisco_sensor(client_opts, index) do
oids = [
@cisco_sensor_oids.ent_sensor_type <> ".#{index}",
@cisco_sensor_oids.ent_sensor_scale <> ".#{index}",
@cisco_sensor_oids.ent_sensor_precision <> ".#{index}",
@cisco_sensor_oids.ent_sensor_value <> ".#{index}",
@cisco_sensor_oids.ent_sensor_status <> ".#{index}",
@cisco_sensor_oids.ent_phys_descr <> ".#{index}",
@cisco_sensor_oids.ent_phys_name <> ".#{index}"
]
case Client.get_multiple(client_opts, oids) do
{:ok, [type, scale, precision, value, status, descr, name]} ->
sensor_type = parse_cisco_sensor_type(type)
divisor = calculate_divisor(scale, precision)
%{
sensor_type: sensor_type,
sensor_index: "#{index}",
sensor_oid: @cisco_sensor_oids.ent_sensor_value <> ".#{index}",
sensor_descr: parse_sensor_description(descr, name),
sensor_unit: cisco_sensor_type_to_unit(sensor_type),
sensor_divisor: divisor,
last_value: parse_sensor_value(value, divisor),
status: parse_cisco_sensor_status(status)
}
{:error, reason} ->
Logger.debug("Failed to fetch Cisco sensor #{index}: #{inspect(reason)}")
nil
end
end
defp extract_indices_from_oids(oid_map) do
oid_map
|> Map.keys()
|> Enum.map(fn oid_string ->
oid_string
|> String.split(".")
|> List.last()
|> String.to_integer()
end)
end
defp extract_cisco_model_detailed(sys_descr) do
cond do
# Catalyst switches: "Cisco IOS Software, C2960 Software..."
match = Regex.run(~r/\b(C\d+\w*|WS-C\d+\w*)\b/, sys_descr) ->
[_, model] = match
model
# ISR routers: "Cisco IOS Software, 1900 Software..."
match = Regex.run(~r/\b(ISR\d+\w*|[0-9]{4})\b/, sys_descr) ->
[_, model] = match
model
# ASR routers
match = Regex.run(~r/\b(ASR\d+\w*)\b/, sys_descr) ->
[_, model] = match
model
true ->
"Unknown Cisco Model"
end
end
defp extract_firmware_version(sys_descr) do
# Try to extract version like "Version 15.2(4)E7"
case Regex.run(~r/Version\s+([\d.()A-Za-z]+)/, sys_descr) do
[_, version] -> version
_ -> nil
end
end
# CISCO-ENTITY-SENSOR-MIB sensor types
defp parse_cisco_sensor_type(1), do: "other"
defp parse_cisco_sensor_type(2), do: "unknown"
defp parse_cisco_sensor_type(3), do: "volts_ac"
defp parse_cisco_sensor_type(4), do: "volts_dc"
defp parse_cisco_sensor_type(5), do: "amperes"
defp parse_cisco_sensor_type(6), do: "watts"
defp parse_cisco_sensor_type(7), do: "hertz"
defp parse_cisco_sensor_type(8), do: "celsius"
defp parse_cisco_sensor_type(9), do: "percent_rh"
defp parse_cisco_sensor_type(10), do: "rpm"
defp parse_cisco_sensor_type(11), do: "cmm"
defp parse_cisco_sensor_type(12), do: "truthvalue"
defp parse_cisco_sensor_type(13), do: "specialenum"
defp parse_cisco_sensor_type(14), do: "dBm"
defp parse_cisco_sensor_type(_), do: "unknown"
defp cisco_sensor_type_to_unit("celsius"), do: "°C"
defp cisco_sensor_type_to_unit("volts_ac"), do: "VAC"
defp cisco_sensor_type_to_unit("volts_dc"), do: "VDC"
defp cisco_sensor_type_to_unit("amperes"), do: "A"
defp cisco_sensor_type_to_unit("watts"), do: "W"
defp cisco_sensor_type_to_unit("hertz"), do: "Hz"
defp cisco_sensor_type_to_unit("percent_rh"), do: "%RH"
defp cisco_sensor_type_to_unit("rpm"), do: "RPM"
defp cisco_sensor_type_to_unit("dBm"), do: "dBm"
defp cisco_sensor_type_to_unit(_), do: ""
# Calculate divisor from scale and precision
# Scale: 10^scale (e.g., -3 = milli, 0 = units)
# Precision: number of decimal places
defp calculate_divisor(scale, _precision) when is_integer(scale) do
case scale do
-24 -> 1_000_000_000_000_000_000_000_000
-12 -> 1_000_000_000_000
-9 -> 1_000_000_000
-6 -> 1_000_000
-3 -> 1_000
0 -> 1
3 -> 0.001
_ -> 1
end
end
defp calculate_divisor(_, _), do: 1
defp parse_sensor_value(value, divisor) when is_integer(value) and is_number(divisor) do
value / divisor
end
defp parse_sensor_value(_, _), do: nil
defp parse_sensor_description(descr, _name) when is_binary(descr) and byte_size(descr) > 0 do
descr
end
defp parse_sensor_description(_descr, name) when is_binary(name) and byte_size(name) > 0 do
name
end
defp parse_sensor_description(_, _), do: "Unknown Sensor"
# CISCO-ENTITY-SENSOR-MIB sensor status
defp parse_cisco_sensor_status(1), do: "ok"
defp parse_cisco_sensor_status(2), do: "unavailable"
defp parse_cisco_sensor_status(3), do: "nonoperational"
defp parse_cisco_sensor_status(_), do: "unknown"
@doc """
Collects raw debug data for troubleshooting.
Includes Cisco-specific sensor OIDs and entity tables.
"""
def collect_raw_debug_data(client_opts) do
# Walk Cisco entity sensor table
cisco_sensor_table =
case Client.walk(client_opts, "1.3.6.1.4.1.9.9.91.1.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
# Walk entity physical table for descriptions
entity_phys_table =
case Client.walk(client_opts, "1.3.6.1.2.1.47.1.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
%{
timestamp: DateTime.utc_now(),
cisco_sensor_oids: @cisco_sensor_oids,
cisco_sensor_table: cisco_sensor_table,
entity_phys_table: entity_phys_table
}
end
end