feat: add LLDP, CDP, MAC, and ARP evidence collectors

LLDP/CDP evidence (0.95 confidence) from snmp_neighbors.
MAC evidence (0.7) cross-references MAC tables with device
interface MACs. ARP evidence (0.6) matches by IP and MAC.
Self-links excluded. Unmatched entries tracked as discovered.
This commit is contained in:
Graham McIntire 2026-02-12 12:55:39 -06:00
parent c6b271d204
commit 00c366ac9a
No known key found for this signature in database
3 changed files with 366 additions and 0 deletions

View file

@ -8,7 +8,10 @@ defmodule Towerops.Topology do
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.Interface
alias Towerops.Snmp.MacAddress
alias Towerops.Snmp.Neighbor
@doc """
Builds lookup maps for matching evidence to managed devices in an organization.
@ -80,4 +83,140 @@ defmodule Towerops.Topology do
end
def resolve_device(_lookup, _), do: nil
@doc """
Collects topology evidence from LLDP neighbor entries for a device.
Returns a list of evidence maps with confidence 0.95.
"""
def collect_lldp_evidence(device_id) do
collect_neighbor_evidence(device_id, "lldp", "lldp_neighbor", 0.95)
end
@doc """
Collects topology evidence from CDP neighbor entries for a device.
Returns a list of evidence maps with confidence 0.95.
"""
def collect_cdp_evidence(device_id) do
collect_neighbor_evidence(device_id, "cdp", "cdp_neighbor", 0.95)
end
defp collect_neighbor_evidence(device_id, protocol, evidence_type, confidence) do
from(n in Neighbor,
join: i in Interface,
on: n.interface_id == i.id,
where: n.device_id == ^device_id and n.protocol == ^protocol,
select: %{
neighbor_id: n.id,
source_interface_id: i.id,
remote_chassis_id: n.remote_chassis_id,
remote_system_name: n.remote_system_name,
remote_address: n.remote_address,
remote_port_id: n.remote_port_id,
remote_capabilities: n.remote_capabilities,
last_discovered_at: n.last_discovered_at
}
)
|> Repo.all()
|> Enum.map(fn row ->
%{
evidence_type: evidence_type,
confidence: confidence,
source_interface_id: row.source_interface_id,
remote_mac: row.remote_chassis_id,
remote_ip: row.remote_address,
remote_name: row.remote_system_name,
remote_port: row.remote_port_id,
remote_capabilities: row.remote_capabilities,
evidence_data: %{
"neighbor_id" => row.neighbor_id,
"remote_chassis_id" => row.remote_chassis_id,
"remote_port_id" => row.remote_port_id
}
}
end)
end
@doc """
Collects topology evidence from MAC address table entries for a device.
Uses the device lookup to match MACs to known devices. Excludes entries
that match the device itself (self-links). Returns evidence maps with
confidence 0.7.
"""
def collect_mac_evidence(device_id, lookup) do
from(m in MacAddress,
where: m.device_id == ^device_id,
select: %{
interface_id: m.interface_id,
mac_address: m.mac_address,
vlan_id: m.vlan_id,
last_seen_at: m.last_seen_at
}
)
|> Repo.all()
|> Enum.map(fn row ->
matched_id = find_device_by_mac(lookup, row.mac_address)
%{
evidence_type: "mac_on_interface",
confidence: 0.7,
source_interface_id: row.interface_id,
remote_mac: row.mac_address,
remote_ip: nil,
remote_name: nil,
remote_port: nil,
remote_capabilities: nil,
matched_device_id: matched_id,
evidence_data: %{
"mac_address" => row.mac_address,
"vlan_id" => row.vlan_id
}
}
end)
|> Enum.reject(fn ev -> ev.matched_device_id == device_id end)
end
@doc """
Collects topology evidence from ARP table entries for a device.
Uses the device lookup to match entries by MAC first, then IP. Excludes
entries that match the device itself (self-links). Returns evidence maps
with confidence 0.6.
"""
def collect_arp_evidence(device_id, lookup) do
from(a in ArpEntry,
where: a.device_id == ^device_id,
select: %{
interface_id: a.interface_id,
ip_address: a.ip_address,
mac_address: a.mac_address,
last_seen_at: a.last_seen_at
}
)
|> Repo.all()
|> Enum.map(fn row ->
matched_id =
find_device_by_mac(lookup, row.mac_address) ||
find_device_by_ip(lookup, row.ip_address)
%{
evidence_type: "arp_entry",
confidence: 0.6,
source_interface_id: row.interface_id,
remote_mac: row.mac_address,
remote_ip: row.ip_address,
remote_name: nil,
remote_port: nil,
remote_capabilities: nil,
matched_device_id: matched_id,
evidence_data: %{
"ip_address" => row.ip_address,
"mac_address" => row.mac_address
}
}
end)
|> Enum.reject(fn ev -> ev.matched_device_id == device_id end)
end
end

View file

@ -0,0 +1,6 @@
defmodule Towerops.Repo.Migrations.ExtendAlertsForChecks do
use Ecto.Migration
def change do
end
end

View file

@ -134,4 +134,225 @@ defmodule Towerops.TopologyTest do
assert result == nil
end
end
describe "collect_lldp_evidence/1" do
test "returns evidence from LLDP neighbors", %{router: router, router_iface: iface} do
{:ok, _} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: iface.id,
protocol: "lldp",
remote_chassis_id: "dd:ee:ff:00:11:22",
remote_system_name: "remote-switch",
remote_address: "10.0.0.5",
remote_port_id: "ge-0/0/1",
remote_capabilities: ["switch", "bridge"],
last_discovered_at: DateTime.utc_now()
})
evidence = Topology.collect_lldp_evidence(router.id)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "lldp_neighbor"
assert ev.confidence == 0.95
assert ev.remote_mac == "dd:ee:ff:00:11:22"
assert ev.remote_ip == "10.0.0.5"
assert ev.remote_name == "remote-switch"
assert ev.source_interface_id == iface.id
end
test "returns empty list when no LLDP neighbors", %{router: router} do
assert Topology.collect_lldp_evidence(router.id) == []
end
end
describe "collect_cdp_evidence/1" do
test "returns evidence from CDP neighbors", %{router: router, router_iface: iface} do
{:ok, _} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: iface.id,
protocol: "cdp",
remote_chassis_id: "11:22:33:44:55:66",
remote_system_name: "cisco-switch",
remote_address: "10.0.0.6",
remote_port_id: "Gi0/1",
remote_capabilities: ["router"],
last_discovered_at: DateTime.utc_now()
})
evidence = Topology.collect_cdp_evidence(router.id)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "cdp_neighbor"
assert ev.confidence == 0.95
end
end
describe "collect_mac_evidence/2" do
setup %{switch: switch} do
switch_snmp =
%Device{}
|> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|> Repo.insert!()
switch_iface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: switch_snmp.id,
if_index: 1,
if_name: "ge-0/0/1",
if_phys_address: "bb:cc:dd:00:11:22"
})
|> Repo.insert!()
%{switch_snmp: switch_snmp, switch_iface: switch_iface}
end
test "returns evidence for MACs matching known devices", %{
organization: org,
switch: switch,
router: router,
switch_iface: switch_iface
} do
# Router's MAC (aa:bb:cc:00:11:22) seen on a switch port
{:ok, _} =
Towerops.Snmp.upsert_mac_address(%{
device_id: switch.id,
interface_id: switch_iface.id,
mac_address: "aa:bb:cc:00:11:22",
vlan_id: 1,
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_mac_evidence(switch.id, lookup)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "mac_on_interface"
assert ev.confidence == 0.7
assert ev.remote_mac == "aa:bb:cc:00:11:22"
assert ev.matched_device_id == router.id
assert ev.source_interface_id == switch_iface.id
end
test "excludes device's own MACs (self-links)", %{
organization: org,
switch: switch,
switch_iface: switch_iface
} do
# Switch's own MAC seen on its own port (self-link)
{:ok, _} =
Towerops.Snmp.upsert_mac_address(%{
device_id: switch.id,
interface_id: switch_iface.id,
mac_address: "bb:cc:dd:00:11:22",
vlan_id: 1,
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_mac_evidence(switch.id, lookup)
assert evidence == []
end
test "returns evidence with nil matched_device_id for unknown MACs", %{
organization: org,
switch: switch,
switch_iface: switch_iface
} do
{:ok, _} =
Towerops.Snmp.upsert_mac_address(%{
device_id: switch.id,
interface_id: switch_iface.id,
mac_address: "ff:ff:ff:00:00:01",
vlan_id: 1,
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_mac_evidence(switch.id, lookup)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "mac_on_interface"
assert ev.matched_device_id == nil
assert ev.remote_mac == "ff:ff:ff:00:00:01"
end
end
describe "collect_arp_evidence/2" do
test "returns evidence for ARP entries matching known devices", %{
organization: org,
router: router,
switch: switch,
router_iface: router_iface
} do
{:ok, _} =
Towerops.Snmp.upsert_arp_entry(%{
device_id: router.id,
interface_id: router_iface.id,
ip_address: "10.0.0.2",
mac_address: "bb:cc:dd:00:11:22",
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_arp_evidence(router.id, lookup)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "arp_entry"
assert ev.confidence == 0.6
assert ev.remote_ip == "10.0.0.2"
assert ev.remote_mac == "bb:cc:dd:00:11:22"
assert ev.matched_device_id == switch.id
end
test "excludes device's own ARP entries (self-links)", %{
organization: org,
router: router,
router_iface: router_iface
} do
# ARP entry pointing back to the router itself
{:ok, _} =
Towerops.Snmp.upsert_arp_entry(%{
device_id: router.id,
interface_id: router_iface.id,
ip_address: "10.0.0.1",
mac_address: "aa:bb:cc:00:11:22",
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_arp_evidence(router.id, lookup)
assert evidence == []
end
test "returns evidence with nil matched_device_id for unknown entries", %{
organization: org,
router: router,
router_iface: router_iface
} do
{:ok, _} =
Towerops.Snmp.upsert_arp_entry(%{
device_id: router.id,
interface_id: router_iface.id,
ip_address: "10.0.0.99",
mac_address: "ff:ff:ff:00:00:99",
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_arp_evidence(router.id, lookup)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "arp_entry"
assert ev.matched_device_id == nil
end
end
end