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:
parent
8e355fce79
commit
be153d364e
2 changed files with 205 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue