add device matcher for preseem AP to towerops device linking

This commit is contained in:
Graham McIntire 2026-02-12 17:52:54 -06:00
parent bf03a5a9a1
commit 78040c0df7
2 changed files with 406 additions and 0 deletions

View file

@ -0,0 +1,154 @@
defmodule Towerops.Preseem.DeviceMatcher do
@moduledoc """
Attempts to match Preseem access points to Towerops devices.
Matching strategy (ordered by confidence):
1. MAC address (through interface -> snmp_device -> device)
2. IP address (direct device match)
3. Hostname (normalized name comparison)
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Preseem.AccessPoint
alias Towerops.Repo
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
@doc """
Finds all unmatched access points for an organization and tries to match each one.
Returns `{:ok, matched_count}` where matched_count is the number of APs
that were successfully matched to a device.
"""
def match_unmatched(organization_id) do
unmatched_aps =
AccessPoint
|> where(organization_id: ^organization_id, match_confidence: "unmatched")
|> Repo.all()
matched_count =
Enum.count(unmatched_aps, fn ap ->
case match_access_point(ap) do
{:ok, matched_ap} -> matched_ap.device_id != nil
_ -> false
end
end)
{:ok, matched_count}
end
@doc """
Attempts to match a single access point against devices in the same organization.
Skips APs with `match_confidence == "manual"` (user manually linked).
Returns `{:ok, access_point}` with updated match_confidence and device_id.
"""
def match_access_point(%AccessPoint{match_confidence: "manual"} = ap) do
{:ok, ap}
end
def match_access_point(%AccessPoint{} = ap) do
org_id = ap.organization_id
result =
try_mac_match(ap, org_id) ||
try_ip_match(ap, org_id) ||
try_hostname_match(ap, org_id)
case result do
{:single, device_id, confidence} ->
update_match(ap, device_id, confidence)
:ambiguous ->
update_match(ap, nil, "ambiguous")
nil ->
{:ok, ap}
end
end
defp try_mac_match(%AccessPoint{mac_address: nil}, _org_id), do: nil
defp try_mac_match(%AccessPoint{mac_address: ""}, _org_id), do: nil
defp try_mac_match(%AccessPoint{mac_address: mac}, org_id) do
normalized_mac = normalize_mac(mac)
# Query interface MAC addresses through interface -> snmp_device -> device chain,
# then filter in Elixir since DB stores MACs in various formats
device_ids =
Interface
|> join(:inner, [i], sd in SnmpDevice, on: i.snmp_device_id == sd.id)
|> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
|> where([_i, _sd, d], d.organization_id == ^org_id)
|> where([i, _sd, _d], not is_nil(i.if_phys_address))
|> select([i, _sd, d], {d.id, i.if_phys_address})
|> Repo.all()
|> Enum.filter(fn {_id, phys_addr} -> normalize_mac(phys_addr) == normalized_mac end)
|> Enum.map(fn {id, _} -> id end)
|> Enum.uniq()
evaluate_matches(device_ids, "auto_mac")
end
defp try_ip_match(%AccessPoint{ip_address: nil}, _org_id), do: nil
defp try_ip_match(%AccessPoint{ip_address: ""}, _org_id), do: nil
defp try_ip_match(%AccessPoint{ip_address: ip}, org_id) do
device_ids =
Device
|> where(organization_id: ^org_id, ip_address: ^ip)
|> select([d], d.id)
|> Repo.all()
evaluate_matches(device_ids, "auto_ip")
end
defp try_hostname_match(%AccessPoint{name: nil}, _org_id), do: nil
defp try_hostname_match(%AccessPoint{name: ""}, _org_id), do: nil
defp try_hostname_match(%AccessPoint{name: ap_name}, org_id) do
normalized_ap_name = normalize_hostname(ap_name)
device_ids =
Device
|> where(organization_id: ^org_id)
|> where([d], not is_nil(d.name))
|> select([d], {d.id, d.name})
|> Repo.all()
|> Enum.filter(fn {_id, name} -> normalize_hostname(name) == normalized_ap_name end)
|> Enum.map(fn {id, _} -> id end)
evaluate_matches(device_ids, "auto_hostname")
end
defp evaluate_matches([], _confidence), do: nil
defp evaluate_matches([device_id], confidence), do: {:single, device_id, confidence}
defp evaluate_matches([_ | _], _confidence), do: :ambiguous
defp update_match(ap, device_id, confidence) do
ap
|> AccessPoint.changeset(%{device_id: device_id, match_confidence: confidence})
|> Repo.update()
end
@doc false
def normalize_mac(mac) when is_binary(mac) do
mac
|> String.downcase()
|> String.replace(~r/[:\-\.]/, "")
end
def normalize_mac(_), do: ""
@doc false
def normalize_hostname(name) when is_binary(name) do
name
|> String.downcase()
|> String.split(".")
|> List.first()
end
def normalize_hostname(_), do: ""
end

View file

@ -0,0 +1,252 @@
defmodule Towerops.Preseem.DeviceMatcherTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.DeviceMatcher
alias Towerops.Preseem.Sync
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
defp create_device_with_mac(org, ip_address, mac_address, opts \\ []) do
name = Keyword.get(opts, :name, "Device #{System.unique_integer([:positive])}")
device = device_fixture(%{organization_id: org.id, ip_address: ip_address, name: name})
snmp_device =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: name})
|> Repo.insert!()
_interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_phys_address: mac_address
})
|> Repo.insert!()
device
end
defp create_preseem_ap(org_id, attrs) do
ap_data =
Map.merge(
%{
"id" => "preseem-#{System.unique_integer([:positive])}",
"name" => "Test AP"
},
attrs
)
{:ok, ap} = Sync.upsert_access_point(org_id, ap_data)
ap
end
describe "match_access_point/1" do
test "matches by MAC address through interface -> snmp_device -> device", %{org: org} do
_device = create_device_with_mac(org, "10.0.0.1", "aa:bb:cc:dd:ee:ff")
ap =
create_preseem_ap(org.id, %{
"id" => "ap-mac-match",
"name" => "MAC Match AP",
"mac_address" => "AA:BB:CC:DD:EE:FF"
})
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
assert matched_ap.match_confidence == "auto_mac"
assert matched_ap.device_id
end
test "normalizes MAC addresses for matching (strips separators)", %{org: org} do
_device = create_device_with_mac(org, "10.0.0.2", "11:22:33:44:55:66")
ap =
create_preseem_ap(org.id, %{
"id" => "ap-mac-normalize",
"name" => "MAC Normalize AP",
"mac_address" => "11-22-33-44-55-66"
})
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
assert matched_ap.match_confidence == "auto_mac"
end
test "matches by IP address when MAC does not match", %{org: org} do
_device = device_fixture(%{organization_id: org.id, ip_address: "10.0.0.50", name: "IP Device"})
ap =
create_preseem_ap(org.id, %{
"id" => "ap-ip-match",
"name" => "IP Match AP",
"mac_address" => "FF:FF:FF:FF:FF:FF",
"ip_address" => "10.0.0.50"
})
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
assert matched_ap.match_confidence == "auto_ip"
assert matched_ap.device_id
end
test "matches by hostname when MAC and IP do not match", %{org: org} do
_device =
device_fixture(%{
organization_id: org.id,
ip_address: "192.168.99.99",
name: "tower1-sector-a"
})
ap =
create_preseem_ap(org.id, %{
"id" => "ap-hostname-match",
"name" => "Tower1-Sector-A.local",
"mac_address" => "FF:FF:FF:FF:FF:FE",
"ip_address" => "172.16.0.99"
})
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
assert matched_ap.match_confidence == "auto_hostname"
assert matched_ap.device_id
end
test "returns ambiguous when multiple devices match by MAC", %{org: org} do
mac = "aa:aa:aa:aa:aa:aa"
_device1 = create_device_with_mac(org, "10.0.1.1", mac, name: "Device A")
_device2 = create_device_with_mac(org, "10.0.1.2", mac, name: "Device B")
ap =
create_preseem_ap(org.id, %{
"id" => "ap-ambiguous-mac",
"name" => "Ambiguous MAC AP",
"mac_address" => "AA:AA:AA:AA:AA:AA"
})
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
assert matched_ap.match_confidence == "ambiguous"
assert matched_ap.device_id == nil
end
test "returns ambiguous when multiple devices match by IP", %{org: org} do
_device1 =
device_fixture(%{organization_id: org.id, ip_address: "10.0.2.1", name: "IP Device 1"})
_device2 =
device_fixture(%{organization_id: org.id, ip_address: "10.0.2.1", name: "IP Device 2"})
ap =
create_preseem_ap(org.id, %{
"id" => "ap-ambiguous-ip",
"name" => "Ambiguous IP AP",
"ip_address" => "10.0.2.1"
})
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
assert matched_ap.match_confidence == "ambiguous"
assert matched_ap.device_id == nil
end
test "skips manually matched access points", %{org: org} do
device = device_fixture(%{organization_id: org.id, ip_address: "10.0.3.1", name: "Manual Device"})
ap =
create_preseem_ap(org.id, %{
"id" => "ap-manual",
"name" => "Manual AP",
"ip_address" => "10.0.3.1"
})
# Manually set the match
ap
|> AccessPoint.changeset(%{device_id: device.id, match_confidence: "manual"})
|> Repo.update!()
ap = Repo.get!(AccessPoint, ap.id)
assert {:ok, unchanged_ap} = DeviceMatcher.match_access_point(ap)
assert unchanged_ap.match_confidence == "manual"
assert unchanged_ap.device_id == device.id
end
test "returns unmatched when no devices match", %{org: org} do
ap =
create_preseem_ap(org.id, %{
"id" => "ap-no-match",
"name" => "Unmatched AP",
"mac_address" => "00:00:00:00:00:01",
"ip_address" => "172.31.255.255"
})
assert {:ok, unmatched_ap} = DeviceMatcher.match_access_point(ap)
assert unmatched_ap.match_confidence == "unmatched"
assert unmatched_ap.device_id == nil
end
end
describe "match_unmatched/1" do
test "processes all unmatched APs in an organization", %{org: org} do
_device1 = device_fixture(%{organization_id: org.id, ip_address: "10.0.10.1", name: "Bulk Device 1"})
_device2 = device_fixture(%{organization_id: org.id, ip_address: "10.0.10.2", name: "Bulk Device 2"})
_ap1 =
create_preseem_ap(org.id, %{
"id" => "ap-bulk-1",
"name" => "Bulk AP 1",
"ip_address" => "10.0.10.1"
})
_ap2 =
create_preseem_ap(org.id, %{
"id" => "ap-bulk-2",
"name" => "Bulk AP 2",
"ip_address" => "10.0.10.2"
})
_ap3 =
create_preseem_ap(org.id, %{
"id" => "ap-bulk-3",
"name" => "Bulk AP 3 No Match",
"ip_address" => "172.16.0.100"
})
assert {:ok, matched_count} = DeviceMatcher.match_unmatched(org.id)
assert matched_count == 2
# Verify the matched APs have device_ids
aps = Repo.all(from ap in AccessPoint, where: ap.organization_id == ^org.id, order_by: :preseem_id)
matched_aps = Enum.filter(aps, &(&1.device_id != nil))
assert length(matched_aps) == 2
end
test "does not reprocess already matched APs", %{org: org} do
device = device_fixture(%{organization_id: org.id, ip_address: "10.0.11.1", name: "Already Matched"})
ap =
create_preseem_ap(org.id, %{
"id" => "ap-already-matched",
"name" => "Already Matched AP",
"ip_address" => "10.0.11.1"
})
# Manually match it first
ap
|> AccessPoint.changeset(%{device_id: device.id, match_confidence: "manual"})
|> Repo.update!()
assert {:ok, matched_count} = DeviceMatcher.match_unmatched(org.id)
assert matched_count == 0
end
end
end