diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex new file mode 100644 index 00000000..c98398e7 --- /dev/null +++ b/lib/towerops/topology.ex @@ -0,0 +1,83 @@ +defmodule Towerops.Topology do + @moduledoc """ + Topology inference engine. Builds and maintains a persistent model of + network device relationships by analyzing SNMP polling data. + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Repo + alias Towerops.Snmp.Interface + + @doc """ + Builds lookup maps for matching evidence to managed devices in an organization. + + Returns a map with `:by_ip`, `:by_name`, and `:by_mac` keys, each mapping + an identifier string to a device ID. + """ + def build_device_lookup(organization_id) do + devices = + Repo.all( + from(d in Device, + where: d.organization_id == ^organization_id, + left_join: sd in assoc(d, :snmp_device), + left_join: i in Interface, + on: sd.id == i.snmp_device_id, + preload: [snmp_device: {sd, interfaces: i}] + ) + ) + + by_ip = Map.new(devices, fn d -> {d.ip_address, d.id} end) + + by_name = + devices + |> Enum.filter(&(&1.name != nil)) + |> Map.new(&{String.downcase(&1.name), &1.id}) + + by_mac = + devices + |> Enum.flat_map(fn device -> + case device.snmp_device do + nil -> + [] + + sd -> + sd.interfaces + |> Enum.filter(&(&1.if_phys_address != nil)) + |> Enum.map(&{&1.if_phys_address, device.id}) + end + end) + |> Map.new() + + %{by_ip: by_ip, by_name: by_name, by_mac: by_mac} + end + + @doc """ + Finds a device ID by IP address from the lookup. + """ + def find_device_by_ip(lookup, ip), do: Map.get(lookup.by_ip, ip) + + @doc """ + Finds a device ID by name (case-insensitive) from the lookup. + """ + def find_device_by_name(lookup, name) when is_binary(name), do: Map.get(lookup.by_name, String.downcase(name)) + + def find_device_by_name(_lookup, _name), do: nil + + @doc """ + Finds a device ID by MAC address from the lookup. + """ + def find_device_by_mac(lookup, mac), do: Map.get(lookup.by_mac, mac) + + @doc """ + Resolves a device ID from evidence fields, trying MAC first, then IP, then name. + """ + def resolve_device(lookup, %{mac: mac, ip: ip, name: name}) do + find_device_by_mac(lookup, mac) || + find_device_by_ip(lookup, ip) || + find_device_by_name(lookup, name) + end + + def resolve_device(_lookup, _), do: nil +end diff --git a/test/towerops/topology_test.exs b/test/towerops/topology_test.exs new file mode 100644 index 00000000..923260ec --- /dev/null +++ b/test/towerops/topology_test.exs @@ -0,0 +1,137 @@ +defmodule Towerops.TopologyTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device + alias Towerops.Snmp.Interface + alias Towerops.Topology + + setup do + user = user_fixture() + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + + {:ok, router} = + Towerops.Devices.create_device( + %{ + name: "Core-Router", + ip_address: "10.0.0.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }, + bypass_limits: true + ) + + {:ok, switch} = + Towerops.Devices.create_device( + %{ + name: "Main-Switch", + ip_address: "10.0.0.2", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id, + organization_id: organization.id + }, + bypass_limits: true + ) + + # Create SNMP device and interface with a known MAC for the router + router_snmp = + %Device{} + |> Device.changeset(%{device_id: router.id, sys_name: "core-router"}) + |> Repo.insert!() + + router_iface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: router_snmp.id, + if_index: 1, + if_name: "eth0", + if_phys_address: "aa:bb:cc:00:11:22" + }) + |> Repo.insert!() + + %{ + organization: organization, + site: site, + router: router, + switch: switch, + router_snmp: router_snmp, + router_iface: router_iface + } + end + + describe "build_device_lookup/1" do + test "matches device by IP address", %{organization: org, switch: switch} do + lookup = Topology.build_device_lookup(org.id) + assert Topology.find_device_by_ip(lookup, "10.0.0.2") == switch.id + end + + test "matches device by name (case-insensitive)", %{organization: org, router: router} do + lookup = Topology.build_device_lookup(org.id) + assert Topology.find_device_by_name(lookup, "core-router") == router.id + assert Topology.find_device_by_name(lookup, "CORE-ROUTER") == router.id + end + + test "matches device by MAC address", %{organization: org, router: router} do + lookup = Topology.build_device_lookup(org.id) + assert Topology.find_device_by_mac(lookup, "aa:bb:cc:00:11:22") == router.id + end + + test "returns nil for unknown IP", %{organization: org} do + lookup = Topology.build_device_lookup(org.id) + assert Topology.find_device_by_ip(lookup, "192.168.99.99") == nil + end + + test "returns nil for unknown MAC", %{organization: org} do + lookup = Topology.build_device_lookup(org.id) + assert Topology.find_device_by_mac(lookup, "ff:ff:ff:ff:ff:ff") == nil + end + + test "returns nil for unknown name", %{organization: org} do + lookup = Topology.build_device_lookup(org.id) + assert Topology.find_device_by_name(lookup, "nonexistent") == nil + end + end + + describe "resolve_device/2" do + test "resolves by MAC first", %{organization: org, router: router} do + lookup = Topology.build_device_lookup(org.id) + + result = + Topology.resolve_device(lookup, %{ + mac: "aa:bb:cc:00:11:22", + ip: "10.0.0.2", + name: "Main-Switch" + }) + + # MAC matches router, even though IP and name match switch + assert result == router.id + end + + test "falls back to IP when MAC is nil", %{organization: org, switch: switch} do + lookup = Topology.build_device_lookup(org.id) + result = Topology.resolve_device(lookup, %{mac: nil, ip: "10.0.0.2", name: nil}) + assert result == switch.id + end + + test "falls back to name when MAC and IP are nil", %{organization: org, router: router} do + lookup = Topology.build_device_lookup(org.id) + result = Topology.resolve_device(lookup, %{mac: nil, ip: nil, name: "Core-Router"}) + assert result == router.id + end + + test "returns nil when nothing matches", %{organization: org} do + lookup = Topology.build_device_lookup(org.id) + result = Topology.resolve_device(lookup, %{mac: nil, ip: nil, name: nil}) + assert result == nil + end + end +end