arp
This commit is contained in:
parent
5f6ac5ddb9
commit
7bd3b1f9ec
15 changed files with 1478 additions and 55 deletions
|
|
@ -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
|
||||
|
|
|
|||
138
lib/towerops/snmp/arp_discovery.ex
Normal file
138
lib/towerops/snmp/arp_discovery.ex
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
defmodule Towerops.Snmp.ArpDiscovery do
|
||||
@moduledoc """
|
||||
Discovers ARP table entries via SNMP.
|
||||
|
||||
Uses ipNetToMediaTable (1.3.6.1.2.1.4.22) from IP-MIB to retrieve
|
||||
IP-to-MAC address mappings from network devices.
|
||||
"""
|
||||
|
||||
alias Towerops.Snmp.Client
|
||||
|
||||
require Logger
|
||||
|
||||
@type arp_entry :: %{
|
||||
required(:ip_address) => String.t(),
|
||||
required(:mac_address) => String.t(),
|
||||
required(:if_index) => integer(),
|
||||
optional(:entry_type) => String.t(),
|
||||
required(:last_seen_at) => DateTime.t()
|
||||
}
|
||||
|
||||
# ipNetToMediaTable OIDs (IP-MIB)
|
||||
# Index: ipNetToMediaIfIndex.ipNetToMediaNetAddress
|
||||
@ip_net_to_media_phys_address "1.3.6.1.2.1.4.22.1.2"
|
||||
@ip_net_to_media_type "1.3.6.1.2.1.4.22.1.4"
|
||||
|
||||
@doc """
|
||||
Discovers ARP table entries for a device.
|
||||
Returns {:ok, arp_entries} where arp_entries is a list of arp_entry maps.
|
||||
"""
|
||||
@spec discover_arp_table(Client.connection_opts()) :: {:ok, [arp_entry()]}
|
||||
def discover_arp_table(client_opts) do
|
||||
mac_addresses = fetch_arp_mac_addresses(client_opts)
|
||||
entry_types = fetch_arp_entry_types(client_opts)
|
||||
|
||||
arp_entries =
|
||||
mac_addresses
|
||||
|> Enum.map(fn {key, mac} ->
|
||||
{if_index, ip_address} = parse_arp_key(key)
|
||||
entry_type = Map.get(entry_types, key)
|
||||
|
||||
%{
|
||||
ip_address: ip_address,
|
||||
mac_address: format_mac_address(mac),
|
||||
if_index: if_index,
|
||||
entry_type: entry_type_to_string(entry_type),
|
||||
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
end)
|
||||
|> Enum.reject(&is_nil(&1.ip_address))
|
||||
|
||||
{:ok, arp_entries}
|
||||
end
|
||||
|
||||
# Fetch MAC addresses from ipNetToMediaPhysAddress
|
||||
defp fetch_arp_mac_addresses(client_opts) do
|
||||
case Client.walk(client_opts, @ip_net_to_media_phys_address) do
|
||||
{:ok, entries} when map_size(entries) > 0 ->
|
||||
# Convert OID keys to just the index part (if_index.ip_address)
|
||||
Map.new(entries, fn {oid, value} ->
|
||||
key = extract_arp_index(oid, @ip_net_to_media_phys_address)
|
||||
{key, value}
|
||||
end)
|
||||
|
||||
{:ok, _} ->
|
||||
Logger.debug("No ARP entries found")
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("ARP table discovery failed: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch entry types from ipNetToMediaType
|
||||
defp fetch_arp_entry_types(client_opts) do
|
||||
case Client.walk(client_opts, @ip_net_to_media_type) do
|
||||
{:ok, entries} when map_size(entries) > 0 ->
|
||||
Map.new(entries, fn {oid, value} ->
|
||||
key = extract_arp_index(oid, @ip_net_to_media_type)
|
||||
{key, value}
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
# Extract the index portion from an OID (if_index.ip_address)
|
||||
defp extract_arp_index(oid, base_oid) do
|
||||
# Remove the base OID prefix and the trailing "."
|
||||
base_len = String.length(base_oid) + 1
|
||||
String.slice(oid, base_len..-1//1)
|
||||
end
|
||||
|
||||
# Parse the ARP table index into if_index and IP address
|
||||
# Index format: if_index.ip_octet1.ip_octet2.ip_octet3.ip_octet4
|
||||
defp parse_arp_key(key) do
|
||||
case String.split(key, ".") do
|
||||
[if_index | ip_parts] when length(ip_parts) == 4 ->
|
||||
ip_address = Enum.join(ip_parts, ".")
|
||||
{String.to_integer(if_index), ip_address}
|
||||
|
||||
_ ->
|
||||
{nil, nil}
|
||||
end
|
||||
end
|
||||
|
||||
# Format MAC address from binary to colon-separated hex
|
||||
defp format_mac_address(value) when is_binary(value) and byte_size(value) == 6 do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp format_mac_address(value) when is_binary(value) do
|
||||
# Already formatted or different format
|
||||
if String.contains?(value, ":") or String.contains?(value, "-") do
|
||||
String.downcase(value)
|
||||
else
|
||||
# Try to convert hex string
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
end
|
||||
|
||||
defp format_mac_address(_), do: nil
|
||||
|
||||
# Convert ipNetToMediaType integer to string
|
||||
# 1 = other, 2 = invalid, 3 = dynamic, 4 = static
|
||||
defp entry_type_to_string(1), do: "other"
|
||||
defp entry_type_to_string(2), do: "invalid"
|
||||
defp entry_type_to_string(3), do: "dynamic"
|
||||
defp entry_type_to_string(4), do: "static"
|
||||
defp entry_type_to_string(_), do: nil
|
||||
end
|
||||
48
lib/towerops/snmp/arp_entry.ex
Normal file
48
lib/towerops/snmp/arp_entry.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
defmodule Towerops.Snmp.ArpEntry do
|
||||
@moduledoc """
|
||||
SNMP ARP entry schema for IP-to-MAC address mappings.
|
||||
|
||||
Stores ARP table entries discovered via ipNetToMediaTable (1.3.6.1.2.1.4.22).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "snmp_arp_entries" do
|
||||
field :ip_address, :string
|
||||
field :mac_address, :string
|
||||
field :entry_type, :string
|
||||
field :if_index, :integer
|
||||
field :last_seen_at, :utc_datetime
|
||||
|
||||
belongs_to :device, Towerops.Devices.Device
|
||||
belongs_to :interface, Towerops.Snmp.Interface
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(arp_entry, attrs) do
|
||||
arp_entry
|
||||
|> cast(attrs, [
|
||||
:device_id,
|
||||
:interface_id,
|
||||
:ip_address,
|
||||
:mac_address,
|
||||
:entry_type,
|
||||
:if_index,
|
||||
:last_seen_at
|
||||
])
|
||||
|> validate_required([
|
||||
:device_id,
|
||||
:ip_address,
|
||||
:mac_address,
|
||||
:last_seen_at
|
||||
])
|
||||
|> foreign_key_constraint(:device_id)
|
||||
|> foreign_key_constraint(:interface_id)
|
||||
|> unique_constraint([:device_id, :ip_address, :mac_address])
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || []
|
||||
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -84,6 +84,22 @@
|
|||
Neighbors
|
||||
</.link>
|
||||
|
||||
<%= if @arp_entries && length(@arp_entries) > 0 do %>
|
||||
<.link
|
||||
patch={~p"/devices/#{@device.id}?tab=arp"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "arp" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
ARP Table
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<%= if @vlans && length(@vlans) > 0 do %>
|
||||
<.link
|
||||
patch={~p"/devices/#{@device.id}?tab=vlans"}
|
||||
|
|
@ -786,8 +802,15 @@
|
|||
{neighbor.protocol}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
||||
{neighbor.interface.if_name || neighbor.interface.if_descr}
|
||||
<td class="px-4 py-3">
|
||||
<.link
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{neighbor.interface.id}"
|
||||
}
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{neighbor.interface.if_name || neighbor.interface.if_descr}
|
||||
</.link>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<%= if neighbor.matched_device do %>
|
||||
|
|
@ -852,6 +875,86 @@
|
|||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% "arp" -> %>
|
||||
<%= if @arp_entries && length(@arp_entries) > 0 do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
ARP Table
|
||||
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
({length(@arp_entries)} entries)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
||||
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<th class="px-4 py-3 text-left font-medium">IP Address</th>
|
||||
<th class="px-4 py-3 text-left font-medium">MAC Address</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Interface</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Type</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for arp <- @arp_entries do %>
|
||||
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">
|
||||
{arp.ip_address}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{arp.mac_address}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
<%= if arp.interface do %>
|
||||
<.link
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{arp.interface.id}"
|
||||
}
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{arp.interface.if_name || arp.interface.if_descr}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-400">ifIndex {arp.if_index}</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
||||
arp.entry_type == "dynamic" &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
arp.entry_type == "static" &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
arp.entry_type not in ["dynamic", "static"] &&
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
]}>
|
||||
{arp.entry_type || "unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{time_ago(arp.last_seen_at)}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-table-cells" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
No ARP entries discovered
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
This device doesn't have any ARP entries, or ARP discovery hasn't run yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% "vlans" -> %>
|
||||
<%= if @vlans && length(@vlans) > 0 do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
31
priv/repo/migrations/20260123142110_add_snmp_arp_entries.exs
Normal file
31
priv/repo/migrations/20260123142110_add_snmp_arp_entries.exs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule Towerops.Repo.Migrations.AddSnmpArpEntries do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:snmp_arp_entries, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :interface_id, references(:snmp_interfaces, type: :binary_id, on_delete: :delete_all),
|
||||
null: true
|
||||
|
||||
add :ip_address, :string, null: false
|
||||
add :mac_address, :string, null: false
|
||||
add :entry_type, :string
|
||||
add :if_index, :integer
|
||||
add :last_seen_at, :utc_datetime, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:snmp_arp_entries, [:device_id])
|
||||
create index(:snmp_arp_entries, [:device_id, :ip_address])
|
||||
create index(:snmp_arp_entries, [:mac_address])
|
||||
create unique_index(:snmp_arp_entries, [:device_id, :ip_address, :mac_address])
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:snmp_arp_entries)
|
||||
end
|
||||
end
|
||||
233
test/towerops/snmp/arp_discovery_test.exs
Normal file
233
test/towerops/snmp/arp_discovery_test.exs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
defmodule Towerops.Snmp.ArpDiscoveryTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.ArpDiscovery
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "discover_arp_table/1" do
|
||||
test "returns empty list when no ARP entries found" do
|
||||
# Mock empty responses for both walks (phys_address and type)
|
||||
expect(SnmpMock, :walk, 2, fn _, _oid, _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
|
||||
|
||||
assert arp_entries == []
|
||||
end
|
||||
|
||||
test "handles SNMP errors gracefully" do
|
||||
# Mock error responses for both walks
|
||||
expect(SnmpMock, :walk, 2, fn _, _oid, _ ->
|
||||
{:error, :timeout}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
|
||||
|
||||
assert arp_entries == []
|
||||
end
|
||||
|
||||
test "discovers ARP entries with dynamic type" do
|
||||
# First walk: ipNetToMediaPhysAddress (MAC addresses)
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
|
||||
# OID format: base.if_index.ip_octet1.ip_octet2.ip_octet3.ip_octet4
|
||||
{:ok,
|
||||
[
|
||||
%{
|
||||
oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.100",
|
||||
value: <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>
|
||||
},
|
||||
%{
|
||||
oid: "1.3.6.1.2.1.4.22.1.2.2.10.0.0.50",
|
||||
value: <<0x11, 0x22, 0x33, 0x44, 0x55, 0x66>>
|
||||
}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# Second walk: ipNetToMediaType (entry types)
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.4") do
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.100", value: 3},
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.4.2.10.0.0.50", value: 3}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
|
||||
|
||||
assert length(arp_entries) == 2
|
||||
|
||||
entry1 = Enum.find(arp_entries, &(&1.ip_address == "192.168.1.100"))
|
||||
assert entry1.mac_address == "aa:bb:cc:dd:ee:ff"
|
||||
assert entry1.if_index == 1
|
||||
assert entry1.entry_type == "dynamic"
|
||||
|
||||
entry2 = Enum.find(arp_entries, &(&1.ip_address == "10.0.0.50"))
|
||||
assert entry2.mac_address == "11:22:33:44:55:66"
|
||||
assert entry2.if_index == 2
|
||||
assert entry2.entry_type == "dynamic"
|
||||
end
|
||||
|
||||
test "discovers ARP entries with static type" do
|
||||
# First walk: MAC addresses
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
|
||||
{:ok,
|
||||
[
|
||||
%{
|
||||
oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.1",
|
||||
value: <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01>>
|
||||
}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# Second walk: entry types (4 = static)
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.4") do
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.1", value: 4}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
|
||||
|
||||
assert length(arp_entries) == 1
|
||||
|
||||
[entry] = arp_entries
|
||||
assert entry.ip_address == "192.168.1.1"
|
||||
assert entry.mac_address == "de:ad:be:ef:00:01"
|
||||
assert entry.if_index == 1
|
||||
assert entry.entry_type == "static"
|
||||
end
|
||||
|
||||
test "handles entries without type information" do
|
||||
# First walk: MAC addresses
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
|
||||
{:ok,
|
||||
[
|
||||
%{
|
||||
oid: "1.3.6.1.2.1.4.22.1.2.5.172.16.0.1",
|
||||
value: <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>
|
||||
}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# Second walk: empty (no type information)
|
||||
expect(SnmpMock, :walk, fn _, _, _ ->
|
||||
{:ok, []}
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
|
||||
|
||||
assert length(arp_entries) == 1
|
||||
|
||||
[entry] = arp_entries
|
||||
assert entry.ip_address == "172.16.0.1"
|
||||
assert entry.mac_address == "00:11:22:33:44:55"
|
||||
assert entry.if_index == 5
|
||||
assert entry.entry_type == nil
|
||||
end
|
||||
|
||||
test "handles all entry types" do
|
||||
# First walk: MAC addresses
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.1", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x06>>},
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.2", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x07>>},
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.3", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x08>>},
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.2.1.192.168.1.4", value: <<0x01, 0x02, 0x03, 0x04, 0x05, 0x09>>}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# Second walk: entry types
|
||||
# 1 = other, 2 = invalid, 3 = dynamic, 4 = static
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.4") do
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.1", value: 1},
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.2", value: 2},
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.3", value: 3},
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.4.1.192.168.1.4", value: 4}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
|
||||
|
||||
assert length(arp_entries) == 4
|
||||
|
||||
types_by_ip = Map.new(arp_entries, &{&1.ip_address, &1.entry_type})
|
||||
|
||||
assert types_by_ip["192.168.1.1"] == "other"
|
||||
assert types_by_ip["192.168.1.2"] == "invalid"
|
||||
assert types_by_ip["192.168.1.3"] == "dynamic"
|
||||
assert types_by_ip["192.168.1.4"] == "static"
|
||||
end
|
||||
|
||||
test "includes last_seen_at timestamp" do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
# First walk: MAC addresses
|
||||
expect(SnmpMock, :walk, fn _, oid, _ ->
|
||||
if String.starts_with?(oid, "1.3.6.1.2.1.4.22.1.2") do
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "1.3.6.1.2.1.4.22.1.2.1.10.0.0.1", value: <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>}
|
||||
]}
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
# Second walk: empty
|
||||
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
|
||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||
{:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)
|
||||
|
||||
assert length(arp_entries) == 1
|
||||
[entry] = arp_entries
|
||||
|
||||
assert %DateTime{} = entry.last_seen_at
|
||||
# Timestamp should be within 1 second of now
|
||||
assert DateTime.diff(entry.last_seen_at, now, :second) <= 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue