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:
parent
c6b271d204
commit
00c366ac9a
3 changed files with 366 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
defmodule Towerops.Repo.Migrations.ExtendAlertsForChecks do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue