This commit is contained in:
Graham McIntire 2026-01-23 08:40:57 -06:00
parent 5f6ac5ddb9
commit 7bd3b1f9ec
No known key found for this signature in database
15 changed files with 1478 additions and 55 deletions

View file

@ -9,6 +9,7 @@ defmodule Towerops.Snmp do
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Repo
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
alias Towerops.Snmp.Discovery
@ -742,4 +743,76 @@ defmodule Towerops.Snmp do
|> StorageReading.changeset(attrs)
|> Repo.insert()
end
# ARP Entry queries
@doc """
Lists all ARP entries for a device.
"""
def list_arp_entries(device_id) do
ArpEntry
|> where([a], a.device_id == ^device_id)
|> order_by([a], a.ip_address)
|> preload(:interface)
|> Repo.all()
end
@doc """
Gets a specific ARP entry.
"""
def get_arp_entry(arp_entry_id) do
Repo.get(ArpEntry, arp_entry_id)
end
@doc """
Creates or updates an ARP entry.
"""
def upsert_arp_entry(attrs) do
case Repo.get_by(ArpEntry,
device_id: attrs.device_id,
ip_address: attrs.ip_address,
mac_address: attrs.mac_address
) do
nil ->
%ArpEntry{}
|> ArpEntry.changeset(attrs)
|> Repo.insert()
arp_entry ->
arp_entry
|> ArpEntry.changeset(attrs)
|> Repo.update()
end
end
@doc """
Deletes stale ARP entries that haven't been seen since the given datetime.
"""
def delete_stale_arp_entries(device_id, cutoff_datetime) do
ArpEntry
|> where([a], a.device_id == ^device_id)
|> where([a], a.last_seen_at < ^cutoff_datetime)
|> Repo.delete_all()
end
@doc """
Bulk upserts ARP entries for a device.
Returns the number of entries processed.
"""
def upsert_arp_entries(device_id, arp_entries, interfaces) do
interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end)
Enum.each(arp_entries, fn entry ->
interface_id = Map.get(interface_map, entry.if_index)
attrs =
entry
|> Map.put(:device_id, device_id)
|> Map.put(:interface_id, interface_id)
upsert_arp_entry(attrs)
end)
length(arp_entries)
end
end

View file

