mac discovery
This commit is contained in:
parent
7bd3b1f9ec
commit
80d1864700
10 changed files with 1023 additions and 28 deletions
|
|
@ -16,6 +16,7 @@ defmodule Towerops.Snmp do
|
|||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.InterfaceStat
|
||||
alias Towerops.Snmp.IpAddress
|
||||
alias Towerops.Snmp.MacAddress
|
||||
alias Towerops.Snmp.Neighbor
|
||||
alias Towerops.Snmp.Processor
|
||||
alias Towerops.Snmp.ProcessorReading
|
||||
|
|
@ -815,4 +816,72 @@ defmodule Towerops.Snmp do
|
|||
|
||||
length(arp_entries)
|
||||
end
|
||||
|
||||
# MAC Address queries
|
||||
|
||||
@doc """
|
||||
Lists all MAC address entries for a device.
|
||||
"""
|
||||
def list_mac_addresses(device_id) do
|
||||
MacAddress
|
||||
|> where([m], m.device_id == ^device_id)
|
||||
|> order_by([m], [m.vlan_id, m.mac_address])
|
||||
|> preload(:interface)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates or updates a MAC address entry.
|
||||
"""
|
||||
def upsert_mac_address(attrs) do
|
||||
case Repo.get_by(MacAddress,
|
||||
device_id: attrs.device_id,
|
||||
mac_address: attrs.mac_address,
|
||||
vlan_id: attrs[:vlan_id]
|
||||
) do
|
||||
nil ->
|
||||
%MacAddress{}
|
||||
|> MacAddress.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
mac_address ->
|
||||
mac_address
|
||||
|> MacAddress.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes stale MAC address entries that haven't been seen since the given datetime.
|
||||
"""
|
||||
def delete_stale_mac_addresses(device_id, cutoff_datetime) do
|
||||
MacAddress
|
||||
|> where([m], m.device_id == ^device_id)
|
||||
|> where([m], m.last_seen_at < ^cutoff_datetime)
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Bulk upserts MAC address entries for a device.
|
||||
Returns the number of entries processed.
|
||||
"""
|
||||
def upsert_mac_addresses(device_id, mac_entries, interfaces) do
|
||||
# Build a map from bridge port index to interface ID
|
||||
# Note: port_index from BRIDGE-MIB is not the same as if_index
|
||||
# For now, we'll use a simple mapping if they're available
|
||||
interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end)
|
||||
|
||||
Enum.each(mac_entries, fn entry ->
|
||||
interface_id = Map.get(interface_map, entry.port_index)
|
||||
|
||||
attrs =
|
||||
entry
|
||||
|> Map.put(:device_id, device_id)
|
||||
|> Map.put(:interface_id, interface_id)
|
||||
|
||||
upsert_mac_address(attrs)
|
||||
end)
|
||||
|
||||
length(mac_entries)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
49
lib/towerops/snmp/mac_address.ex
Normal file
49
lib/towerops/snmp/mac_address.ex
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Towerops.Snmp.MacAddress do
|
||||
@moduledoc """
|
||||
SNMP MAC address schema for bridge forwarding database entries.
|
||||
|
||||
Stores MAC address table entries discovered via BRIDGE-MIB
|
||||
dot1dTpFdbTable (1.3.6.1.2.1.17.4.3) and Q-BRIDGE-MIB
|
||||
dot1qTpFdbTable (1.3.6.1.2.1.17.7.1.2.2).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "snmp_mac_addresses" do
|
||||
field :mac_address, :string
|
||||
field :vlan_id, :integer
|
||||
field :port_index, :integer
|
||||
field :entry_status, :string
|
||||
field :last_seen_at, :utc_datetime
|
||||
|
||||
belongs_to :device, Towerops.Devices.Device
|
||||
belongs_to :interface, Towerops.Snmp.Interface
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(mac_address, attrs) do
|
||||
mac_address
|
||||
|> cast(attrs, [
|
||||
:device_id,
|
||||
:interface_id,
|
||||
:mac_address,
|
||||
:vlan_id,
|
||||
:port_index,
|
||||
:entry_status,
|
||||
:last_seen_at
|
||||
])
|
||||
|> validate_required([
|
||||
:device_id,
|
||||
:mac_address,
|
||||
:last_seen_at
|
||||
])
|
||||
|> foreign_key_constraint(:device_id)
|
||||
|> foreign_key_constraint(:interface_id)
|
||||
|> unique_constraint([:device_id, :mac_address, :vlan_id])
|
||||
end
|
||||
end
|
||||
218
lib/towerops/snmp/mac_discovery.ex
Normal file
218
lib/towerops/snmp/mac_discovery.ex
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
defmodule Towerops.Snmp.MacDiscovery do
|
||||
@moduledoc """
|
||||
Discovers MAC address forwarding table entries via SNMP.
|
||||
|
||||
Uses BRIDGE-MIB dot1dTpFdbTable (1.3.6.1.2.1.17.4.3) and Q-BRIDGE-MIB
|
||||
dot1qTpFdbTable (1.3.6.1.2.1.17.7.1.2.2) to retrieve MAC address
|
||||
forwarding database entries from network switches.
|
||||
"""
|
||||
|
||||
alias Towerops.Snmp.Client
|
||||
|
||||
require Logger
|
||||
|
||||
@type mac_entry :: %{
|
||||
required(:mac_address) => String.t(),
|
||||
required(:port_index) => integer(),
|
||||
optional(:vlan_id) => integer(),
|
||||
optional(:entry_status) => String.t(),
|
||||
required(:last_seen_at) => DateTime.t()
|
||||
}
|
||||
|
||||
# BRIDGE-MIB FDB table OIDs
|
||||
# dot1dTpFdbTable - basic FDB without VLAN awareness
|
||||
@dot1d_tp_fdb_address "1.3.6.1.2.1.17.4.3.1.1"
|
||||
@dot1d_tp_fdb_port "1.3.6.1.2.1.17.4.3.1.2"
|
||||
@dot1d_tp_fdb_status "1.3.6.1.2.1.17.4.3.1.3"
|
||||
|
||||
# Q-BRIDGE-MIB FDB table OIDs (VLAN-aware)
|
||||
@dot1q_tp_fdb_port "1.3.6.1.2.1.17.7.1.2.2.1.2"
|
||||
@dot1q_tp_fdb_status "1.3.6.1.2.1.17.7.1.2.2.1.3"
|
||||
|
||||
@doc """
|
||||
Discovers MAC address forwarding table entries for a device.
|
||||
Returns {:ok, mac_entries} where mac_entries is a list of mac_entry maps.
|
||||
"""
|
||||
@spec discover_mac_table(Client.connection_opts()) :: {:ok, [mac_entry()]}
|
||||
def discover_mac_table(client_opts) do
|
||||
# Try Q-BRIDGE-MIB first (VLAN-aware), fall back to BRIDGE-MIB
|
||||
case discover_qbridge_fdb(client_opts) do
|
||||
{:ok, [_ | _] = entries} ->
|
||||
{:ok, entries}
|
||||
|
||||
_ ->
|
||||
discover_bridge_fdb(client_opts)
|
||||
end
|
||||
end
|
||||
|
||||
# Discover using Q-BRIDGE-MIB (VLAN-aware)
|
||||
defp discover_qbridge_fdb(client_opts) do
|
||||
port_map = fetch_qbridge_ports(client_opts)
|
||||
status_map = fetch_qbridge_status(client_opts)
|
||||
|
||||
if map_size(port_map) == 0 do
|
||||
{:ok, []}
|
||||
else
|
||||
entries =
|
||||
port_map
|
||||
|> Enum.map(fn {key, port_index} ->
|
||||
{vlan_id, mac_address} = parse_qbridge_key(key)
|
||||
status = Map.get(status_map, key)
|
||||
|
||||
%{
|
||||
mac_address: mac_address,
|
||||
port_index: port_index,
|
||||
vlan_id: vlan_id,
|
||||
entry_status: fdb_status_to_string(status),
|
||||
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
end)
|
||||
|> Enum.reject(&is_nil(&1.mac_address))
|
||||
|
||||
{:ok, entries}
|
||||
end
|
||||
end
|
||||
|
||||
# Discover using BRIDGE-MIB (no VLAN awareness)
|
||||
defp discover_bridge_fdb(client_opts) do
|
||||
mac_map = fetch_bridge_macs(client_opts)
|
||||
port_map = fetch_bridge_ports(client_opts)
|
||||
status_map = fetch_bridge_status(client_opts)
|
||||
|
||||
if map_size(mac_map) == 0 do
|
||||
{:ok, []}
|
||||
else
|
||||
entries =
|
||||
mac_map
|
||||
|> Enum.map(fn {key, _mac_binary} ->
|
||||
mac_address = parse_mac_from_key(key)
|
||||
port_index = Map.get(port_map, key)
|
||||
status = Map.get(status_map, key)
|
||||
|
||||
%{
|
||||
mac_address: mac_address,
|
||||
port_index: port_index,
|
||||
vlan_id: nil,
|
||||
entry_status: fdb_status_to_string(status),
|
||||
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
end)
|
||||
|> Enum.reject(&(is_nil(&1.mac_address) or is_nil(&1.port_index)))
|
||||
|
||||
{:ok, entries}
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch helpers for Q-BRIDGE-MIB
|
||||
defp fetch_qbridge_ports(client_opts) do
|
||||
case Client.walk(client_opts, @dot1q_tp_fdb_port) do
|
||||
{:ok, entries} when map_size(entries) > 0 ->
|
||||
Map.new(entries, fn {oid, value} ->
|
||||
key = extract_index(oid, @dot1q_tp_fdb_port)
|
||||
{key, value}
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_qbridge_status(client_opts) do
|
||||
case Client.walk(client_opts, @dot1q_tp_fdb_status) do
|
||||
{:ok, entries} when map_size(entries) > 0 ->
|
||||
Map.new(entries, fn {oid, value} ->
|
||||
key = extract_index(oid, @dot1q_tp_fdb_status)
|
||||
{key, value}
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch helpers for BRIDGE-MIB
|
||||
defp fetch_bridge_macs(client_opts) do
|
||||
case Client.walk(client_opts, @dot1d_tp_fdb_address) do
|
||||
{:ok, entries} when map_size(entries) > 0 ->
|
||||
Map.new(entries, fn {oid, value} ->
|
||||
key = extract_index(oid, @dot1d_tp_fdb_address)
|
||||
{key, value}
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_bridge_ports(client_opts) do
|
||||
case Client.walk(client_opts, @dot1d_tp_fdb_port) do
|
||||
{:ok, entries} when map_size(entries) > 0 ->
|
||||
Map.new(entries, fn {oid, value} ->
|
||||
key = extract_index(oid, @dot1d_tp_fdb_port)
|
||||
{key, value}
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_bridge_status(client_opts) do
|
||||
case Client.walk(client_opts, @dot1d_tp_fdb_status) do
|
||||
{:ok, entries} when map_size(entries) > 0 ->
|
||||
Map.new(entries, fn {oid, value} ->
|
||||
key = extract_index(oid, @dot1d_tp_fdb_status)
|
||||
{key, value}
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
# Extract the index portion from an OID
|
||||
defp extract_index(oid, base_oid) do
|
||||
base_len = String.length(base_oid) + 1
|
||||
String.slice(oid, base_len..-1//1)
|
||||
end
|
||||
|
||||
# Parse Q-BRIDGE key format: vlan_id.mac_octet1.mac_octet2...mac_octet6
|
||||
defp parse_qbridge_key(key) do
|
||||
case String.split(key, ".") do
|
||||
[vlan_id | mac_parts] when length(mac_parts) == 6 ->
|
||||
mac_address = format_mac_from_octets(mac_parts)
|
||||
{String.to_integer(vlan_id), mac_address}
|
||||
|
||||
_ ->
|
||||
{nil, nil}
|
||||
end
|
||||
end
|
||||
|
||||
# Parse MAC from BRIDGE-MIB key format: mac_octet1.mac_octet2...mac_octet6
|
||||
defp parse_mac_from_key(key) do
|
||||
parts = String.split(key, ".")
|
||||
|
||||
if length(parts) == 6 do
|
||||
format_mac_from_octets(parts)
|
||||
end
|
||||
end
|
||||
|
||||
# Format MAC address from octet strings
|
||||
defp format_mac_from_octets(octets) when length(octets) == 6 do
|
||||
octets
|
||||
|> Enum.map(&String.to_integer/1)
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp format_mac_from_octets(_), do: nil
|
||||
|
||||
# Convert FDB status integer to string
|
||||
# 1 = other, 2 = invalid, 3 = learned, 4 = self, 5 = mgmt
|
||||
defp fdb_status_to_string(1), do: "other"
|
||||
defp fdb_status_to_string(2), do: "invalid"
|
||||
defp fdb_status_to_string(3), do: "learned"
|
||||
defp fdb_status_to_string(4), do: "self"
|
||||
defp fdb_status_to_string(5), do: "mgmt"
|
||||
defp fdb_status_to_string(_), do: nil
|
||||
end
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
defmodule Towerops.Snmp.NeighborCleanupWorker do
|
||||
@moduledoc """
|
||||
GenServer that periodically cleans up stale neighbor and ARP records.
|
||||
GenServer that periodically cleans up stale neighbor, ARP, and MAC FDB records.
|
||||
|
||||
Neighbors and ARP entries that haven't been seen in the last 24 hours are
|
||||
considered stale and will be automatically removed from the database.
|
||||
Neighbors, ARP entries, and MAC forwarding database entries that haven't been
|
||||
seen in the last 24 hours are considered stale and will be automatically
|
||||
removed from the database.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
|
|
@ -39,38 +40,39 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do
|
|||
defp cleanup_stale_records do
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -@stale_threshold_hours, :hour)
|
||||
|
||||
Logger.info("Starting neighbor and ARP cleanup for records older than #{cutoff}")
|
||||
Logger.info("Starting neighbor, ARP, and MAC cleanup for records older than #{cutoff}")
|
||||
|
||||
# device IDs with SNMP enabled
|
||||
equipment_list = Devices.list_snmp_enabled_devices()
|
||||
|
||||
{total_neighbors, total_arp} =
|
||||
Enum.reduce(equipment_list, {0, 0}, fn device, {neighbor_acc, arp_acc} ->
|
||||
neighbors_deleted =
|
||||
case Snmp.delete_stale_neighbors(device.id, cutoff) do
|
||||
{count, _} when count > 0 ->
|
||||
Logger.info("Deleted #{count} stale neighbors for device #{device.name} (#{device.id})")
|
||||
count
|
||||
{total_neighbors, total_arp, total_mac} =
|
||||
Enum.reduce(equipment_list, {0, 0, 0}, fn device, {neighbor_acc, arp_acc, mac_acc} ->
|
||||
neighbors_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_neighbors/2, "neighbors")
|
||||
arp_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_arp_entries/2, "ARP entries")
|
||||
mac_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_mac_addresses/2, "MAC entries")
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
|
||||
arp_deleted =
|
||||
case Snmp.delete_stale_arp_entries(device.id, cutoff) do
|
||||
{count, _} when count > 0 ->
|
||||
Logger.info("Deleted #{count} stale ARP entries for device #{device.name} (#{device.id})")
|
||||
count
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
|
||||
{neighbor_acc + neighbors_deleted, arp_acc + arp_deleted}
|
||||
{neighbor_acc + neighbors_deleted, arp_acc + arp_deleted, mac_acc + mac_deleted}
|
||||
end)
|
||||
|
||||
if total_neighbors > 0 or total_arp > 0 do
|
||||
Logger.info("Cleanup completed: removed #{total_neighbors} neighbors, #{total_arp} ARP entries")
|
||||
log_cleanup_results(total_neighbors, total_arp, total_mac)
|
||||
end
|
||||
|
||||
defp delete_stale_records(device, cutoff, delete_fn, record_type) do
|
||||
case delete_fn.(device.id, cutoff) do
|
||||
{count, _} when count > 0 ->
|
||||
Logger.info("Deleted #{count} stale #{record_type} for device #{device.name} (#{device.id})")
|
||||
count
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp log_cleanup_results(total_neighbors, total_arp, total_mac) do
|
||||
if total_neighbors > 0 or total_arp > 0 or total_mac > 0 do
|
||||
Logger.info(
|
||||
"Cleanup completed: removed #{total_neighbors} neighbors, #{total_arp} ARP entries, #{total_mac} MAC entries"
|
||||
)
|
||||
else
|
||||
Logger.debug("Cleanup completed: no stale records found")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
alias Towerops.Snmp
|
||||
alias Towerops.Snmp.ArpDiscovery
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Snmp.MacDiscovery
|
||||
alias Towerops.Snmp.NeighborDiscovery
|
||||
alias Towerops.Snmp.PollerRegistry
|
||||
alias Towerops.Snmp.PollerTaskSupervisor
|
||||
|
|
@ -171,6 +172,9 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
|
||||
poll_device_arp(device, snmp_device, client_opts)
|
||||
end),
|
||||
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
|
||||
poll_device_mac(device, snmp_device, client_opts)
|
||||
end),
|
||||
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
|
||||
poll_device_processors(device, snmp_device, client_opts, now)
|
||||
end),
|
||||
|
|
@ -296,6 +300,29 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
Logger.error("Error polling ARP table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
|
||||
end
|
||||
|
||||
defp poll_device_mac(device, snmp_device, client_opts) do
|
||||
{:ok, mac_entries} = MacDiscovery.discover_mac_table(client_opts)
|
||||
|
||||
# Delete stale MAC entries (not seen in last 5 minutes)
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
|
||||
Snmp.delete_stale_mac_addresses(device.id, cutoff)
|
||||
|
||||
# Upsert MAC entries with interface mapping
|
||||
Snmp.upsert_mac_addresses(device.id, mac_entries, snmp_device.interfaces)
|
||||
|
||||
Logger.debug("Polled and saved #{length(mac_entries)} MAC FDB entries for #{device.name}")
|
||||
|
||||
# Broadcast MAC update event to device-specific topic
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:#{device.id}",
|
||||
{:mac_updated, device.id}
|
||||
)
|
||||
rescue
|
||||
error ->
|
||||
Logger.error("Error polling MAC FDB table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
|
||||
end
|
||||
|
||||
defp poll_device_processors(device, snmp_device, client_opts, now) do
|
||||
processors = snmp_device.processors || []
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Snmp
|
||||
|
||||
# SNMP interface type to category mappings
|
||||
|
|
@ -113,6 +115,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:mac_updated, _device_id}, socket) do
|
||||
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:state_sensors_updated, _device_id}, socket) do
|
||||
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
||||
|
|
@ -144,11 +151,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|
||||
defp load_equipment_data(socket, device_id) do
|
||||
device = Devices.get_device!(device_id)
|
||||
device_with_associations = Repo.preload(device, site: [organization: :default_agent_token])
|
||||
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
|
||||
agent_info = load_agent_info(agent_token_id, agent_source)
|
||||
recent_checks = Monitoring.list_devices_checks(device_id, 50)
|
||||
snmp_data = load_snmp_data(device_id)
|
||||
events = Devices.list_devices_events(device_id, 100)
|
||||
neighbors = Snmp.list_neighbors_with_equipment(device_id)
|
||||
arp_entries = Snmp.list_arp_entries(device_id)
|
||||
mac_addresses = Snmp.list_mac_addresses(device_id)
|
||||
vlans = load_vlans(snmp_data.device)
|
||||
ip_addresses = load_ip_addresses(snmp_data.device)
|
||||
processors = load_processors(snmp_data.device)
|
||||
|
|
@ -157,6 +168,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
processor_chart_data = load_processor_chart_data(processors)
|
||||
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
|
||||
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
|
||||
storage_volume_chart_data = load_storage_volume_chart_data(storage)
|
||||
traffic_chart_data = load_overall_traffic_chart_data(device_id)
|
||||
|
||||
# Filter sensors by type for temperature, voltage, storage, count, and transceivers
|
||||
|
|
@ -195,9 +207,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|> assign(:snmp_sensors, snmp_data.sensors)
|
||||
|> assign(:neighbors, neighbors)
|
||||
|> assign(:arp_entries, arp_entries)
|
||||
|> assign(:mac_addresses, mac_addresses)
|
||||
|> assign(:vlans, vlans)
|
||||
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|
||||
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|
||||
|> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses))
|
||||
|> assign(:processors, processors)
|
||||
|> assign(:storage, storage)
|
||||
|> assign(:events, events)
|
||||
|
|
@ -205,12 +219,34 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|> assign(:processor_chart_data, processor_chart_data)
|
||||
|> assign(:memory_chart_data, memory_chart_data)
|
||||
|> assign(:storage_chart_data, storage_chart_data)
|
||||
|> assign(:storage_volume_chart_data, storage_volume_chart_data)
|
||||
|> assign(:traffic_chart_data, traffic_chart_data)
|
||||
|> assign(:temperature_sensors, temperature_sensors)
|
||||
|> assign(:voltage_sensors, voltage_sensors)
|
||||
|> assign(:storage_sensors, storage_sensors)
|
||||
|> assign(:count_sensors, count_sensors)
|
||||
|> assign(:grouped_transceivers, grouped_transceivers)
|
||||
|> assign(:agent_info, agent_info)
|
||||
end
|
||||
|
||||
defp load_agent_info(nil, :none) do
|
||||
%{
|
||||
type: :cloud,
|
||||
name: "Cloud Polling",
|
||||
source: nil,
|
||||
last_seen_at: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp load_agent_info(agent_token_id, source) do
|
||||
agent_token = Agents.get_agent_token!(agent_token_id)
|
||||
|
||||
%{
|
||||
type: :agent,
|
||||
name: agent_token.name,
|
||||
source: source,
|
||||
last_seen_at: agent_token.last_seen_at
|
||||
}
|
||||
end
|
||||
|
||||
defp calculate_metrics(checks, _equipment) do
|
||||
|
|
@ -354,6 +390,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
format_duration(diff) <> " ago"
|
||||
end
|
||||
|
||||
defp format_relative_time(nil), do: "never"
|
||||
|
||||
defp format_relative_time(datetime) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)} min ago"
|
||||
diff < 86_400 -> "#{div(diff, 3600)} hours ago"
|
||||
true -> format_duration(diff) <> " ago"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_event_type(event_type) do
|
||||
event_type
|
||||
|> String.replace("_", " ")
|
||||
|
|
@ -449,6 +498,44 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
}
|
||||
end
|
||||
|
||||
defp load_storage_volume_chart_data([]), do: nil
|
||||
|
||||
defp load_storage_volume_chart_data(storage_volumes) do
|
||||
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
||||
|
||||
datasets =
|
||||
storage_volumes
|
||||
|> Enum.map(&storage_volume_to_chart_dataset(&1, twenty_four_hours_ago))
|
||||
|> Enum.reject(&Enum.empty?(&1.data))
|
||||
|
||||
if Enum.empty?(datasets) do
|
||||
nil
|
||||
else
|
||||
Jason.encode!(%{datasets: datasets})
|
||||
end
|
||||
end
|
||||
|
||||
defp storage_volume_to_chart_dataset(storage, since) do
|
||||
readings =
|
||||
storage.id
|
||||
|> Snmp.get_storage_readings(since: since, limit: 1000)
|
||||
|> Enum.reverse()
|
||||
|
||||
label = storage.description || "Storage #{storage.storage_index}"
|
||||
|
||||
%{
|
||||
label: label,
|
||||
data: Enum.map(readings, &storage_reading_to_data_point/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp storage_reading_to_data_point(reading) do
|
||||
%{
|
||||
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
||||
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
|
||||
}
|
||||
end
|
||||
|
||||
defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do
|
||||
case Snmp.get_device_with_associations(device_id) do
|
||||
nil -> nil
|
||||
|
|
@ -652,4 +739,9 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|> String.downcase()
|
||||
end
|
||||
end
|
||||
|
||||
# Group IP addresses by interface ID for efficient lookup in templates
|
||||
defp group_ip_addresses_by_interface(ip_addresses) do
|
||||
Enum.group_by(ip_addresses, & &1.snmp_interface_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,6 +30,35 @@
|
|||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@device.name}</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 font-mono">{@device.ip_address}</p>
|
||||
<!-- Poller Indicator -->
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<%= if @agent_info.type == :cloud do %>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
|
||||
<.icon name="hero-cloud" class="h-3.5 w-3.5" /> Cloud Polling
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300">
|
||||
<.icon name="hero-server" class="h-3.5 w-3.5" />
|
||||
{@agent_info.name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<%= case @agent_info.source do %>
|
||||
<% :device -> %>
|
||||
(assigned directly)
|
||||
<% :site -> %>
|
||||
(via site default)
|
||||
<% :organization -> %>
|
||||
(via org default)
|
||||
<% _ -> %>
|
||||
<% end %>
|
||||
</span>
|
||||
<%= if @agent_info.last_seen_at do %>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
• Last seen: {format_relative_time(@agent_info.last_seen_at)}
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<.button navigate={~p"/devices/#{@device.id}/edit"}>
|
||||
|
|
@ -100,6 +129,22 @@
|
|||
</.link>
|
||||
<% end %>
|
||||
|
||||
<%= if @mac_addresses && length(@mac_addresses) > 0 do %>
|
||||
<.link
|
||||
patch={~p"/devices/#{@device.id}?tab=mac"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "mac" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
MAC Table
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<%= if @vlans && length(@vlans) > 0 do %>
|
||||
<.link
|
||||
patch={~p"/devices/#{@device.id}?tab=vlans"}
|
||||
|
|
@ -487,6 +532,20 @@
|
|||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Storage Volume Chart -->
|
||||
<%= if @storage_volume_chart_data do %>
|
||||
<div class="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div
|
||||
id="storage-volume-chart"
|
||||
phx-hook="SensorChart"
|
||||
data-chart={@storage_volume_chart_data}
|
||||
data-unit="%"
|
||||
style="height: 200px;"
|
||||
>
|
||||
<canvas></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="p-4 space-y-4">
|
||||
<%= for storage <- @storage do %>
|
||||
<% usage_percent =
|
||||
|
|
@ -708,6 +767,7 @@
|
|||
<th class="px-4 py-3 text-left font-medium">Name</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Status</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Speed</th>
|
||||
<th class="px-4 py-3 text-left font-medium">IP Address</th>
|
||||
<th class="px-4 py-3 text-left font-medium">MAC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -750,6 +810,24 @@
|
|||
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
||||
{format_speed(interface.if_speed)}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
<% interface_ips =
|
||||
Map.get(@ip_addresses_by_interface, interface.id, []) %>
|
||||
<%= if Enum.empty?(interface_ips) do %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% else %>
|
||||
<div class="space-y-0.5">
|
||||
<%= for ip <- interface_ips do %>
|
||||
<div>
|
||||
{ip.ip_address}
|
||||
<%= if ip.prefix_length do %>
|
||||
/{ip.prefix_length}
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{interface.if_phys_address || "-"}
|
||||
</td>
|
||||
|
|
@ -955,6 +1033,92 @@
|
|||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% "mac" -> %>
|
||||
<%= if @mac_addresses && length(@mac_addresses) > 0 do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
MAC Address Table
|
||||
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
({length(@mac_addresses)} entries)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
||||
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<th class="px-4 py-3 text-left font-medium">MAC Address</th>
|
||||
<th class="px-4 py-3 text-left font-medium">VLAN</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Interface</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Status</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for mac <- @mac_addresses do %>
|
||||
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
|
||||
{mac.mac_address}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
<%= if mac.vlan_id do %>
|
||||
{mac.vlan_id}
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
<%= if mac.interface do %>
|
||||
<.link
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{mac.interface.id}"
|
||||
}
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{mac.interface.if_name || mac.interface.if_descr}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-400">Port {mac.port_index}</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
||||
mac.entry_status == "learned" &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
mac.entry_status == "self" &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
mac.entry_status == "mgmt" &&
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
mac.entry_status not in ["learned", "self", "mgmt"] &&
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
]}>
|
||||
{mac.entry_status || "unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{time_ago(mac.last_seen_at)}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-table-cells" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
No MAC addresses discovered
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
This device doesn't have any MAC forwarding entries, or MAC discovery hasn't run yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% "vlans" -> %>
|
||||
<%= if @vlans && length(@vlans) > 0 do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateSnmpMacAddresses do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:snmp_mac_addresses, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :interface_id, references(:snmp_interfaces, type: :binary_id, on_delete: :delete_all)
|
||||
add :mac_address, :string, null: false
|
||||
add :vlan_id, :integer
|
||||
add :port_index, :integer
|
||||
add :entry_status, :string
|
||||
add :last_seen_at, :utc_datetime, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:snmp_mac_addresses, [:device_id])
|
||||
create index(:snmp_mac_addresses, [:interface_id])
|
||||
create index(:snmp_mac_addresses, [:mac_address])
|
||||
create unique_index(:snmp_mac_addresses, [:device_id, :mac_address, :vlan_id])
|
||||
end
|
||||
end
|
||||
167
test/towerops/snmp/mac_address_test.exs
Normal file
167
test/towerops/snmp/mac_address_test.exs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
defmodule Towerops.Snmp.MacAddressTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.MacAddress
|
||||
|
||||
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!()
|
||||
|
||||
interface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: snmp_device.id,
|
||||
if_index: 1,
|
||||
if_descr: "GigabitEthernet0/1",
|
||||
if_name: "Gi0/1",
|
||||
if_type: 6,
|
||||
if_oper_status: "up"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%{device: device_schema, snmp_device: snmp_device, interface: interface}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid with required fields", %{device: device} do
|
||||
attrs = %{
|
||||
device_id: device.id,
|
||||
mac_address: "00:11:22:33:44:55",
|
||||
last_seen_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = MacAddress.changeset(%MacAddress{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "valid with all fields", %{device: device, interface: interface} do
|
||||
attrs = %{
|
||||
device_id: device.id,
|
||||
interface_id: interface.id,
|
||||
mac_address: "00:11:22:33:44:55",
|
||||
vlan_id: 100,
|
||||
port_index: 1,
|
||||
entry_status: "learned",
|
||||
last_seen_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = MacAddress.changeset(%MacAddress{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without device_id" do
|
||||
attrs = %{
|
||||
mac_address: "00:11:22:33:44:55",
|
||||
last_seen_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = MacAddress.changeset(%MacAddress{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).device_id
|
||||
end
|
||||
|
||||
test "invalid without mac_address", %{device: device} do
|
||||
attrs = %{
|
||||
device_id: device.id,
|
||||
last_seen_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = MacAddress.changeset(%MacAddress{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).mac_address
|
||||
end
|
||||
|
||||
test "invalid without last_seen_at", %{device: device} do
|
||||
attrs = %{
|
||||
device_id: device.id,
|
||||
mac_address: "00:11:22:33:44:55"
|
||||
}
|
||||
|
||||
changeset = MacAddress.changeset(%MacAddress{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).last_seen_at
|
||||
end
|
||||
|
||||
test "enforces unique constraint on device_id, mac_address, vlan_id", %{device: device} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, _} =
|
||||
%MacAddress{}
|
||||
|> MacAddress.changeset(%{
|
||||
device_id: device.id,
|
||||
mac_address: "00:11:22:33:44:55",
|
||||
vlan_id: 100,
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:error, changeset} =
|
||||
%MacAddress{}
|
||||
|> MacAddress.changeset(%{
|
||||
device_id: device.id,
|
||||
mac_address: "00:11:22:33:44:55",
|
||||
vlan_id: 100,
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
assert "has already been taken" in errors_on(changeset)[:device_id]
|
||||
end
|
||||
|
||||
test "allows same mac_address on different VLANs", %{device: device} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, _} =
|
||||
%MacAddress{}
|
||||
|> MacAddress.changeset(%{
|
||||
device_id: device.id,
|
||||
mac_address: "00:11:22:33:44:55",
|
||||
vlan_id: 100,
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:ok, _} =
|
||||
%MacAddress{}
|
||||
|> MacAddress.changeset(%{
|
||||
device_id: device.id,
|
||||
mac_address: "00:11:22:33:44:55",
|
||||
vlan_id: 200,
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
end
|
||||
184
test/towerops/snmp/mac_discovery_test.exs
Normal file
184
test/towerops/snmp/mac_discovery_test.exs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
defmodule Towerops.Snmp.MacDiscoveryTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.MacDiscovery
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "discover_mac_table/1" do
|
||||
test "returns empty list when no MAC entries found" do
|
||||
# Q-BRIDGE port returns empty
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
# Q-BRIDGE status walk (still called even with empty port results)
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
# BRIDGE-MIB address returns empty
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.1", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
# BRIDGE-MIB port returns empty
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.2", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
# BRIDGE-MIB status returns empty
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.3", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
assert {:ok, []} = MacDiscovery.discover_mac_table(client_opts())
|
||||
end
|
||||
|
||||
test "discovers Q-BRIDGE MAC entries with VLAN awareness" do
|
||||
# Q-BRIDGE-MIB dot1qTpFdbPort walk
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
|
||||
{:ok,
|
||||
[
|
||||
# VLAN 100, MAC 00:11:22:33:44:55, Port 1
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.85", value: 1},
|
||||
# VLAN 100, MAC 00:11:22:33:44:66, Port 2
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.102", value: 2},
|
||||
# VLAN 200, MAC 00:11:22:33:44:55, Port 3
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.200.0.17.34.51.68.85", value: 3}
|
||||
]}
|
||||
end)
|
||||
|
||||
# Q-BRIDGE-MIB dot1qTpFdbStatus walk
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.100.0.17.34.51.68.85", value: 3},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.100.0.17.34.51.68.102", value: 3},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.200.0.17.34.51.68.85", value: 3}
|
||||
]}
|
||||
end)
|
||||
|
||||
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
|
||||
|
||||
assert length(entries) == 3
|
||||
|
||||
entry1 = Enum.find(entries, &(&1.vlan_id == 100 && &1.port_index == 1))
|
||||
assert entry1.mac_address == "00:11:22:33:44:55"
|
||||
assert entry1.entry_status == "learned"
|
||||
|
||||
entry2 = Enum.find(entries, &(&1.vlan_id == 100 && &1.port_index == 2))
|
||||
assert entry2.mac_address == "00:11:22:33:44:66"
|
||||
|
||||
entry3 = Enum.find(entries, &(&1.vlan_id == 200 && &1.port_index == 3))
|
||||
assert entry3.mac_address == "00:11:22:33:44:55"
|
||||
end
|
||||
|
||||
test "falls back to BRIDGE-MIB when Q-BRIDGE returns no entries" do
|
||||
# Q-BRIDGE port returns empty
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
# Q-BRIDGE status walk (still called even with empty port results)
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
# BRIDGE-MIB dot1dTpFdbAddress walk
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.1", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.4.3.1.1.0.17.34.51.68.85", value: <<0, 17, 34, 51, 68, 85>>}
|
||||
]}
|
||||
end)
|
||||
|
||||
# BRIDGE-MIB dot1dTpFdbPort walk
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.2", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.4.3.1.2.0.17.34.51.68.85", value: 1}
|
||||
]}
|
||||
end)
|
||||
|
||||
# BRIDGE-MIB dot1dTpFdbStatus walk
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.3", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.4.3.1.3.0.17.34.51.68.85", value: 3}
|
||||
]}
|
||||
end)
|
||||
|
||||
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
|
||||
|
||||
assert length(entries) == 1
|
||||
entry = hd(entries)
|
||||
assert entry.mac_address == "00:11:22:33:44:55"
|
||||
assert entry.port_index == 1
|
||||
assert entry.vlan_id == nil
|
||||
assert entry.entry_status == "learned"
|
||||
end
|
||||
|
||||
test "handles all FDB status values" do
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.1", value: 1},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.2", value: 2},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.3", value: 3},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.4", value: 4},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.5", value: 5}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.1", value: 1},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.2", value: 2},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.3", value: 3},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.4", value: 4},
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.5", value: 5}
|
||||
]}
|
||||
end)
|
||||
|
||||
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
|
||||
|
||||
assert length(entries) == 5
|
||||
|
||||
statuses = entries |> Enum.map(& &1.entry_status) |> Enum.sort()
|
||||
assert statuses == ["invalid", "learned", "mgmt", "other", "self"]
|
||||
end
|
||||
|
||||
test "includes last_seen_at timestamp" do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.85", value: 1}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
{:ok, entries} = MacDiscovery.discover_mac_table(client_opts())
|
||||
|
||||
assert length(entries) == 1
|
||||
[entry] = entries
|
||||
|
||||
assert %DateTime{} = entry.last_seen_at
|
||||
# Timestamp should be within 1 second of now
|
||||
assert DateTime.diff(entry.last_seen_at, now, :second) <= 1
|
||||
end
|
||||
end
|
||||
|
||||
defp client_opts do
|
||||
[ip: "192.168.1.1", community: "public", version: :v2c, port: 161]
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue