diff --git a/lib/towerops/snmp/profiles/cisco.ex b/lib/towerops/snmp/profiles/cisco.ex index 5d70e260..677701fa 100644 --- a/lib/towerops/snmp/profiles/cisco.ex +++ b/lib/towerops/snmp/profiles/cisco.ex @@ -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. """ diff --git a/test/towerops/snmp/profiles/cisco_test.exs b/test/towerops/snmp/profiles/cisco_test.exs index e7c3b84b..4c5b8305 100644 --- a/test/towerops/snmp/profiles/cisco_test.exs +++ b/test/towerops/snmp/profiles/cisco_test.exs @@ -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