add VLAN schema, migration, and discovery

This commit is contained in:
Graham McIntire 2026-01-21 10:31:21 -06:00
parent c3e26a44d6
commit 579a7bac21
No known key found for this signature in database
5 changed files with 584 additions and 0 deletions

View file

@ -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.

73
lib/towerops/snmp/vlan.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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