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) + +

+
+
+ + + + + + + + + + + + <%= for arp <- @arp_entries do %> + + + + + + + + <% end %> + +
IP AddressMAC AddressInterfaceTypeLast Seen
+ {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)} +
+
+
+ <% 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