feat: add Topology context with device matching

Build lookup maps to resolve evidence (MAC, IP, hostname) to
managed device IDs. Foundation for evidence collectors.
This commit is contained in:
Graham McIntire 2026-02-12 12:52:04 -06:00
parent 29abb688d2
commit c74243d321
No known key found for this signature in database
2 changed files with 220 additions and 0 deletions

83
lib/towerops/topology.ex Normal file
View file

@ -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

View file

@ -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