diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex
index c7055412..13d7a815 100644
--- a/lib/towerops/snmp.ex
+++ b/lib/towerops/snmp.ex
@@ -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
diff --git a/lib/towerops/snmp/arp_discovery.ex b/lib/towerops/snmp/arp_discovery.ex
new file mode 100644
index 00000000..89a0c02b
--- /dev/null
+++ b/lib/towerops/snmp/arp_discovery.ex
@@ -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
diff --git a/lib/towerops/snmp/arp_entry.ex b/lib/towerops/snmp/arp_entry.ex
new file mode 100644
index 00000000..7a01c5d4
--- /dev/null
+++ b/lib/towerops/snmp/arp_entry.ex
@@ -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
diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex
index a83a78ad..6d571c4c 100644
--- a/lib/towerops/snmp/discovery.ex
+++ b/lib/towerops/snmp/discovery.ex
@@ -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
diff --git a/lib/towerops/snmp/neighbor_cleanup_worker.ex b/lib/towerops/snmp/neighbor_cleanup_worker.ex
index 776aed17..6b6364d9 100644
--- a/lib/towerops/snmp/neighbor_cleanup_worker.ex
+++ b/lib/towerops/snmp/neighbor_cleanup_worker.ex
@@ -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
diff --git a/lib/towerops/snmp/neighbor_discovery.ex b/lib/towerops/snmp/neighbor_discovery.ex
index bd46f725..6d275130 100644
--- a/lib/towerops/snmp/neighbor_discovery.ex
+++ b/lib/towerops/snmp/neighbor_discovery.ex
@@ -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
diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex
index dfb11675..ed5df33c 100644
--- a/lib/towerops/snmp/poller_worker.ex
+++ b/lib/towerops/snmp/poller_worker.ex
@@ -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 || []
diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex
index 49c58136..d76f485e 100644
--- a/lib/towerops_web/live/device_live/show.ex
+++ b/lib/towerops_web/live/device_live/show.ex
@@ -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")))
diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex
index e18c390f..a5c470b2 100644
--- a/lib/towerops_web/live/device_live/show.html.heex
+++ b/lib/towerops_web/live/device_live/show.html.heex
@@ -84,6 +84,22 @@
Neighbors
+ <%= 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
+
+ <% end %>
+
<%= if @vlans && length(@vlans) > 0 do %>
<.link
patch={~p"/devices/#{@device.id}?tab=vlans"}
@@ -786,8 +802,15 @@
{neighbor.protocol}
-
- {neighbor.interface.if_name || neighbor.interface.if_descr}
+ |
+ <.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}
+
|
<%= if neighbor.matched_device do %>
@@ -852,6 +875,86 @@
<% end %>
+ <% "arp" -> %>
+ <%= if @arp_entries && length(@arp_entries) > 0 do %>
+
+
+
+ ARP Table
+
+ ({length(@arp_entries)} entries)
+
+
+
+
+
+
+
+ | IP Address |
+ MAC Address |
+ Interface |
+ Type |
+ Last Seen |
+
+
+
+ <%= for arp <- @arp_entries do %>
+
+ |
+ {arp.ip_address}
+ |
+
+ {arp.mac_address}
+ |
+
+ <%= 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}
+
+ <% else %>
+ ifIndex {arp.if_index}
+ <% end %>
+ |
+
+
+ {arp.entry_type || "unknown"}
+
+ |
+
+ {time_ago(arp.last_seen_at)}
+ |
+
+ <% end %>
+
+
+
+
+ <% else %>
+
+
+ <.icon name="hero-table-cells" class="mx-auto h-12 w-12 text-gray-400" />
+
+ No ARP entries discovered
+
+
+ This device doesn't have any ARP entries, or ARP discovery hasn't run yet.
+
+
+
+ <% end %>
<% "vlans" -> %>
<%= if @vlans && length(@vlans) > 0 do %>
diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex
index 440806fb..2aa49f42 100644
--- a/lib/towerops_web/live/graph_live/show.ex
+++ b/lib/towerops_web/live/graph_live/show.ex
@@ -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"]
diff --git a/priv/repo/migrations/20260123142110_add_snmp_arp_entries.exs b/priv/repo/migrations/20260123142110_add_snmp_arp_entries.exs
new file mode 100644
index 00000000..da660fa4
--- /dev/null
+++ b/priv/repo/migrations/20260123142110_add_snmp_arp_entries.exs
@@ -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
diff --git a/test/towerops/snmp/arp_discovery_test.exs b/test/towerops/snmp/arp_discovery_test.exs
new file mode 100644
index 00000000..f055cf36
--- /dev/null
+++ b/test/towerops/snmp/arp_discovery_test.exs
@@ -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
diff --git a/test/towerops/snmp/neighbor_cleanup_worker_test.exs b/test/towerops/snmp/neighbor_cleanup_worker_test.exs
index 2f1ec09f..923c6bc7 100644
--- a/test/towerops/snmp/neighbor_cleanup_worker_test.exs
+++ b/test/towerops/snmp/neighbor_cleanup_worker_test.exs
@@ -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()
diff --git a/test/towerops/snmp/neighbor_discovery_test.exs b/test/towerops/snmp/neighbor_discovery_test.exs
index dd47fc94..2bec964a 100644
--- a/test/towerops/snmp/neighbor_discovery_test.exs
+++ b/test/towerops/snmp/neighbor_discovery_test.exs
@@ -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
diff --git a/test/towerops/snmp_test.exs b/test/towerops/snmp_test.exs
index ea4f2b11..acb50d00 100644
--- a/test/towerops/snmp_test.exs
+++ b/test/towerops/snmp_test.exs
@@ -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
|