diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 13d7a815..4af1a09e 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -16,6 +16,7 @@ defmodule Towerops.Snmp do alias Towerops.Snmp.Interface alias Towerops.Snmp.InterfaceStat alias Towerops.Snmp.IpAddress + alias Towerops.Snmp.MacAddress alias Towerops.Snmp.Neighbor alias Towerops.Snmp.Processor alias Towerops.Snmp.ProcessorReading @@ -815,4 +816,72 @@ defmodule Towerops.Snmp do length(arp_entries) end + + # MAC Address queries + + @doc """ + Lists all MAC address entries for a device. + """ + def list_mac_addresses(device_id) do + MacAddress + |> where([m], m.device_id == ^device_id) + |> order_by([m], [m.vlan_id, m.mac_address]) + |> preload(:interface) + |> Repo.all() + end + + @doc """ + Creates or updates a MAC address entry. + """ + def upsert_mac_address(attrs) do + case Repo.get_by(MacAddress, + device_id: attrs.device_id, + mac_address: attrs.mac_address, + vlan_id: attrs[:vlan_id] + ) do + nil -> + %MacAddress{} + |> MacAddress.changeset(attrs) + |> Repo.insert() + + mac_address -> + mac_address + |> MacAddress.changeset(attrs) + |> Repo.update() + end + end + + @doc """ + Deletes stale MAC address entries that haven't been seen since the given datetime. + """ + def delete_stale_mac_addresses(device_id, cutoff_datetime) do + MacAddress + |> where([m], m.device_id == ^device_id) + |> where([m], m.last_seen_at < ^cutoff_datetime) + |> Repo.delete_all() + end + + @doc """ + Bulk upserts MAC address entries for a device. + Returns the number of entries processed. + """ + def upsert_mac_addresses(device_id, mac_entries, interfaces) do + # Build a map from bridge port index to interface ID + # Note: port_index from BRIDGE-MIB is not the same as if_index + # For now, we'll use a simple mapping if they're available + interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end) + + Enum.each(mac_entries, fn entry -> + interface_id = Map.get(interface_map, entry.port_index) + + attrs = + entry + |> Map.put(:device_id, device_id) + |> Map.put(:interface_id, interface_id) + + upsert_mac_address(attrs) + end) + + length(mac_entries) + end end diff --git a/lib/towerops/snmp/mac_address.ex b/lib/towerops/snmp/mac_address.ex new file mode 100644 index 00000000..e69eeab0 --- /dev/null +++ b/lib/towerops/snmp/mac_address.ex @@ -0,0 +1,49 @@ +defmodule Towerops.Snmp.MacAddress do + @moduledoc """ + SNMP MAC address schema for bridge forwarding database entries. + + Stores MAC address table entries discovered via BRIDGE-MIB + dot1dTpFdbTable (1.3.6.1.2.1.17.4.3) and Q-BRIDGE-MIB + dot1qTpFdbTable (1.3.6.1.2.1.17.7.1.2.2). + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_mac_addresses" do + field :mac_address, :string + field :vlan_id, :integer + field :port_index, :integer + field :entry_status, :string + field :last_seen_at, :utc_datetime + + belongs_to :device, Towerops.Devices.Device + belongs_to :interface, Towerops.Snmp.Interface + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(mac_address, attrs) do + mac_address + |> cast(attrs, [ + :device_id, + :interface_id, + :mac_address, + :vlan_id, + :port_index, + :entry_status, + :last_seen_at + ]) + |> validate_required([ + :device_id, + :mac_address, + :last_seen_at + ]) + |> foreign_key_constraint(:device_id) + |> foreign_key_constraint(:interface_id) + |> unique_constraint([:device_id, :mac_address, :vlan_id]) + end +end diff --git a/lib/towerops/snmp/mac_discovery.ex b/lib/towerops/snmp/mac_discovery.ex new file mode 100644 index 00000000..606be6ed --- /dev/null +++ b/lib/towerops/snmp/mac_discovery.ex @@ -0,0 +1,218 @@ +defmodule Towerops.Snmp.MacDiscovery do + @moduledoc """ + Discovers MAC address forwarding table entries via SNMP. + + Uses BRIDGE-MIB dot1dTpFdbTable (1.3.6.1.2.1.17.4.3) and Q-BRIDGE-MIB + dot1qTpFdbTable (1.3.6.1.2.1.17.7.1.2.2) to retrieve MAC address + forwarding database entries from network switches. + """ + + alias Towerops.Snmp.Client + + require Logger + + @type mac_entry :: %{ + required(:mac_address) => String.t(), + required(:port_index) => integer(), + optional(:vlan_id) => integer(), + optional(:entry_status) => String.t(), + required(:last_seen_at) => DateTime.t() + } + + # BRIDGE-MIB FDB table OIDs + # dot1dTpFdbTable - basic FDB without VLAN awareness + @dot1d_tp_fdb_address "1.3.6.1.2.1.17.4.3.1.1" + @dot1d_tp_fdb_port "1.3.6.1.2.1.17.4.3.1.2" + @dot1d_tp_fdb_status "1.3.6.1.2.1.17.4.3.1.3" + + # Q-BRIDGE-MIB FDB table OIDs (VLAN-aware) + @dot1q_tp_fdb_port "1.3.6.1.2.1.17.7.1.2.2.1.2" + @dot1q_tp_fdb_status "1.3.6.1.2.1.17.7.1.2.2.1.3" + + @doc """ + Discovers MAC address forwarding table entries for a device. + Returns {:ok, mac_entries} where mac_entries is a list of mac_entry maps. + """ + @spec discover_mac_table(Client.connection_opts()) :: {:ok, [mac_entry()]} + def discover_mac_table(client_opts) do + # Try Q-BRIDGE-MIB first (VLAN-aware), fall back to BRIDGE-MIB + case discover_qbridge_fdb(client_opts) do + {:ok, [_ | _] = entries} -> + {:ok, entries} + + _ -> + discover_bridge_fdb(client_opts) + end + end + + # Discover using Q-BRIDGE-MIB (VLAN-aware) + defp discover_qbridge_fdb(client_opts) do + port_map = fetch_qbridge_ports(client_opts) + status_map = fetch_qbridge_status(client_opts) + + if map_size(port_map) == 0 do + {:ok, []} + else + entries = + port_map + |> Enum.map(fn {key, port_index} -> + {vlan_id, mac_address} = parse_qbridge_key(key) + status = Map.get(status_map, key) + + %{ + mac_address: mac_address, + port_index: port_index, + vlan_id: vlan_id, + entry_status: fdb_status_to_string(status), + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + } + end) + |> Enum.reject(&is_nil(&1.mac_address)) + + {:ok, entries} + end + end + + # Discover using BRIDGE-MIB (no VLAN awareness) + defp discover_bridge_fdb(client_opts) do + mac_map = fetch_bridge_macs(client_opts) + port_map = fetch_bridge_ports(client_opts) + status_map = fetch_bridge_status(client_opts) + + if map_size(mac_map) == 0 do + {:ok, []} + else + entries = + mac_map + |> Enum.map(fn {key, _mac_binary} -> + mac_address = parse_mac_from_key(key) + port_index = Map.get(port_map, key) + status = Map.get(status_map, key) + + %{ + mac_address: mac_address, + port_index: port_index, + vlan_id: nil, + entry_status: fdb_status_to_string(status), + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + } + end) + |> Enum.reject(&(is_nil(&1.mac_address) or is_nil(&1.port_index))) + + {:ok, entries} + end + end + + # Fetch helpers for Q-BRIDGE-MIB + defp fetch_qbridge_ports(client_opts) do + case Client.walk(client_opts, @dot1q_tp_fdb_port) do + {:ok, entries} when map_size(entries) > 0 -> + Map.new(entries, fn {oid, value} -> + key = extract_index(oid, @dot1q_tp_fdb_port) + {key, value} + end) + + _ -> + %{} + end + end + + defp fetch_qbridge_status(client_opts) do + case Client.walk(client_opts, @dot1q_tp_fdb_status) do + {:ok, entries} when map_size(entries) > 0 -> + Map.new(entries, fn {oid, value} -> + key = extract_index(oid, @dot1q_tp_fdb_status) + {key, value} + end) + + _ -> + %{} + end + end + + # Fetch helpers for BRIDGE-MIB + defp fetch_bridge_macs(client_opts) do + case Client.walk(client_opts, @dot1d_tp_fdb_address) do + {:ok, entries} when map_size(entries) > 0 -> + Map.new(entries, fn {oid, value} -> + key = extract_index(oid, @dot1d_tp_fdb_address) + {key, value} + end) + + _ -> + %{} + end + end + + defp fetch_bridge_ports(client_opts) do + case Client.walk(client_opts, @dot1d_tp_fdb_port) do + {:ok, entries} when map_size(entries) > 0 -> + Map.new(entries, fn {oid, value} -> + key = extract_index(oid, @dot1d_tp_fdb_port) + {key, value} + end) + + _ -> + %{} + end + end + + defp fetch_bridge_status(client_opts) do + case Client.walk(client_opts, @dot1d_tp_fdb_status) do + {:ok, entries} when map_size(entries) > 0 -> + Map.new(entries, fn {oid, value} -> + key = extract_index(oid, @dot1d_tp_fdb_status) + {key, value} + end) + + _ -> + %{} + end + end + + # Extract the index portion from an OID + defp extract_index(oid, base_oid) do + base_len = String.length(base_oid) + 1 + String.slice(oid, base_len..-1//1) + end + + # Parse Q-BRIDGE key format: vlan_id.mac_octet1.mac_octet2...mac_octet6 + defp parse_qbridge_key(key) do + case String.split(key, ".") do + [vlan_id | mac_parts] when length(mac_parts) == 6 -> + mac_address = format_mac_from_octets(mac_parts) + {String.to_integer(vlan_id), mac_address} + + _ -> + {nil, nil} + end + end + + # Parse MAC from BRIDGE-MIB key format: mac_octet1.mac_octet2...mac_octet6 + defp parse_mac_from_key(key) do + parts = String.split(key, ".") + + if length(parts) == 6 do + format_mac_from_octets(parts) + end + end + + # Format MAC address from octet strings + defp format_mac_from_octets(octets) when length(octets) == 6 do + octets + |> Enum.map(&String.to_integer/1) + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + + defp format_mac_from_octets(_), do: nil + + # Convert FDB status integer to string + # 1 = other, 2 = invalid, 3 = learned, 4 = self, 5 = mgmt + defp fdb_status_to_string(1), do: "other" + defp fdb_status_to_string(2), do: "invalid" + defp fdb_status_to_string(3), do: "learned" + defp fdb_status_to_string(4), do: "self" + defp fdb_status_to_string(5), do: "mgmt" + defp fdb_status_to_string(_), do: nil +end diff --git a/lib/towerops/snmp/neighbor_cleanup_worker.ex b/lib/towerops/snmp/neighbor_cleanup_worker.ex index 6b6364d9..e147ac4c 100644 --- a/lib/towerops/snmp/neighbor_cleanup_worker.ex +++ b/lib/towerops/snmp/neighbor_cleanup_worker.ex @@ -1,9 +1,10 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do @moduledoc """ - GenServer that periodically cleans up stale neighbor and ARP records. + GenServer that periodically cleans up stale neighbor, ARP, and MAC FDB records. - Neighbors and ARP entries that haven't been seen in the last 24 hours are - considered stale and will be automatically removed from the database. + Neighbors, ARP entries, and MAC forwarding database entries that haven't been + seen in the last 24 hours are considered stale and will be automatically + removed from the database. """ use GenServer @@ -39,38 +40,39 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do defp cleanup_stale_records do cutoff = DateTime.add(DateTime.utc_now(), -@stale_threshold_hours, :hour) - Logger.info("Starting neighbor and ARP cleanup for records older than #{cutoff}") + Logger.info("Starting neighbor, ARP, and MAC cleanup for records older than #{cutoff}") # device IDs with SNMP enabled equipment_list = Devices.list_snmp_enabled_devices() - {total_neighbors, total_arp} = - Enum.reduce(equipment_list, {0, 0}, fn device, {neighbor_acc, arp_acc} -> - neighbors_deleted = - case Snmp.delete_stale_neighbors(device.id, cutoff) do - {count, _} when count > 0 -> - Logger.info("Deleted #{count} stale neighbors for device #{device.name} (#{device.id})") - count + {total_neighbors, total_arp, total_mac} = + Enum.reduce(equipment_list, {0, 0, 0}, fn device, {neighbor_acc, arp_acc, mac_acc} -> + neighbors_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_neighbors/2, "neighbors") + arp_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_arp_entries/2, "ARP entries") + mac_deleted = delete_stale_records(device, cutoff, &Snmp.delete_stale_mac_addresses/2, "MAC entries") - _ -> - 0 - end - - arp_deleted = - case Snmp.delete_stale_arp_entries(device.id, cutoff) do - {count, _} when count > 0 -> - Logger.info("Deleted #{count} stale ARP entries for device #{device.name} (#{device.id})") - count - - _ -> - 0 - end - - {neighbor_acc + neighbors_deleted, arp_acc + arp_deleted} + {neighbor_acc + neighbors_deleted, arp_acc + arp_deleted, mac_acc + mac_deleted} end) - if total_neighbors > 0 or total_arp > 0 do - Logger.info("Cleanup completed: removed #{total_neighbors} neighbors, #{total_arp} ARP entries") + log_cleanup_results(total_neighbors, total_arp, total_mac) + end + + defp delete_stale_records(device, cutoff, delete_fn, record_type) do + case delete_fn.(device.id, cutoff) do + {count, _} when count > 0 -> + Logger.info("Deleted #{count} stale #{record_type} for device #{device.name} (#{device.id})") + count + + _ -> + 0 + end + end + + defp log_cleanup_results(total_neighbors, total_arp, total_mac) do + if total_neighbors > 0 or total_arp > 0 or total_mac > 0 do + Logger.info( + "Cleanup completed: removed #{total_neighbors} neighbors, #{total_arp} ARP entries, #{total_mac} MAC entries" + ) else Logger.debug("Cleanup completed: no stale records found") end diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index ed5df33c..0daa32d2 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -18,6 +18,7 @@ defmodule Towerops.Snmp.PollerWorker do alias Towerops.Snmp alias Towerops.Snmp.ArpDiscovery alias Towerops.Snmp.Client + alias Towerops.Snmp.MacDiscovery alias Towerops.Snmp.NeighborDiscovery alias Towerops.Snmp.PollerRegistry alias Towerops.Snmp.PollerTaskSupervisor @@ -171,6 +172,9 @@ defmodule Towerops.Snmp.PollerWorker do Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> poll_device_arp(device, snmp_device, client_opts) end), + Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> + poll_device_mac(device, snmp_device, client_opts) + end), Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> poll_device_processors(device, snmp_device, client_opts, now) end), @@ -296,6 +300,29 @@ defmodule Towerops.Snmp.PollerWorker do Logger.error("Error polling ARP table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") end + defp poll_device_mac(device, snmp_device, client_opts) do + {:ok, mac_entries} = MacDiscovery.discover_mac_table(client_opts) + + # Delete stale MAC entries (not seen in last 5 minutes) + cutoff = DateTime.add(DateTime.utc_now(), -5, :minute) + Snmp.delete_stale_mac_addresses(device.id, cutoff) + + # Upsert MAC entries with interface mapping + Snmp.upsert_mac_addresses(device.id, mac_entries, snmp_device.interfaces) + + Logger.debug("Polled and saved #{length(mac_entries)} MAC FDB entries for #{device.name}") + + # Broadcast MAC update event to device-specific topic + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}", + {:mac_updated, device.id} + ) + rescue + error -> + Logger.error("Error polling MAC FDB table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") + end + defp poll_device_processors(device, snmp_device, client_opts, now) do processors = snmp_device.processors || [] diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index d76f485e..25f3191b 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -2,8 +2,10 @@ defmodule ToweropsWeb.DeviceLive.Show do @moduledoc false use ToweropsWeb, :live_view + alias Towerops.Agents alias Towerops.Devices alias Towerops.Monitoring + alias Towerops.Repo alias Towerops.Snmp # SNMP interface type to category mappings @@ -113,6 +115,11 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, load_equipment_data(socket, socket.assigns.device.id)} end + @impl true + def handle_info({:mac_updated, _device_id}, socket) do + {:noreply, load_equipment_data(socket, socket.assigns.device.id)} + end + @impl true def handle_info({:state_sensors_updated, _device_id}, socket) do {:noreply, load_equipment_data(socket, socket.assigns.device.id)} @@ -144,11 +151,15 @@ defmodule ToweropsWeb.DeviceLive.Show do defp load_equipment_data(socket, device_id) do device = Devices.get_device!(device_id) + device_with_associations = Repo.preload(device, site: [organization: :default_agent_token]) + {agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations) + agent_info = load_agent_info(agent_token_id, agent_source) recent_checks = Monitoring.list_devices_checks(device_id, 50) snmp_data = load_snmp_data(device_id) events = Devices.list_devices_events(device_id, 100) neighbors = Snmp.list_neighbors_with_equipment(device_id) arp_entries = Snmp.list_arp_entries(device_id) + mac_addresses = Snmp.list_mac_addresses(device_id) vlans = load_vlans(snmp_data.device) ip_addresses = load_ip_addresses(snmp_data.device) processors = load_processors(snmp_data.device) @@ -157,6 +168,7 @@ defmodule ToweropsWeb.DeviceLive.Show do processor_chart_data = load_processor_chart_data(processors) memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"]) storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"]) + storage_volume_chart_data = load_storage_volume_chart_data(storage) traffic_chart_data = load_overall_traffic_chart_data(device_id) # Filter sensors by type for temperature, voltage, storage, count, and transceivers @@ -195,9 +207,11 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:snmp_sensors, snmp_data.sensors) |> assign(:neighbors, neighbors) |> assign(:arp_entries, arp_entries) + |> assign(:mac_addresses, mac_addresses) |> assign(:vlans, vlans) |> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4"))) |> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6"))) + |> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses)) |> assign(:processors, processors) |> assign(:storage, storage) |> assign(:events, events) @@ -205,12 +219,34 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:processor_chart_data, processor_chart_data) |> assign(:memory_chart_data, memory_chart_data) |> assign(:storage_chart_data, storage_chart_data) + |> assign(:storage_volume_chart_data, storage_volume_chart_data) |> assign(:traffic_chart_data, traffic_chart_data) |> assign(:temperature_sensors, temperature_sensors) |> assign(:voltage_sensors, voltage_sensors) |> assign(:storage_sensors, storage_sensors) |> assign(:count_sensors, count_sensors) |> assign(:grouped_transceivers, grouped_transceivers) + |> assign(:agent_info, agent_info) + end + + defp load_agent_info(nil, :none) do + %{ + type: :cloud, + name: "Cloud Polling", + source: nil, + last_seen_at: nil + } + end + + defp load_agent_info(agent_token_id, source) do + agent_token = Agents.get_agent_token!(agent_token_id) + + %{ + type: :agent, + name: agent_token.name, + source: source, + last_seen_at: agent_token.last_seen_at + } end defp calculate_metrics(checks, _equipment) do @@ -354,6 +390,19 @@ defmodule ToweropsWeb.DeviceLive.Show do format_duration(diff) <> " ago" end + defp format_relative_time(nil), do: "never" + + defp format_relative_time(datetime) do + diff = DateTime.diff(DateTime.utc_now(), datetime, :second) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)} min ago" + diff < 86_400 -> "#{div(diff, 3600)} hours ago" + true -> format_duration(diff) <> " ago" + end + end + defp format_event_type(event_type) do event_type |> String.replace("_", " ") @@ -449,6 +498,44 @@ defmodule ToweropsWeb.DeviceLive.Show do } end + defp load_storage_volume_chart_data([]), do: nil + + defp load_storage_volume_chart_data(storage_volumes) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + datasets = + storage_volumes + |> Enum.map(&storage_volume_to_chart_dataset(&1, twenty_four_hours_ago)) + |> Enum.reject(&Enum.empty?(&1.data)) + + if Enum.empty?(datasets) do + nil + else + Jason.encode!(%{datasets: datasets}) + end + end + + defp storage_volume_to_chart_dataset(storage, since) do + readings = + storage.id + |> Snmp.get_storage_readings(since: since, limit: 1000) + |> Enum.reverse() + + label = storage.description || "Storage #{storage.storage_index}" + + %{ + label: label, + data: Enum.map(readings, &storage_reading_to_data_point/1) + } + end + + defp storage_reading_to_data_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1)) + } + end + defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do case Snmp.get_device_with_associations(device_id) do nil -> nil @@ -652,4 +739,9 @@ defmodule ToweropsWeb.DeviceLive.Show do |> String.downcase() end end + + # Group IP addresses by interface ID for efficient lookup in templates + defp group_ip_addresses_by_interface(ip_addresses) do + Enum.group_by(ip_addresses, & &1.snmp_interface_id) + end end diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index a5c470b2..d2822441 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -30,6 +30,35 @@

{@device.name}

{@device.ip_address}

+ +
+ <%= if @agent_info.type == :cloud do %> + + <.icon name="hero-cloud" class="h-3.5 w-3.5" /> Cloud Polling + + <% else %> + + <.icon name="hero-server" class="h-3.5 w-3.5" /> + {@agent_info.name} + + + <%= case @agent_info.source do %> + <% :device -> %> + (assigned directly) + <% :site -> %> + (via site default) + <% :organization -> %> + (via org default) + <% _ -> %> + <% end %> + + <%= if @agent_info.last_seen_at do %> + + • Last seen: {format_relative_time(@agent_info.last_seen_at)} + + <% end %> + <% end %> +
<.button navigate={~p"/devices/#{@device.id}/edit"}> @@ -100,6 +129,22 @@ <% end %> + <%= if @mac_addresses && length(@mac_addresses) > 0 do %> + <.link + patch={~p"/devices/#{@device.id}?tab=mac"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if @active_tab == "mac" do + "border-blue-500 text-blue-600 dark:text-blue-400" + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" + end + ]} + > + MAC Table + + <% end %> + <%= if @vlans && length(@vlans) > 0 do %> <.link patch={~p"/devices/#{@device.id}?tab=vlans"} @@ -487,6 +532,20 @@
+ + <%= if @storage_volume_chart_data do %> +
+
+ +
+
+ <% end %>
<%= for storage <- @storage do %> <% usage_percent = @@ -708,6 +767,7 @@ Name Status Speed + IP Address MAC @@ -750,6 +810,24 @@ {format_speed(interface.if_speed)} + + <% interface_ips = + Map.get(@ip_addresses_by_interface, interface.id, []) %> + <%= if Enum.empty?(interface_ips) do %> + - + <% else %> +
+ <%= for ip <- interface_ips do %> +
+ {ip.ip_address} + <%= if ip.prefix_length do %> + /{ip.prefix_length} + <% end %> +
+ <% end %> +
+ <% end %> + {interface.if_phys_address || "-"} @@ -955,6 +1033,92 @@
<% end %> + <% "mac" -> %> + <%= if @mac_addresses && length(@mac_addresses) > 0 do %> +
+
+

+ MAC Address Table + + ({length(@mac_addresses)} entries) + +

+
+
+ + + + + + + + + + + + <%= for mac <- @mac_addresses do %> + + + + + + + + <% end %> + +
MAC AddressVLANInterfaceStatusLast Seen
+ {mac.mac_address} + + <%= if mac.vlan_id do %> + {mac.vlan_id} + <% else %> + - + <% end %> + + <%= if mac.interface do %> + <.link + navigate={ + ~p"/devices/#{@device.id}/graph/traffic?interface_id=#{mac.interface.id}" + } + class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" + > + {mac.interface.if_name || mac.interface.if_descr} + + <% else %> + Port {mac.port_index} + <% end %> + + + {mac.entry_status || "unknown"} + + + {time_ago(mac.last_seen_at)} +
+
+
+ <% else %> +
+
+ <.icon name="hero-table-cells" class="mx-auto h-12 w-12 text-gray-400" /> +

+ No MAC addresses discovered +

+

+ This device doesn't have any MAC forwarding entries, or MAC discovery hasn't run yet. +

+
+
+ <% end %> <% "vlans" -> %> <%= if @vlans && length(@vlans) > 0 do %>
diff --git a/priv/repo/migrations/20260123150047_create_snmp_mac_addresses.exs b/priv/repo/migrations/20260123150047_create_snmp_mac_addresses.exs new file mode 100644 index 00000000..caf09900 --- /dev/null +++ b/priv/repo/migrations/20260123150047_create_snmp_mac_addresses.exs @@ -0,0 +1,23 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpMacAddresses do + use Ecto.Migration + + def change do + create table(:snmp_mac_addresses, primary_key: false) do + add :id, :binary_id, primary_key: true + add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false + add :interface_id, references(:snmp_interfaces, type: :binary_id, on_delete: :delete_all) + add :mac_address, :string, null: false + add :vlan_id, :integer + add :port_index, :integer + add :entry_status, :string + add :last_seen_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create index(:snmp_mac_addresses, [:device_id]) + create index(:snmp_mac_addresses, [:interface_id]) + create index(:snmp_mac_addresses, [:mac_address]) + create unique_index(:snmp_mac_addresses, [:device_id, :mac_address, :vlan_id]) + end +end diff --git a/test/towerops/snmp/mac_address_test.exs b/test/towerops/snmp/mac_address_test.exs new file mode 100644 index 00000000..51602f97 --- /dev/null +++ b/test/towerops/snmp/mac_address_test.exs @@ -0,0 +1,167 @@ +defmodule Towerops.Snmp.MacAddressTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device + alias Towerops.Snmp.Interface + alias Towerops.Snmp.MacAddress + + setup do + user = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, device_schema} = + Towerops.Devices.create_device(%{ + name: "Test Switch", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id + }) + + snmp_device = + %Device{} + |> Device.changeset(%{ + device_id: device_schema.id, + sys_name: "test-switch", + sys_descr: "Test Switch" + }) + |> Repo.insert!() + + interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 1, + if_descr: "GigabitEthernet0/1", + if_name: "Gi0/1", + if_type: 6, + if_oper_status: "up" + }) + |> Repo.insert!() + + %{device: device_schema, snmp_device: snmp_device, interface: interface} + end + + describe "changeset/2" do + test "valid with required fields", %{device: device} do + attrs = %{ + device_id: device.id, + mac_address: "00:11:22:33:44:55", + last_seen_at: DateTime.utc_now() + } + + changeset = MacAddress.changeset(%MacAddress{}, attrs) + assert changeset.valid? + end + + test "valid with all fields", %{device: device, interface: interface} do + attrs = %{ + device_id: device.id, + interface_id: interface.id, + mac_address: "00:11:22:33:44:55", + vlan_id: 100, + port_index: 1, + entry_status: "learned", + last_seen_at: DateTime.utc_now() + } + + changeset = MacAddress.changeset(%MacAddress{}, attrs) + assert changeset.valid? + end + + test "invalid without device_id" do + attrs = %{ + mac_address: "00:11:22:33:44:55", + last_seen_at: DateTime.utc_now() + } + + changeset = MacAddress.changeset(%MacAddress{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).device_id + end + + test "invalid without mac_address", %{device: device} do + attrs = %{ + device_id: device.id, + last_seen_at: DateTime.utc_now() + } + + changeset = MacAddress.changeset(%MacAddress{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).mac_address + end + + test "invalid without last_seen_at", %{device: device} do + attrs = %{ + device_id: device.id, + mac_address: "00:11:22:33:44:55" + } + + changeset = MacAddress.changeset(%MacAddress{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).last_seen_at + end + + test "enforces unique constraint on device_id, mac_address, vlan_id", %{device: device} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + {:ok, _} = + %MacAddress{} + |> MacAddress.changeset(%{ + device_id: device.id, + mac_address: "00:11:22:33:44:55", + vlan_id: 100, + last_seen_at: now + }) + |> Repo.insert() + + {:error, changeset} = + %MacAddress{} + |> MacAddress.changeset(%{ + device_id: device.id, + mac_address: "00:11:22:33:44:55", + vlan_id: 100, + last_seen_at: now + }) + |> Repo.insert() + + assert "has already been taken" in errors_on(changeset)[:device_id] + end + + test "allows same mac_address on different VLANs", %{device: device} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + {:ok, _} = + %MacAddress{} + |> MacAddress.changeset(%{ + device_id: device.id, + mac_address: "00:11:22:33:44:55", + vlan_id: 100, + last_seen_at: now + }) + |> Repo.insert() + + {:ok, _} = + %MacAddress{} + |> MacAddress.changeset(%{ + device_id: device.id, + mac_address: "00:11:22:33:44:55", + vlan_id: 200, + last_seen_at: now + }) + |> Repo.insert() + end + end +end diff --git a/test/towerops/snmp/mac_discovery_test.exs b/test/towerops/snmp/mac_discovery_test.exs new file mode 100644 index 00000000..1b01a6c6 --- /dev/null +++ b/test/towerops/snmp/mac_discovery_test.exs @@ -0,0 +1,184 @@ +defmodule Towerops.Snmp.MacDiscoveryTest do + use ExUnit.Case, async: true + + import Mox + + alias Towerops.Snmp.MacDiscovery + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + describe "discover_mac_table/1" do + test "returns empty list when no MAC entries found" do + # Q-BRIDGE port returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ -> + {:ok, []} + end) + + # Q-BRIDGE status walk (still called even with empty port results) + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ -> + {:ok, []} + end) + + # BRIDGE-MIB address returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.1", _ -> + {:ok, []} + end) + + # BRIDGE-MIB port returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.2", _ -> + {:ok, []} + end) + + # BRIDGE-MIB status returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.3", _ -> + {:ok, []} + end) + + assert {:ok, []} = MacDiscovery.discover_mac_table(client_opts()) + end + + test "discovers Q-BRIDGE MAC entries with VLAN awareness" do + # Q-BRIDGE-MIB dot1qTpFdbPort walk + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ -> + {:ok, + [ + # VLAN 100, MAC 00:11:22:33:44:55, Port 1 + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.85", value: 1}, + # VLAN 100, MAC 00:11:22:33:44:66, Port 2 + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.102", value: 2}, + # VLAN 200, MAC 00:11:22:33:44:55, Port 3 + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.200.0.17.34.51.68.85", value: 3} + ]} + end) + + # Q-BRIDGE-MIB dot1qTpFdbStatus walk + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.100.0.17.34.51.68.85", value: 3}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.100.0.17.34.51.68.102", value: 3}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.200.0.17.34.51.68.85", value: 3} + ]} + end) + + {:ok, entries} = MacDiscovery.discover_mac_table(client_opts()) + + assert length(entries) == 3 + + entry1 = Enum.find(entries, &(&1.vlan_id == 100 && &1.port_index == 1)) + assert entry1.mac_address == "00:11:22:33:44:55" + assert entry1.entry_status == "learned" + + entry2 = Enum.find(entries, &(&1.vlan_id == 100 && &1.port_index == 2)) + assert entry2.mac_address == "00:11:22:33:44:66" + + entry3 = Enum.find(entries, &(&1.vlan_id == 200 && &1.port_index == 3)) + assert entry3.mac_address == "00:11:22:33:44:55" + end + + test "falls back to BRIDGE-MIB when Q-BRIDGE returns no entries" do + # Q-BRIDGE port returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ -> + {:ok, []} + end) + + # Q-BRIDGE status walk (still called even with empty port results) + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ -> + {:ok, []} + end) + + # BRIDGE-MIB dot1dTpFdbAddress walk + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.1", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.17.4.3.1.1.0.17.34.51.68.85", value: <<0, 17, 34, 51, 68, 85>>} + ]} + end) + + # BRIDGE-MIB dot1dTpFdbPort walk + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.2", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.17.4.3.1.2.0.17.34.51.68.85", value: 1} + ]} + end) + + # BRIDGE-MIB dot1dTpFdbStatus walk + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.4.3.1.3", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.17.4.3.1.3.0.17.34.51.68.85", value: 3} + ]} + end) + + {:ok, entries} = MacDiscovery.discover_mac_table(client_opts()) + + assert length(entries) == 1 + entry = hd(entries) + assert entry.mac_address == "00:11:22:33:44:55" + assert entry.port_index == 1 + assert entry.vlan_id == nil + assert entry.entry_status == "learned" + end + + test "handles all FDB status values" do + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.1", value: 1}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.2", value: 2}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.3", value: 3}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.4", value: 4}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.1.0.17.34.51.68.5", value: 5} + ]} + end) + + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.1", value: 1}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.2", value: 2}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.3", value: 3}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.4", value: 4}, + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.3.1.0.17.34.51.68.5", value: 5} + ]} + end) + + {:ok, entries} = MacDiscovery.discover_mac_table(client_opts()) + + assert length(entries) == 5 + + statuses = entries |> Enum.map(& &1.entry_status) |> Enum.sort() + assert statuses == ["invalid", "learned", "mgmt", "other", "self"] + end + + test "includes last_seen_at timestamp" do + now = DateTime.utc_now() + + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.2", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.17.7.1.2.2.1.2.100.0.17.34.51.68.85", value: 1} + ]} + end) + + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.17.7.1.2.2.1.3", _ -> + {:ok, []} + end) + + {:ok, entries} = MacDiscovery.discover_mac_table(client_opts()) + + assert length(entries) == 1 + [entry] = entries + + assert %DateTime{} = entry.last_seen_at + # Timestamp should be within 1 second of now + assert DateTime.diff(entry.last_seen_at, now, :second) <= 1 + end + end + + defp client_opts do + [ip: "192.168.1.1", community: "public", version: :v2c, port: 161] + end +end