diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index 893cab36..0d34c493 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -9,6 +9,7 @@ defmodule Towerops.Topology do alias Towerops.Devices.Device alias Towerops.Repo alias Towerops.Snmp.ArpEntry + alias Towerops.Snmp.Device, as: SnmpDevice alias Towerops.Snmp.Interface alias Towerops.Snmp.MacAddress alias Towerops.Snmp.Neighbor @@ -17,6 +18,10 @@ defmodule Towerops.Topology do require Logger + @ups_vendors ~w(apc cyberpower eaton) + @firewall_vendors ~w(fortinet fortigate paloalto pfsense sonicwall sophos) + @server_platforms ~w(linux windows vmware esxi proxmox) + @doc """ Builds lookup maps for matching evidence to managed devices in an organization. @@ -277,6 +282,32 @@ defmodule Towerops.Topology do end end + @doc """ + Infer the role of a device from its SNMP data and neighbor reports. + Returns a role string. Does not write to the database. + """ + def infer_device_role(device) do + device = Repo.preload(device, snmp_device: :interfaces) + snmp = device.snmp_device + + {manufacturer, sys_descr, iface_count} = extract_snmp_hints(snmp) + capabilities = get_reported_capabilities(snmp) + + infer_role_from_evidence(capabilities, iface_count, manufacturer, sys_descr) + end + + @doc """ + Apply inferred role to a device. Only updates when device_role_source is not "manual". + """ + def maybe_update_device_role(device) do + if device.device_role_source == "manual" do + {:ok, device} + else + role = infer_device_role(device) + apply_inferred_role(device, role) + end + end + @doc """ Main topology inference pipeline for a device. Called after each poll cycle. Collects evidence from all sources, groups by remote identifier, upserts links, @@ -305,6 +336,9 @@ defmodule Towerops.Topology do has_changes = Enum.any?(results, &match?({:ok, _}, &1)) + # Re-evaluate device role after collecting new evidence + maybe_update_device_role(device) + if has_changes do Phoenix.PubSub.broadcast( Towerops.PubSub, @@ -383,4 +417,74 @@ defmodule Towerops.Topology do defp evidence_type_to_link_type("arp_entry"), do: "arp_inference" defp evidence_type_to_link_type("wireless_registration"), do: "wireless_association" defp evidence_type_to_link_type(_), do: "mac_match" + + # --- Device role inference helpers --- + + defp extract_snmp_hints(nil), do: {"", "", 0} + + defp extract_snmp_hints(%SnmpDevice{} = snmp) do + manufacturer = String.downcase(snmp.manufacturer || "") + sys_descr = String.downcase(snmp.sys_descr || "") + iface_count = length(snmp.interfaces) + {manufacturer, sys_descr, iface_count} + end + + defp get_reported_capabilities(nil), do: [] + + defp get_reported_capabilities(%SnmpDevice{} = snmp) do + snmp.interfaces + |> Enum.map(& &1.if_phys_address) + |> Enum.reject(&is_nil/1) + |> fetch_capabilities_for_macs() + end + + defp fetch_capabilities_for_macs([]), do: [] + + defp fetch_capabilities_for_macs(mac_addresses) do + from(n in Neighbor, + where: n.remote_chassis_id in ^mac_addresses, + select: n.remote_capabilities + ) + |> Repo.all() + |> List.flatten() + |> Enum.uniq() + end + + defp infer_role_from_evidence(capabilities, iface_count, manufacturer, sys_descr) do + infer_from_capabilities(capabilities, iface_count) || + infer_from_vendor(manufacturer, sys_descr) || + "unknown" + end + + defp infer_from_capabilities(capabilities, iface_count) do + cond do + "wlan-ap" in capabilities -> "access_point" + "router" in capabilities and iface_count >= 10 -> "core_router" + "router" in capabilities -> "distribution_switch" + "bridge" in capabilities -> "access_switch" + true -> nil + end + end + + defp infer_from_vendor(manufacturer, sys_descr) do + cond do + vendor_match?(manufacturer, @ups_vendors) -> "ups" + vendor_match?(manufacturer, @firewall_vendors) -> "firewall" + vendor_match?(sys_descr, @firewall_vendors) -> "firewall" + platform_match?(sys_descr, @server_platforms) -> "server" + true -> nil + end + end + + defp apply_inferred_role(device, role) when role == device.device_role, do: {:ok, device} + + defp apply_inferred_role(device, role) do + device + |> Ecto.Changeset.change(%{device_role: role, device_role_source: "inferred"}) + |> Repo.update() + end + + defp vendor_match?(value, vendors), do: Enum.any?(vendors, &String.contains?(value, &1)) + + defp platform_match?(description, platforms), do: Enum.any?(platforms, &String.contains?(description, &1)) end diff --git a/test/towerops/topology_test.exs b/test/towerops/topology_test.exs index 08bf6084..7c8e8839 100644 --- a/test/towerops/topology_test.exs +++ b/test/towerops/topology_test.exs @@ -539,6 +539,107 @@ defmodule Towerops.TopologyTest do end end + describe "infer_device_role/1" do + test "infers access_point from LLDP wlan-ap capability reported by others", + %{router: router, site: site, organization: organization} do + # Create a second device whose LLDP neighbor entry reports our router + # has wlan-ap capability (via our router's MAC address) + {:ok, ap_device} = + Towerops.Devices.create_device( + %{ + name: "Test-AP", + ip_address: "10.0.0.50", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }, + bypass_limits: true + ) + + ap_snmp = + %Device{} + |> Device.changeset(%{device_id: ap_device.id, sys_name: "test-ap"}) + |> Repo.insert!() + + ap_iface = + %Interface{} + |> Interface.changeset(%{snmp_device_id: ap_snmp.id, if_index: 1, if_name: "eth0"}) + |> Repo.insert!() + + # This neighbor on ap_device reports that our ROUTER has wlan-ap capability + {:ok, _} = + Towerops.Snmp.upsert_neighbor(%{ + device_id: ap_device.id, + interface_id: ap_iface.id, + protocol: "lldp", + remote_chassis_id: "aa:bb:cc:00:11:22", + remote_system_name: "Core-Router", + remote_capabilities: ["wlan-ap"], + last_discovered_at: DateTime.utc_now() + }) + + role = Topology.infer_device_role(router) + assert role == "access_point" + end + + test "infers ups from manufacturer", %{router: router, router_snmp: snmp} do + snmp + |> Device.changeset(%{manufacturer: "APC"}) + |> Repo.update!() + + role = Topology.infer_device_role(router) + assert role == "ups" + end + + test "infers firewall from manufacturer", %{router: router, router_snmp: snmp} do + snmp + |> Device.changeset(%{manufacturer: "Fortinet"}) + |> Repo.update!() + + role = Topology.infer_device_role(router) + assert role == "firewall" + end + + test "infers server from sys_descr", %{router: router, router_snmp: snmp} do + snmp + |> Device.changeset(%{sys_descr: "Linux server 5.15.0"}) + |> Repo.update!() + + role = Topology.infer_device_role(router) + assert role == "server" + end + + test "returns unknown when no evidence", %{switch: switch} do + role = Topology.infer_device_role(switch) + assert role == "unknown" + end + end + + describe "maybe_update_device_role/1" do + test "updates role when source is inferred", %{router: router, router_snmp: snmp} do + snmp + |> Device.changeset(%{manufacturer: "APC"}) + |> Repo.update!() + + {:ok, updated} = Topology.maybe_update_device_role(router) + assert updated.device_role == "ups" + assert updated.device_role_source == "inferred" + end + + test "does not update role when source is manual", %{router: router} do + router + |> Ecto.Changeset.change(%{device_role: "core_router", device_role_source: "manual"}) + |> Repo.update!() + + router = Repo.reload!(router) + {:ok, unchanged} = Topology.maybe_update_device_role(router) + assert unchanged.device_role == "core_router" + end + end + describe "collect_arp_evidence/2" do test "returns evidence for ARP entries matching known devices", %{ organization: org,