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)
This commit is contained in:
Graham McIntire 2026-01-21 11:32:18 -06:00
parent 7e1fc35f2c
commit f8c417a338
No known key found for this signature in database
2 changed files with 320 additions and 0 deletions

View file

@ -29,6 +29,13 @@ defmodule Towerops.Snmp.Profiles.Cisco do
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.
@ -68,6 +75,102 @@ defmodule Towerops.Snmp.Profiles.Cisco do
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.
"""

View file

@ -171,6 +171,223 @@ defmodule Towerops.Snmp.Profiles.CiscoTest do
end
end
describe "discover_vlans/1" do
test "discovers VLANs from Cisco VTP MIB" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# vtpVlanName - VLAN names
"1.3.6.1.4.1.9.9.46.1.3.1.1.4" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.1", value: "default"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.10", value: "Management"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.100", value: "Production"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.200", value: "Guest"}
]}
# vtpVlanState - VLAN status (1=operational, 2=suspended)
"1.3.6.1.4.1.9.9.46.1.3.1.1.2" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.1", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.10", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.100", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.200", value: 2}
]}
# vtpVlanType - VLAN type (1=ethernet)
"1.3.6.1.4.1.9.9.46.1.3.1.1.3" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.3.1.1", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.3.1.10", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.3.1.100", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.3.1.200", value: 1}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, vlans} = Cisco.discover_vlans(@client_opts)
assert length(vlans) == 4
vlan1 = Enum.find(vlans, &(&1.vlan_id == 1))
assert vlan1.vlan_name == "default"
assert vlan1.status == "active"
assert vlan1.vlan_type == "ethernet"
vlan100 = Enum.find(vlans, &(&1.vlan_id == 100))
assert vlan100.vlan_name == "Production"
assert vlan100.status == "active"
vlan200 = Enum.find(vlans, &(&1.vlan_id == 200))
assert vlan200.vlan_name == "Guest"
assert vlan200.status == "suspended"
end
test "falls back to Q-BRIDGE-MIB when VTP not available" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# VTP returns empty
"1.3.6.1.4.1.9.9.46.1.3.1.1.4" ->
{:ok, []}
# Q-BRIDGE-MIB has VLANs
"1.3.6.1.2.1.17.7.1.4.3.1.1" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.1", value: "default"},
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.10", value: "Management"}
]}
"1.3.6.1.2.1.17.7.1.4.3.1.5" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.1", value: 1},
%{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.10", value: 1}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, vlans} = Cisco.discover_vlans(@client_opts)
# Should have found VLANs via Q-BRIDGE fallback
assert length(vlans) == 2
end
test "returns empty list when no VLANs found" do
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert {:ok, vlans} = Cisco.discover_vlans(@client_opts)
assert vlans == []
end
test "handles VTP walk errors gracefully" do
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
assert {:ok, vlans} = Cisco.discover_vlans(@client_opts)
assert vlans == []
end
test "handles various VTP VLAN states" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.9.9.46.1.3.1.1.4" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.1", value: "vlan1"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.2", value: "vlan2"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.3", value: "vlan3"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.4", value: "vlan4"}
]}
"1.3.6.1.4.1.9.9.46.1.3.1.1.2" ->
{:ok,
[
# 1=operational, 2=suspended, 3=mtuTooBigForDevice, 4=mtuTooBigForTrunk
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.1", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.2", value: 2},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.3", value: 3},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.2.1.4", value: 4}
]}
"1.3.6.1.4.1.9.9.46.1.3.1.1.3" ->
{:ok, []}
_ ->
{:ok, []}
end
end)
assert {:ok, vlans} = Cisco.discover_vlans(@client_opts)
vlan1 = Enum.find(vlans, &(&1.vlan_id == 1))
assert vlan1.status == "active"
vlan2 = Enum.find(vlans, &(&1.vlan_id == 2))
assert vlan2.status == "suspended"
vlan3 = Enum.find(vlans, &(&1.vlan_id == 3))
assert vlan3.status == "suspended"
vlan4 = Enum.find(vlans, &(&1.vlan_id == 4))
assert vlan4.status == "suspended"
end
test "handles various VTP VLAN types" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.9.9.46.1.3.1.1.4" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.1", value: "vlan1"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.2", value: "vlan2"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.3", value: "vlan3"}
]}
"1.3.6.1.4.1.9.9.46.1.3.1.1.2" ->
{:ok, []}
"1.3.6.1.4.1.9.9.46.1.3.1.1.3" ->
{:ok,
[
# 1=ethernet, 2=fddi, 3=tokenRing
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.3.1.1", value: 1},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.3.1.2", value: 2},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.3.1.3", value: 3}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, vlans} = Cisco.discover_vlans(@client_opts)
vlan1 = Enum.find(vlans, &(&1.vlan_id == 1))
assert vlan1.vlan_type == "ethernet"
vlan2 = Enum.find(vlans, &(&1.vlan_id == 2))
assert vlan2.vlan_type == "fddi"
vlan3 = Enum.find(vlans, &(&1.vlan_id == 3))
assert vlan3.vlan_type == "tokenRing"
end
test "validates VLAN ID range (1-4094)" do
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.9.9.46.1.3.1.1.4" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.0", value: "invalid0"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.1", value: "valid1"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.4094", value: "valid4094"},
%{oid: "1.3.6.1.4.1.9.9.46.1.3.1.1.4.1.4095", value: "invalid4095"}
]}
_ ->
{:ok, []}
end
end)
assert {:ok, vlans} = Cisco.discover_vlans(@client_opts)
vlan_ids = Enum.map(vlans, & &1.vlan_id)
assert 1 in vlan_ids
assert 4094 in vlan_ids
refute 0 in vlan_ids
refute 4095 in vlan_ids
end
end
describe "sensor type parsing" do
test "attempts to discover Cisco-specific sensors" do
# Mock walk to indicate Cisco sensors available