@ -0,0 +1,138 @@
defmodule Towerops.Snmp.ArpDiscovery do
@moduledoc """
Discovers ARP table entries via SNMP.
Uses ipNetToMediaTable (1.3.6.1.2.1.4.22) from IP-MIB to retrieve
IP-to-MAC address mappings from network devices.
"""
alias Towerops.Snmp.Client
require Logger
@type arp_entry :: %{
required(:ip_address) => String.t(),
required(:mac_address) => String.t(),
required(:if_index) => integer(),
optional(:entry_type) => String.t(),
required(:last_seen_at) => DateTime.t()
}
# ipNetToMediaTable OIDs (IP-MIB)
# Index: ipNetToMediaIfIndex.ipNetToMediaNetAddress
@ip_net_to_media_phys_address "1.3.6.1.2.1.4.22.1.2"
@ip_net_to_media_type "1.3.6.1.2.1.4.22.1.4"
@doc """
Discovers ARP table entries for a device.
Returns {:ok, arp_entries} where arp_entries is a list of arp_entry maps.
"""
@spec discover_arp_table(Client.connection_opts()) :: {:ok, [arp_entry()]}
def discover_arp_table(client_opts) do
mac_addresses = fetch_arp_mac_addresses(client_opts)
entry_types = fetch_arp_entry_types(client_opts)
arp_entries =
mac_addresses
|> Enum.map(fn {key, mac} ->
{if_index, ip_address} = parse_arp_key(key)
entry_type = Map.get(entry_types, key)
%{
ip_address: ip_address,
mac_address: format_mac_address(mac),
if_index: if_index,
entry_type: entry_type_to_string(entry_type),
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
end)
|> Enum.reject(&is_nil(&1.ip_address))
{:ok, arp_entries}
end
# Fetch MAC addresses from ipNetToMediaPhysAddress
defp fetch_arp_mac_addresses(client_opts) do
case Client.walk(client_opts, @ip_net_to_media_phys_address) do
{:ok, entries} when map_size(entries) > 0 ->
# Convert OID keys to just the index part (if_index.ip_address)
Map.new(entries, fn {oid, value} ->
key = extract_arp_index(oid, @ip_net_to_media_phys_address)
{key, value}
end)
{:ok, _} ->
Logger.debug("No ARP entries found")
%{}
{:error, reason} ->
Logger.debug("ARP table discovery failed: #{inspect(reason)}")
%{}
end
end
# Fetch entry types from ipNetToMediaType
defp fetch_arp_entry_types(client_opts) do
case Client.walk(client_opts, @ip_net_to_media_type) do
{:ok, entries} when map_size(entries) > 0 ->
Map.new(entries, fn {oid, value} ->
key = extract_arp_index(oid, @ip_net_to_media_type)
{key, value}
end)
_ ->
%{}
end
end
# Extract the index portion from an OID (if_index.ip_address)
defp extract_arp_index(oid, base_oid) do
# Remove the base OID prefix and the trailing "."
base_len = String.length(base_oid) + 1
String.slice(oid, base_len..-1//1)
end
# Parse the ARP table index into if_index and IP address
# Index format: if_index.ip_octet1.ip_octet2.ip_octet3.ip_octet4
defp parse_arp_key(key) do
case String.split(key, ".") do
[if_index | ip_parts] when length(ip_parts) == 4 ->
ip_address = Enum.join(ip_parts, ".")
{String.to_integer(if_index), ip_address}
_ ->
{nil, nil}
end
end
# Format MAC address from binary to colon-separated hex
defp format_mac_address(value) when is_binary(value) and byte_size(value) == 6 do
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
defp format_mac_address(value) when is_binary(value) do
# Already formatted or different format
if String.contains?(value, ":") or String.contains?(value, "-") do
String.downcase(value)
else
# Try to convert hex string
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
end
defp format_mac_address(_), do: nil
# Convert ipNetToMediaType integer to string
# 1 = other, 2 = invalid, 3 = dynamic, 4 = static
defp entry_type_to_string(1), do: "other"
defp entry_type_to_string(2), do: "invalid"
defp entry_type_to_string(3), do: "dynamic"
defp entry_type_to_string(4), do: "static"
defp entry_type_to_string(_), do: nil
end

View file

@ -0,0 +1,48 @@
defmodule Towerops.Snmp.ArpEntry do
@moduledoc """
SNMP ARP entry schema for IP-to-MAC address mappings.
Stores ARP table entries discovered via ipNetToMediaTable (1.3.6.1.2.1.4.22).
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_arp_entries" do
field :ip_address, :string
field :mac_address, :string
field :entry_type, :string
field :if_index, :integer
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(arp_entry, attrs) do
arp_entry
|> cast(attrs, [
:device_id,
:interface_id,
:ip_address,
:mac_address,
:entry_type,
:if_index,
:last_seen_at
])
|> validate_required([
:device_id,
:ip_address,
:mac_address,
:last_seen_at
])
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:interface_id)
|> unique_constraint([:device_id, :ip_address, :mac_address])
end
end

View file

@ -18,6 +18,7 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Profiles.YamlProfiles
alias Towerops.Repo
alias Towerops.Snmp.ArpDiscovery
alias Towerops.Snmp.Client
alias Towerops.Snmp.DeferredDiscovery
alias Towerops.Snmp.Device
@ -138,7 +139,9 @@ defmodule Towerops.Snmp.Discovery do
:ok <- sync_processors(discovered_device, processors),
:ok <- sync_storage(discovered_device, storage),
{:ok, neighbors} <- discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts),
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
:ok <- save_neighbors(discovered_device.device_id, neighbors),
{:ok, arp_entries} <- discover_arp_with_timeout(client_opts, timeouts),
:ok <- save_arp_entries(discovered_device.device_id, arp_entries, discovered_device.interfaces) do
_ = update_device_discovery_time(device)
Logger.info("SNMP discovery completed successfully for: #{device.name}")
@ -195,6 +198,16 @@ defmodule Towerops.Snmp.Discovery do
)
end
# ARP table discovery with timeout - falls back to empty list on timeout
defp discover_arp_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> ArpDiscovery.discover_arp_table(client_opts) end,
timeout: timeouts[:deferred],
default: []
)
end
# VLAN discovery with timeout - falls back to empty list on timeout
defp discover_vlans_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
@ -855,6 +868,19 @@ defmodule Towerops.Snmp.Discovery do
:ok
end
@spec save_arp_entries(binary(), [map()], [Interface.t()]) :: :ok
defp save_arp_entries(device_id, arp_entries, interfaces) do
# Delete stale ARP entries (not seen in last 30 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -30, :minute)
Towerops.Snmp.delete_stale_arp_entries(device_id, cutoff)
# Upsert each discovered ARP entry
count = Towerops.Snmp.upsert_arp_entries(device_id, arp_entries, interfaces)
Logger.info("Saved #{count} ARP entries for device #{device_id}")
:ok
end
@spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
{:ok, DeviceSchema.t()} | {:error, term()}
defp update_device_name_from_snmp(device, system_info) do

View file

@ -1,9 +1,9 @@
defmodule Towerops.Snmp.NeighborCleanupWorker do
@moduledoc """
GenServer that periodically cleans up stale neighbor discovery records.
GenServer that periodically cleans up stale neighbor and ARP records.
Neighbors that haven't been discovered in the last 24 hours are considered stale
and will be automatically removed from the database.
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.
"""
use GenServer
@ -31,36 +31,48 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do
@impl true
def handle_info(:cleanup, state) do
cleanup_stale_neighbors()
cleanup_stale_records()
schedule_cleanup(@cleanup_interval)
{:noreply, state}
end
defp cleanup_stale_neighbors do
defp cleanup_stale_records do
cutoff = DateTime.add(DateTime.utc_now(), -@stale_threshold_hours, :hour)
Logger.info("Starting neighbor cleanup for records older than #{cutoff}")
Logger.info("Starting neighbor and ARP cleanup for records older than #{cutoff}")
# device IDs with SNMP enabled
equipment_list = Devices.list_snmp_enabled_devices()
total_deleted =
Enum.reduce(equipment_list, 0, fn device, acc ->
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})")
{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
acc + count
_ ->
0
end
_ ->
acc
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}
end)
if total_deleted > 0 do
Logger.info("Neighbor cleanup completed: removed #{total_deleted} stale records")
if total_neighbors > 0 or total_arp > 0 do
Logger.info("Cleanup completed: removed #{total_neighbors} neighbors, #{total_arp} ARP entries")
else
Logger.debug("Neighbor cleanup completed: no stale records found")
Logger.debug("Cleanup completed: no stale records found")
end
:ok

View file

@ -43,11 +43,14 @@ defmodule Towerops.Snmp.NeighborDiscovery do
# LLDP Discovery (IEEE 802.1AB)
@lldp_rem_table_oid "1.0.8802.1.1.2.1.4.1.1"
@lldp_rem_man_addr_table_oid "1.0.8802.1.1.2.1.4.2"
defp discover_lldp_neighbors(client_opts, interfaces) do
case Client.walk(client_opts, @lldp_rem_table_oid) do
{:ok, entries} when entries != [] ->
parse_lldp_neighbors(entries, interfaces)
# Fetch management addresses separately
mgmt_addresses = fetch_lldp_management_addresses(client_opts)
parse_lldp_neighbors(entries, interfaces, mgmt_addresses)
{:ok, entries} when entries == %{} ->
Logger.debug("No LLDP neighbors found")
@ -59,7 +62,75 @@ defmodule Towerops.Snmp.NeighborDiscovery do
end
end
defp parse_lldp_neighbors(entries, interfaces) do
# Fetch LLDP management addresses from lldpRemManAddrTable
# Returns a map of "local_port.rem_index" => ip_address
defp fetch_lldp_management_addresses(client_opts) do
case Client.walk(client_opts, @lldp_rem_man_addr_table_oid) do
{:ok, entries} when entries != [] ->
parse_lldp_management_addresses(entries)
_ ->
%{}
end
end
# Parse management addresses from the lldpRemManAddrTable
# OID format: 1.0.8802.1.1.2.1.4.2.1.{field}.{time}.{local_port}.{rem_index}.{addr_subtype}.{addr_len}.{addr_bytes...}
defp parse_lldp_management_addresses(entries) do
entries
|> Enum.map(fn {oid, _value} -> parse_management_address_oid(oid) end)
|> Enum.reject(&is_nil/1)
|> Enum.reduce(%{}, fn {key, addr}, acc ->
# Only keep first address per neighbor (prefer not overwriting)
Map.put_new(acc, key, addr)
end)
end
# Parse the management address from the OID index
# Returns {neighbor_key, ip_address} or nil
defp parse_management_address_oid(oid) do
parts = String.split(oid, ".")
# OID structure after base: field.time.local_port.rem_index.addr_subtype.addr_len.addr_bytes...
# Base OID is 1.0.8802.1.1.2.1.4.2.1 (10 parts)
case Enum.drop(parts, 10) do
[_field, _time, local_port, rem_index, addr_subtype | addr_parts] ->
parse_management_address(local_port, rem_index, addr_subtype, addr_parts)
_ ->
nil
end
end
# Parse IPv4 management address (addr_subtype = 1)
defp parse_management_address(local_port, rem_index, "1", [_len | addr_bytes]) when length(addr_bytes) >= 4 do
# Take first 4 bytes for IPv4
ip =
addr_bytes
|> Enum.take(4)
|> Enum.join(".")
{"#{local_port}.#{rem_index}", ip}
end
# Parse IPv6 management address (addr_subtype = 2)
defp parse_management_address(local_port, rem_index, "2", [_len | addr_bytes]) when length(addr_bytes) >= 16 do
# Format IPv6 address
ip =
addr_bytes
|> Enum.take(16)
|> Enum.chunk_every(2)
|> Enum.map_join(":", fn [a, b] ->
Integer.to_string(String.to_integer(a) * 256 + String.to_integer(b), 16)
end)
{"#{local_port}.#{rem_index}", ip}
end
# Unknown address type or malformed
defp parse_management_address(_local_port, _rem_index, _addr_subtype, _addr_parts), do: nil
defp parse_lldp_neighbors(entries, interfaces, mgmt_addresses) do
# Convert map from Client.walk to list of maps
entry_list = Enum.map(entries, fn {oid, value} -> %{oid: oid, value: value} end)
@ -74,13 +145,13 @@ defmodule Towerops.Snmp.NeighborDiscovery do
end)
grouped
|> Enum.map(fn {_key, neighbor_entries} ->
build_lldp_neighbor(neighbor_entries, interfaces)
|> Enum.map(fn {neighbor_key, neighbor_entries} ->
build_lldp_neighbor(neighbor_entries, interfaces, neighbor_key, mgmt_addresses)
end)
|> Enum.reject(&is_nil/1)
end
defp build_lldp_neighbor(entries, interfaces) do
defp build_lldp_neighbor(entries, interfaces, neighbor_key, mgmt_addresses) do
neighbor_map =
Enum.reduce(entries, %{}, fn %{oid: oid, value: value}, acc ->
parts = String.split(oid, ".")
@ -90,6 +161,10 @@ defmodule Towerops.Snmp.NeighborDiscovery do
parse_lldp_field(field_oid, value, oid, acc)
end)
# Look up management address from separate table
mgmt_addr = Map.get(mgmt_addresses, neighbor_key)
neighbor_map = if mgmt_addr, do: Map.put(neighbor_map, :remote_address, mgmt_addr), else: neighbor_map
build_neighbor_record("lldp", neighbor_map, interfaces, neighbor_map[:local_port])
end

View file

@ -16,6 +16,7 @@ defmodule Towerops.Snmp.PollerWorker do
alias Towerops.Devices
alias Towerops.Repo
alias Towerops.Snmp
alias Towerops.Snmp.ArpDiscovery
alias Towerops.Snmp.Client
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.PollerRegistry
@ -167,6 +168,9 @@ defmodule Towerops.Snmp.PollerWorker do
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_neighbors(device, snmp_device, client_opts)
end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_arp(device, snmp_device, client_opts)
end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_processors(device, snmp_device, client_opts, now)
end),
@ -269,6 +273,29 @@ defmodule Towerops.Snmp.PollerWorker do
Logger.error("Error polling neighbors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_device_arp(device, snmp_device, client_opts) do
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
# Delete stale ARP entries (not seen in last 5 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
Snmp.delete_stale_arp_entries(device.id, cutoff)
# Upsert ARP entries with interface mapping
Snmp.upsert_arp_entries(device.id, arp_entries, snmp_device.interfaces)
Logger.debug("Polled and saved #{length(arp_entries)} ARP entries for #{device.name}")
# Broadcast ARP update event to device-specific topic
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:arp_updated, device.id}
)
rescue
error ->
Logger.error("Error polling ARP 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 || []

View file

@ -108,6 +108,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:arp_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)}
@ -143,6 +148,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
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)
vlans = load_vlans(snmp_data.device)
ip_addresses = load_ip_addresses(snmp_data.device)
processors = load_processors(snmp_data.device)
@ -188,6 +194,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:interfaces_by_type, interfaces_by_type)
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign(:neighbors, neighbors)
|> assign(:arp_entries, arp_entries)
|> 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")))

View file

@ -84,6 +84,22 @@
Neighbors
</.link>
<%= if @arp_entries && length(@arp_entries) > 0 do %>
<.link
patch={~p"/devices/#{@device.id}?tab=arp"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "arp" 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
]}
>
ARP Table
</.link>
<% end %>
<%= if @vlans && length(@vlans) > 0 do %>
<.link
patch={~p"/devices/#{@device.id}?tab=vlans"}
@ -786,8 +802,15 @@
{neighbor.protocol}
</span>
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
{neighbor.interface.if_name || neighbor.interface.if_descr}
<td class="px-4 py-3">
<.link
navigate={
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{neighbor.interface.id}"
}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{neighbor.interface.if_name || neighbor.interface.if_descr}
</.link>
</td>
<td class="px-4 py-3">
<%= if neighbor.matched_device do %>
@ -852,6 +875,86 @@
</div>
</div>
<% end %>
<% "arp" -> %>
<%= if @arp_entries && length(@arp_entries) > 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">
ARP Table
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@arp_entries)} 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">IP Address</th>
<th class="px-4 py-3 text-left font-medium">MAC Address</th>
<th class="px-4 py-3 text-left font-medium">Interface</th>
<th class="px-4 py-3 text-left font-medium">Type</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 arp <- @arp_entries do %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">
{arp.ip_address}
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
{arp.mac_address}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
<%= if arp.interface do %>
<.link
navigate={
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{arp.interface.id}"
}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{arp.interface.if_name || arp.interface.if_descr}
</.link>
<% else %>
<span class="text-gray-400">ifIndex {arp.if_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",
arp.entry_type == "dynamic" &&
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
arp.entry_type == "static" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
arp.entry_type not in ["dynamic", "static"] &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{arp.entry_type || "unknown"}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{time_ago(arp.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 ARP entries discovered
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
This device doesn't have any ARP entries, or ARP 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">

View file

@ -76,34 +76,9 @@ defmodule ToweropsWeb.GraphLive.Show do
device_id = socket.assigns.device_id
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
interface_id = socket.assigns.interface_id
sensor_id = socket.assigns.sensor_id
storage_id = socket.assigns.storage_id
device = Devices.get_device!(device_id)
{chart_data, title_suffix} =
cond do
sensor_type == "latency" ->
{load_latency_chart_data(device_id, range), nil}
sensor_type == "traffic" && interface_id ->
{load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)}
sensor_type == "traffic" ->
{load_traffic_chart_data(device_id, range), nil}
sensor_type == "storage_volume" && storage_id ->
{load_storage_chart_data(storage_id, range), get_storage_name(storage_id)}
sensor_id ->
# Load only the specific sensor when sensor_id is provided
{load_single_sensor_chart_data(sensor_id, range), get_sensor_name(sensor_id)}
true ->
sensor_types = get_sensor_types_for_chart(sensor_type)
{load_sensor_chart_data(device_id, sensor_types, range), nil}
end
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
{title, unit, auto_scale} = get_chart_config(sensor_type)
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
@ -119,6 +94,33 @@ defmodule ToweropsWeb.GraphLive.Show do
|> assign(:show_zero_line, show_zero_line)
end
defp load_chart_data_for_type(%{sensor_type: "latency", device_id: device_id}, range) do
{load_latency_chart_data(device_id, range), nil}
end
defp load_chart_data_for_type(%{sensor_type: "traffic", interface_id: interface_id}, range)
when not is_nil(interface_id) do
{load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)}
end
defp load_chart_data_for_type(%{sensor_type: "traffic", device_id: device_id}, range) do
{load_traffic_chart_data(device_id, range), nil}
end
defp load_chart_data_for_type(%{sensor_type: "storage_volume", storage_id: storage_id}, range)
when not is_nil(storage_id) do
{load_storage_chart_data(storage_id, range), get_storage_name(storage_id)}
end
defp load_chart_data_for_type(%{sensor_id: sensor_id}, range) when not is_nil(sensor_id) do
{load_single_sensor_chart_data(sensor_id, range), get_sensor_name(sensor_id)}
end
defp load_chart_data_for_type(%{sensor_type: sensor_type, device_id: device_id}, range) do
sensor_types = get_sensor_types_for_chart(sensor_type)
{load_sensor_chart_data(device_id, sensor_types, range), nil}
end
defp get_sensor_types_for_chart("processors"), do: ["cpu_load"]
defp get_sensor_types_for_chart("memory"), do: ["memory_usage"]
defp get_sensor_types_for_chart("storage"), do: ["disk_usage"]

View file

@ -0,0 +1,31 @@
defmodule Towerops.Repo.Migrations.AddSnmpArpEntries do
use Ecto.Migration
def up do
create table(:snmp_arp_entries, 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),
null: true
add :ip_address, :string, null: false
add :mac_address, :string, null: false
add :entry_type, :string
add :if_index, :integer
add :last_seen_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
create index(:snmp_arp_entries, [:device_id])
create index(:snmp_arp_entries, [:device_id, :ip_address])
create index(:snmp_arp_entries, [:mac_address])
create unique_index(:snmp_arp_entries, [:device_id, :ip_address, :mac_address])
end
def down do
drop table(:snmp_arp_entries)
end
end

View file

@ -0,0 +1,233 @@
defmodule Towerops.Snmp.ArpDiscoveryTest do
use ExUnit.Case, async: true
import Mox
alias Towerops.Snmp.ArpDiscovery
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
describe "discover_arp_table/1" do
test "returns empty list when no ARP entries found" do
# Mock empty responses for both walks (phys_address and type)
expect(SnmpMock, :walk, 2, fn _, _oid, _ ->
{:ok, []}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
assert arp_entries == []
end
test "handles SNMP errors gracefully" do
# Mock error responses for both walks
expect(SnmpMock, :walk, 2, fn _, _oid, _ ->
{:error, :timeout}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
assert arp_entries == []
end
test "discovers ARP entries with dynamic type" do
# First walk: ipNetToMediaPhysAddress (MAC addresses)
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
# OID format: base.if_index.ip_octet1.ip_octet2.ip_octet3.ip_octet4
{:ok,
[
%{
oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.100",
value: <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>
},
%{
oid: "1.3.6.1.2.1.4.22.1.2.2.10.0.0.50",
value: <<0x11, 0x22, 0x33, 0x44, 0x55, 0x66>>
}
]}
else
{:ok, []}
end
end)
# Second walk: ipNetToMediaType (entry types)
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.4") do
{:ok,
[
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.100", value: 3},
%{oid: "1.3.6.1.2.1.4.22.1.4.2.10.0.0.50", value: 3}
]}
else
{:ok, []}
end
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
assert length(arp_entries) == 2
entry1 = Enum.find(arp_entries, &(&1.ip_address == "192.168.1.100"))
assert entry1.mac_address == "aa:bb:cc:dd:ee:ff"
assert entry1.if_index == 1
assert entry1.entry_type == "dynamic"
entry2 = Enum.find(arp_entries, &(&1.ip_address == "10.0.0.50"))
assert entry2.mac_address == "11:22:33:44:55:66"
assert entry2.if_index == 2
assert entry2.entry_type == "dynamic"
end
test "discovers ARP entries with static type" do
# First walk: MAC addresses
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
{:ok,
[
%{
oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.1",
value: <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01>>
}
]}
else
{:ok, []}
end
end)
# Second walk: entry types (4 = static)
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.4") do
{:ok,
[
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.1", value: 4}
]}
else
{:ok, []}
end
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
assert length(arp_entries) == 1
[entry] = arp_entries
assert entry.ip_address == "192.168.1.1"
assert entry.mac_address == "de:ad:be:ef:00:01"
assert entry.if_index == 1
assert entry.entry_type == "static"
end
test "handles entries without type information" do
# First walk: MAC addresses
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
{:ok,
[
%{
oid: "1.3.6.1.2.1.4.22.1.2.5.172.16.0.1",
value: <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>
}
]}
else
{:ok, []}
end
end)
# Second walk: empty (no type information)
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
assert length(arp_entries) == 1
[entry] = arp_entries
assert entry.ip_address == "172.16.0.1"
assert entry.mac_address == "00:11:22:33:44:55"
assert entry.if_index == 5
assert entry.entry_type == nil
end
test "handles all entry types" do
# First walk: MAC addresses
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
{:ok,
[
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.1", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x06>>},
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.2", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x07>>},
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.3", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x08>>},
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.4", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x09>>}
]}
else
{:ok, []}
end
end)
# Second walk: entry types
# 1 = other, 2 = invalid, 3 = dynamic, 4 = static
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.4") do
{:ok,
[
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.1", value: 1},
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.2", value: 2},
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.3", value: 3},
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.4", value: 4}
]}
else
{:ok, []}
end
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
assert length(arp_entries) == 4
types_by_ip = Map.new(arp_entries, &{&1.ip_address, &1.entry_type})
assert types_by_ip["192.168.1.1"] == "other"
assert types_by_ip["192.168.1.2"] == "invalid"
assert types_by_ip["192.168.1.3"] == "dynamic"
assert types_by_ip["192.168.1.4"] == "static"
end
test "includes last_seen_at timestamp" do
now = DateTime.utc_now()
# First walk: MAC addresses
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
{:ok,
[
%{oid: "1.3.6.1.2.1.4.22.1.2.1.10.0.0.1", value: <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>}
]}
else
{:ok, []}
end
end)
# Second walk: empty
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
assert length(arp_entries) == 1
[entry] = arp_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
end

View file

@ -4,6 +4,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
import Towerops.AccountsFixtures
alias Towerops.Snmp
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Neighbor
@ -286,6 +287,114 @@ defmodule Towerops.Snmp.NeighborCleanupWorkerTest do
end
end
describe "ARP cleanup" do
test "cleanup removes stale ARP entries across all devices", %{
device_schema1: device_schema1,
device_schema2: device_schema2,
interface1: interface1,
interface2: interface2
} do
two_days_ago = DateTime.add(DateTime.utc_now(), -48, :hour)
one_hour_ago = DateTime.add(DateTime.utc_now(), -1, :hour)
# Old ARP entry on device1
{:ok, old_arp1} =
Snmp.upsert_arp_entry(%{
device_id: device_schema1.id,
interface_id: interface1.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:01",
entry_type: "dynamic",
if_index: 1,
last_seen_at: two_days_ago
})
# Old ARP entry on device2
{:ok, old_arp2} =
Snmp.upsert_arp_entry(%{
device_id: device_schema2.id,
interface_id: interface2.id,
ip_address: "192.168.1.200",
mac_address: "aa:bb:cc:dd:ee:02",
entry_type: "dynamic",
if_index: 1,
last_seen_at: two_days_ago
})
# Recent ARP entry on device1
{:ok, recent_arp} =
Snmp.upsert_arp_entry(%{
device_id: device_schema1.id,
interface_id: interface1.id,
ip_address: "192.168.1.50",
mac_address: "aa:bb:cc:dd:ee:03",
entry_type: "static",
if_index: 1,
last_seen_at: one_hour_ago
})
{:ok, pid} = NeighborCleanupWorker.start_link()
send(pid, :cleanup)
Process.sleep(100)
# Old ARP entries should be deleted
assert Repo.get(ArpEntry, old_arp1.id) == nil
assert Repo.get(ArpEntry, old_arp2.id) == nil
# Recent ARP entry should still exist
assert Repo.get(ArpEntry, recent_arp.id)
GenServer.stop(pid)
end
test "cleanup preserves ARP entries within 24 hour threshold", %{
device_schema1: device_schema1,
interface1: interface1
} do
now = DateTime.utc_now()
twenty_three_hours_ago = DateTime.add(now, -23, :hour)
twenty_five_hours_ago = DateTime.add(now, -25, :hour)
# ARP entry just within threshold
{:ok, recent_arp} =
Snmp.upsert_arp_entry(%{
device_id: device_schema1.id,
interface_id: interface1.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:01",
entry_type: "dynamic",
if_index: 1,
last_seen_at: twenty_three_hours_ago
})
# ARP entry just outside threshold
{:ok, stale_arp} =
Snmp.upsert_arp_entry(%{
device_id: device_schema1.id,
interface_id: interface1.id,
ip_address: "192.168.1.200",
mac_address: "aa:bb:cc:dd:ee:02",
entry_type: "dynamic",
if_index: 1,
last_seen_at: twenty_five_hours_ago
})
{:ok, pid} = NeighborCleanupWorker.start_link()
send(pid, :cleanup)
Process.sleep(100)
# Recent ARP entry should still exist
assert Repo.get(ArpEntry, recent_arp.id)
# Stale ARP entry should be deleted
assert Repo.get(ArpEntry, stale_arp.id) == nil
GenServer.stop(pid)
end
end
describe "scheduled cleanup" do
test "worker schedules periodic cleanup" do
{:ok, pid} = NeighborCleanupWorker.start_link()

View file

@ -30,8 +30,8 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
describe "discover_neighbors/2" do
test "handles no neighbors found", %{interfaces: interfaces} do
# Mock empty responses
expect(SnmpMock, :walk, 2, fn _, _oid, _ ->
# Mock empty responses (3 walks: lldp_rem, lldp_man_addr, cdp)
expect(SnmpMock, :walk, 3, fn _, _oid, _ ->
{:ok, []}
end)
@ -42,7 +42,8 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
end
test "handles SNMP errors gracefully", %{interfaces: interfaces} do
# Mock SNMP errors
# When LLDP remote table fails, we don't call management address walk
# So only 2 walks happen: lldp_rem (fails) and cdp (fails)
expect(SnmpMock, :walk, 2, fn _, _oid, _ ->
{:error, :timeout}
end)
@ -52,5 +53,213 @@ defmodule Towerops.Snmp.NeighborDiscoveryTest do
assert neighbors == []
end
test "discovers LLDP neighbors with management addresses", %{interfaces: interfaces} do
# First walk: LLDP remote table
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.0.8802.1.1.2.1.4.1.1") do
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "neighbor-switch-1"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.7.0.1.1", value: "Gi0/1"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "neighbor-switch-1.example.com"}
]}
else
{:ok, []}
end
end)
# Second walk: LLDP management address table
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.0.8802.1.1.2.1.4.2") do
# Management address is encoded in the OID index
# OID: base.1.field.time.local_port.rem_index.addr_subtype.addr_len.addr_bytes
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.2.1.3.0.1.1.1.4.192.168.1.100", value: 2}
]}
else
{:ok, []}
end
end)
# Third walk: CDP (empty)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert length(neighbors) == 1
[neighbor] = neighbors
assert neighbor.protocol == "lldp"
assert neighbor.remote_chassis_id == "neighbor-switch-1"
assert neighbor.remote_system_name == "neighbor-switch-1.example.com"
assert neighbor.remote_address == "192.168.1.100"
end
test "discovers LLDP neighbors without management addresses", %{interfaces: interfaces} do
# First walk: LLDP remote table
expect(SnmpMock, :walk, fn _, _oid, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "neighbor-switch-2"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "neighbor-2.example.com"}
]}
end)
# Second walk: LLDP management address table (empty)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
# Third walk: CDP (empty)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert length(neighbors) == 1
[neighbor] = neighbors
assert neighbor.remote_chassis_id == "neighbor-switch-2"
assert neighbor.remote_address == nil
end
test "discovers CDP neighbors with IP addresses", %{interfaces: interfaces} do
# First walk: LLDP remote table (empty)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
# Second walk: LLDP management address table (empty - but still called)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
# Third walk: CDP cache table
expect(SnmpMock, :walk, fn _, _oid, _ ->
{:ok,
[
# Address (field 4) - 4 bytes for IPv4: 10.0.0.1
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.4.1.1", value: <<10, 0, 0, 1>>},
# System Name (field 6)
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.1.1", value: "cisco-switch.example.com"},
# Port ID (field 7)
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.7.1.1", value: "GigabitEthernet1/0/1"},
# Platform (field 8)
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.8.1.1", value: "Cisco IOS"}
]}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert length(neighbors) == 1
[neighbor] = neighbors
assert neighbor.protocol == "cdp"
assert neighbor.remote_system_name == "cisco-switch.example.com"
assert neighbor.remote_address == "10.0.0.1"
assert neighbor.remote_platform == "Cisco IOS"
end
test "discovers multiple LLDP neighbors with different management addresses", %{
interfaces: interfaces
} do
# First walk: LLDP remote table
expect(SnmpMock, :walk, fn _, _oid, _ ->
{:ok,
[
# Neighbor 1 on port 1
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "switch-1"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "switch-1.local"},
# Neighbor 2 on port 2
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.2.1", value: "switch-2"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.2.1", value: "switch-2.local"}
]}
end)
# Second walk: LLDP management address table
expect(SnmpMock, :walk, fn _, _oid, _ ->
{:ok,
[
# IPv4 192.168.1.10 for neighbor on port 1
%{oid: "1.0.8802.1.1.2.1.4.2.1.3.0.1.1.1.4.192.168.1.10", value: 2},
# IPv4 192.168.1.20 for neighbor on port 2
%{oid: "1.0.8802.1.1.2.1.4.2.1.3.0.2.1.1.4.192.168.1.20", value: 2}
]}
end)
# Third walk: CDP (empty)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert length(neighbors) == 2
neighbor_1 = Enum.find(neighbors, &(&1.remote_chassis_id == "switch-1"))
neighbor_2 = Enum.find(neighbors, &(&1.remote_chassis_id == "switch-2"))
assert neighbor_1.remote_address == "192.168.1.10"
assert neighbor_2.remote_address == "192.168.1.20"
end
test "handles IPv6 management addresses", %{interfaces: interfaces} do
# First walk: LLDP remote table
expect(SnmpMock, :walk, fn _, _oid, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "ipv6-switch"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "ipv6-switch.local"}
]}
end)
# Second walk: LLDP management address table with IPv6
expect(SnmpMock, :walk, fn _, _oid, _ ->
# IPv6 address 2001:db8::1
# addr_subtype=2 (IPv6), addr_len=16
# 2001:0db8:0000:0000:0000:0000:0000:0001
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.2.1.3.0.1.1.2.16.32.1.13.184.0.0.0.0.0.0.0.0.0.0.0.1", value: 2}
]}
end)
# Third walk: CDP (empty)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert length(neighbors) == 1
[neighbor] = neighbors
assert neighbor.remote_chassis_id == "ipv6-switch"
# IPv6 address formatted
assert neighbor.remote_address == "2001:DB8:0:0:0:0:0:1"
end
test "handles malformed management address OIDs gracefully", %{interfaces: interfaces} do
# First walk: LLDP remote table
expect(SnmpMock, :walk, fn _, _oid, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "switch-malformed"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "switch-malformed.local"}
]}
end)
# Second walk: LLDP management address table with malformed entry
expect(SnmpMock, :walk, fn _, _oid, _ ->
# Malformed OID - not enough parts
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.2.1.3.0.1", value: 2}
]}
end)
# Third walk: CDP (empty)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert length(neighbors) == 1
[neighbor] = neighbors
# No management address parsed due to malformed OID
assert neighbor.remote_address == nil
end
end
end

View file

@ -1639,4 +1639,334 @@ defmodule Towerops.SnmpTest do
assert changeset.errors[:storage_id]
end
end
describe "list_arp_entries/1" do
test "returns all ARP entries for a device", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 1
assert hd(entries).id == arp.id
assert Ecto.assoc_loaded?(hd(entries).interface)
end
test "returns empty list for device with no ARP entries", %{device: device} do
assert Snmp.list_arp_entries(device.id) == []
end
test "orders ARP entries by IP address", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp2 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
mac_address: "aa:bb:cc:dd:ee:02",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
arp1 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:01",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 2
assert Enum.at(entries, 0).id == arp1.id
assert Enum.at(entries, 1).id == arp2.id
end
end
describe "get_arp_entry/1" do
test "returns ARP entry by id", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
found = Snmp.get_arp_entry(arp.id)
assert found.id == arp.id
assert found.ip_address == "192.168.1.100"
end
test "returns nil for non-existent id" do
assert Snmp.get_arp_entry(Ecto.UUID.generate()) == nil
end
end
describe "upsert_arp_entry/1" do
test "creates new ARP entry when none exists", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
attrs = %{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.utc_now()
}
{:ok, arp} = Snmp.upsert_arp_entry(attrs)
assert arp.ip_address == "192.168.1.100"
assert arp.mac_address == "aa:bb:cc:dd:ee:ff"
end
test "updates existing ARP entry when match found", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
existing =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_time = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "static",
if_index: 1,
last_seen_at: new_time
}
{:ok, updated} = Snmp.upsert_arp_entry(attrs)
assert updated.id == existing.id
assert updated.entry_type == "static"
end
end
describe "delete_stale_arp_entries/2" do
test "deletes ARP entries older than cutoff", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
recent_time = DateTime.add(DateTime.utc_now(), -60, :second)
old_arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: old_time
})
|> Repo.insert!()
recent_arp =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
last_seen_at: recent_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_arp_entries(device.id, cutoff)
assert deleted_count == 1
assert Snmp.get_arp_entry(old_arp.id) == nil
assert Snmp.get_arp_entry(recent_arp.id)
end
test "does not delete ARP entries from other devices", %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Another Site",
organization_id: organization.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id
})
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
arp1 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device1.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: old_time
})
|> Repo.insert!()
arp2 =
%Snmp.ArpEntry{}
|> Snmp.ArpEntry.changeset(%{
device_id: device2.id,
ip_address: "192.168.1.100",
mac_address: "11:22:33:44:55:66",
last_seen_at: old_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_arp_entries(device1.id, cutoff)
assert deleted_count == 1
assert Snmp.get_arp_entry(arp1.id) == nil
assert Snmp.get_arp_entry(arp2.id)
end
end
describe "upsert_arp_entries/3" do
test "creates multiple ARP entries with interface mapping", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
},
%{
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
if_index: 1,
entry_type: "static",
last_seen_at: DateTime.utc_now()
}
]
count = Snmp.upsert_arp_entries(device.id, arp_data, [interface])
assert count == 2
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 2
ips = entries |> Enum.map(& &1.ip_address) |> Enum.sort()
assert ips == ["192.168.1.100", "192.168.1.200"]
# Both should have the interface linked
assert Enum.all?(entries, &(&1.interface_id == interface.id))
end
test "handles entries without matching interface", %{device: device} do
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 999,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
}
]
count = Snmp.upsert_arp_entries(device.id, arp_data, [])
assert count == 1
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 1
assert hd(entries).interface_id == nil
assert hd(entries).if_index == 999
end
end
end