diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index c98398e7..80beac99 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -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 diff --git a/priv/repo/migrations/20260212185506_extend_alerts_for_checks.exs b/priv/repo/migrations/20260212185506_extend_alerts_for_checks.exs new file mode 100644 index 00000000..b1035675 --- /dev/null +++ b/priv/repo/migrations/20260212185506_extend_alerts_for_checks.exs @@ -0,0 +1,6 @@ +defmodule Towerops.Repo.Migrations.ExtendAlertsForChecks do + use Ecto.Migration + + def change do + end +end diff --git a/test/towerops/topology_test.exs b/test/towerops/topology_test.exs index 923260ec..c89bce3f 100644 --- a/test/towerops/topology_test.exs +++ b/test/towerops/topology_test.exs @@ -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