feat: add device role inference

Infers device roles from LLDP/CDP capabilities, interface count,
vendor/manufacturer, and system description. Only overwrites
inferred roles, never manual overrides. Wired into process_device
pipeline.
This commit is contained in:
Graham McIntire 2026-02-12 13:06:41 -06:00
parent 8e355fce79
commit be153d364e
No known key found for this signature in database
2 changed files with 205 additions and 0 deletions

View file

@ -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

View file

@ -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,