diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index c10cd49a..84271782 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -71,6 +71,12 @@ defmodule Towerops.Snmp.Profiles.Base do ent_state_oper: "1.3.6.1.2.1.131.1.1.1.1" } + @q_bridge_oids %{ + # Q-BRIDGE-MIB - IEEE 802.1Q VLAN discovery + dot1q_vlan_static_name: "1.3.6.1.2.1.17.7.1.4.3.1.1", + dot1q_vlan_static_row_status: "1.3.6.1.2.1.17.7.1.4.3.1.5" + } + # ENTITY-MIB physical class values to track for state sensors # 6 = powerSupply, 7 = fan @state_sensor_classes [6, 7] @@ -262,6 +268,92 @@ defmodule Towerops.Snmp.Profiles.Base do {:ok, state_sensors} end + @doc """ + Discovers VLANs from Q-BRIDGE-MIB. + Returns a list of VLAN maps with vlan_id, vlan_name, and status. + """ + @spec discover_vlans(Client.connection_opts()) :: {:ok, [map()]} + def discover_vlans(client_opts) do + # Walk VLAN names from Q-BRIDGE-MIB + case Client.walk(client_opts, @q_bridge_oids.dot1q_vlan_static_name) do + {:ok, name_results} when is_map(name_results) and map_size(name_results) > 0 -> + do_discover_vlans(client_opts, name_results) + + {:ok, _} -> + # No VLANs found via name walk, try status walk + try_vlan_status_walk(client_opts) + + {:error, reason} -> + Logger.debug("Q-BRIDGE-MIB VLAN name walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_vlans(client_opts, name_results) do + # Fetch VLAN status + status_map = + case Client.walk(client_opts, @q_bridge_oids.dot1q_vlan_static_row_status) do + {:ok, results} when is_map(results) -> build_vlan_index_map(results) + _ -> %{} + end + + # Build VLAN list from names + vlans = + name_results + |> Enum.map(fn {oid, name} -> + vlan_id = extract_vlan_id_from_oid(oid) + status = Map.get(status_map, vlan_id, 1) + + if valid_vlan_id?(vlan_id) do + %{ + vlan_id: vlan_id, + vlan_name: name, + vlan_type: "ethernet", + status: vlan_row_status_to_status(status), + last_checked_at: DateTime.utc_now() + } + end + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(vlans)} VLANs from Q-BRIDGE-MIB") + {:ok, vlans} + end + + defp try_vlan_status_walk(client_opts) do + # Try to discover VLANs from status table (some devices only have this) + case Client.walk(client_opts, @q_bridge_oids.dot1q_vlan_static_row_status) do + {:ok, status_results} when is_map(status_results) and map_size(status_results) > 0 -> + vlans = build_vlans_from_status(status_results) + Logger.debug("Discovered #{length(vlans)} VLANs from status walk") + {:ok, vlans} + + _ -> + Logger.debug("Device does not support Q-BRIDGE-MIB") + {:ok, []} + end + end + + defp build_vlans_from_status(status_results) do + status_results + |> Enum.map(&build_vlan_from_status/1) + |> Enum.reject(&is_nil/1) + end + + defp build_vlan_from_status({oid, status}) do + vlan_id = extract_vlan_id_from_oid(oid) + + if valid_vlan_id?(vlan_id) do + %{ + vlan_id: vlan_id, + vlan_name: "VLAN #{vlan_id}", + vlan_type: "ethernet", + status: vlan_row_status_to_status(status), + last_checked_at: DateTime.utc_now() + } + end + end + @doc """ Identifies device manufacturer and model from sysDescr and sysObjectID. Can be overridden by vendor-specific profiles. @@ -585,6 +677,41 @@ defmodule Towerops.Snmp.Profiles.Base do defp entity_state_to_descr(4), do: "testing" defp entity_state_to_descr(_), do: "unknown" + # VLAN helper functions + + defp build_vlan_index_map(walk_map) when is_map(walk_map) do + Map.new(walk_map, fn {oid, value} -> + vlan_id = extract_vlan_id_from_oid(oid) + {vlan_id, value} + end) + end + + defp build_vlan_index_map(_), do: %{} + + defp extract_vlan_id_from_oid(oid) when is_binary(oid) do + oid + |> String.split(".") + |> List.last() + |> String.to_integer() + rescue + _ -> 0 + end + + defp extract_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 + + # Q-BRIDGE-MIB RowStatus values: + # 1=active, 2=notInService, 3=notReady, 4=createAndGo, 5=createAndWait, 6=destroy + defp vlan_row_status_to_status(1), do: "active" + defp vlan_row_status_to_status(2), do: "suspended" + defp vlan_row_status_to_status(3), do: "suspended" + defp vlan_row_status_to_status(_), do: "active" + @doc """ Collects raw debug data for troubleshooting. Includes system OIDs and interface/sensor tables. diff --git a/lib/towerops/snmp/vlan.ex b/lib/towerops/snmp/vlan.ex new file mode 100644 index 00000000..3250ae30 --- /dev/null +++ b/lib/towerops/snmp/vlan.ex @@ -0,0 +1,73 @@ +defmodule Towerops.Snmp.Vlan do + @moduledoc """ + SNMP VLAN schema for tracking VLANs discovered on network devices. + + VLANs (Virtual LANs) segment network traffic and are discovered via: + - Q-BRIDGE-MIB (IEEE 802.1Q standard) + - Cisco VTP MIB (Cisco-specific) + + Status values: + - active: VLAN is operational + - suspended: VLAN is administratively suspended + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Snmp.Device + + @valid_statuses ~w(active suspended) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_vlans" do + field :vlan_id, :integer + field :vlan_name, :string + field :vlan_type, :string + field :status, :string, default: "active" + field :last_checked_at, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :snmp_device, Device + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + vlan_id: integer(), + vlan_name: String.t() | nil, + vlan_type: String.t() | nil, + status: String.t(), + last_checked_at: DateTime.t() | nil, + metadata: map(), + snmp_device_id: Ecto.UUID.t(), + snmp_device: NotLoaded.t() | Device.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @doc false + def changeset(vlan, attrs) do + vlan + |> cast(attrs, [ + :snmp_device_id, + :vlan_id, + :vlan_name, + :vlan_type, + :status, + :last_checked_at, + :metadata + ]) + |> validate_required([:snmp_device_id, :vlan_id]) + |> validate_inclusion(:status, @valid_statuses) + |> validate_number(:vlan_id, + greater_than_or_equal_to: 1, + less_than_or_equal_to: 4094, + message: "must be between 1 and 4094" + ) + |> unique_constraint([:snmp_device_id, :vlan_id]) + |> foreign_key_constraint(:snmp_device_id) + end +end diff --git a/priv/repo/migrations/20260121162517_create_snmp_vlans.exs b/priv/repo/migrations/20260121162517_create_snmp_vlans.exs new file mode 100644 index 00000000..60fd4a12 --- /dev/null +++ b/priv/repo/migrations/20260121162517_create_snmp_vlans.exs @@ -0,0 +1,25 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpVlans do + use Ecto.Migration + + def change do + create table(:snmp_vlans, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all), + null: false + + add :vlan_id, :integer, null: false + add :vlan_name, :string + add :vlan_type, :string + add :status, :string, default: "active" + add :last_checked_at, :utc_datetime + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:snmp_vlans, [:snmp_device_id]) + create unique_index(:snmp_vlans, [:snmp_device_id, :vlan_id]) + create index(:snmp_vlans, [:status]) + end +end diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs index 7d0034ac..cdfef311 100644 --- a/test/towerops/snmp/profiles/base_test.exs +++ b/test/towerops/snmp/profiles/base_test.exs @@ -548,4 +548,143 @@ defmodule Towerops.Snmp.Profiles.BaseTest do assert statuses["4"] == "unknown" end end + + describe "discover_vlans/1" do + test "discovers VLANs from Q-BRIDGE-MIB" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # dot1qVlanStaticName - VLAN names + "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: {:octet_string, "default"}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.10", value: {:octet_string, "Management"}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.100", value: {:octet_string, "Production"}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.200", value: {:octet_string, "Guest"}} + ]} + + # dot1qVlanStaticRowStatus - VLAN status + "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: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.10", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.100", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.200", value: {:integer, 2}} + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, vlans} = Base.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" + + 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 "returns empty list when Q-BRIDGE-MIB not supported" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, vlans} = Base.discover_vlans(@client_opts) + assert vlans == [] + end + + test "handles walk errors gracefully" do + stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end) + + assert {:ok, vlans} = Base.discover_vlans(@client_opts) + assert vlans == [] + end + + test "handles VLANs with missing names" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # dot1qVlanStaticName - Only some VLANs have names + "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: {:octet_string, "default"}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.10", value: {:octet_string, "Management"}} + ]} + + # dot1qVlanStaticRowStatus - More VLANs exist than have names + "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: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.10", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.50", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.100", value: {:integer, 1}} + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, vlans} = Base.discover_vlans(@client_opts) + + # Only 2 VLANs from the name walk (status-only VLANs require fallback) + assert length(vlans) == 2 + + # Named VLANs + vlan1 = Enum.find(vlans, &(&1.vlan_id == 1)) + assert vlan1.vlan_name == "default" + + vlan10 = Enum.find(vlans, &(&1.vlan_id == 10)) + assert vlan10.vlan_name == "Management" + end + + test "validates VLAN ID range (1-4094)" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + "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.0", value: {:octet_string, "invalid"}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.1", value: {:octet_string, "default"}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.4094", value: {:octet_string, "max"}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.1.4095", value: {:octet_string, "too_high"}} + ]} + + "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.0", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.1", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.4094", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.17.7.1.4.3.1.5.4095", value: {:integer, 1}} + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, vlans} = Base.discover_vlans(@client_opts) + + vlan_ids = Enum.map(vlans, & &1.vlan_id) + + # Should include valid VLANs + assert 1 in vlan_ids + assert 4094 in vlan_ids + + # Should exclude invalid VLANs (0 and 4095+) + refute 0 in vlan_ids + refute 4095 in vlan_ids + end + end end diff --git a/test/towerops/snmp/vlan_test.exs b/test/towerops/snmp/vlan_test.exs new file mode 100644 index 00000000..c2161052 --- /dev/null +++ b/test/towerops/snmp/vlan_test.exs @@ -0,0 +1,220 @@ +defmodule Towerops.Snmp.VlanTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device + alias Towerops.Snmp.Vlan + + setup do + user = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, device_schema} = + Towerops.Devices.create_device(%{ + name: "Test Switch", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id + }) + + snmp_device = + %Device{} + |> Device.changeset(%{ + device_id: device_schema.id, + sys_name: "test-switch", + sys_descr: "Test Switch" + }) + |> Repo.insert!() + + %{device: device_schema, snmp_device: snmp_device} + end + + describe "changeset/2" do + test "valid changeset with all required fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: 100 + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + assert changeset.valid? + end + + test "valid changeset with all fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: 100, + vlan_name: "Production", + vlan_type: "ethernet", + status: "active", + last_checked_at: DateTime.utc_now(), + metadata: %{"source" => "q-bridge"} + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + assert changeset.valid? + assert get_field(changeset, :vlan_name) == "Production" + assert get_field(changeset, :vlan_type) == "ethernet" + assert get_field(changeset, :status) == "active" + end + + test "invalid changeset without snmp_device_id" do + attrs = %{ + vlan_id: 100 + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).snmp_device_id + end + + test "invalid changeset without vlan_id", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).vlan_id + end + + test "invalid status value", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: 100, + status: "invalid_status" + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).status + end + + test "valid status values", %{snmp_device: snmp_device} do + for status <- ~w(active suspended) do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: Enum.random(100..999), + status: status + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + assert changeset.valid?, "status '#{status}' should be valid" + end + end + + test "default status is active", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: 100 + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + assert changeset.valid? + end + + test "validates vlan_id range", %{snmp_device: snmp_device} do + # VLAN IDs must be between 1 and 4094 + for invalid_id <- [0, -1, 4095, 5000] do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: invalid_id + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + refute changeset.valid?, "vlan_id #{invalid_id} should be invalid" + assert "must be between 1 and 4094" in errors_on(changeset).vlan_id + end + end + + test "accepts valid vlan_id range", %{snmp_device: snmp_device} do + for valid_id <- [1, 100, 1000, 4094] do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: valid_id + } + + changeset = Vlan.changeset(%Vlan{}, attrs) + assert changeset.valid?, "vlan_id #{valid_id} should be valid" + end + end + end + + describe "unique constraint" do + test "prevents duplicate VLANs with same device and vlan_id", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + vlan_id: 100, + vlan_name: "Production" + } + + # Insert first VLAN + changeset = Vlan.changeset(%Vlan{}, attrs) + assert {:ok, _vlan} = Repo.insert(changeset) + + # Attempt to insert duplicate + changeset2 = Vlan.changeset(%Vlan{}, attrs) + + assert {:error, changeset} = Repo.insert(changeset2) + errors = errors_on(changeset) + + assert "has already been taken" in Map.get(errors, :vlan_id, []) or + "has already been taken" in Map.get(errors, :snmp_device_id, []) + end + + test "allows same vlan_id for different devices", %{device: device_schema} do + # Create second device + {:ok, device2} = + Towerops.Devices.create_device(%{ + name: "Test Router", + ip_address: "192.168.1.2", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: device_schema.site_id + }) + + snmp_device2 = + %Device{} + |> Device.changeset(%{ + device_id: device2.id, + sys_name: "test-router", + sys_descr: "Test Router" + }) + |> Repo.insert!() + + snmp_device1 = Repo.get_by!(Device, device_id: device_schema.id) + + attrs1 = %{ + snmp_device_id: snmp_device1.id, + vlan_id: 100, + vlan_name: "Production" + } + + attrs2 = %{ + snmp_device_id: snmp_device2.id, + vlan_id: 100, + vlan_name: "Production" + } + + changeset1 = Vlan.changeset(%Vlan{}, attrs1) + assert {:ok, _vlan1} = Repo.insert(changeset1) + + changeset2 = Vlan.changeset(%Vlan{}, attrs2) + assert {:ok, _vlan2} = Repo.insert(changeset2) + end + end +end