diff --git a/lib/towerops/preseem/device_matcher.ex b/lib/towerops/preseem/device_matcher.ex new file mode 100644 index 00000000..884b2a36 --- /dev/null +++ b/lib/towerops/preseem/device_matcher.ex @@ -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 diff --git a/test/towerops/preseem/device_matcher_test.exs b/test/towerops/preseem/device_matcher_test.exs new file mode 100644 index 00000000..567b49b6 --- /dev/null +++ b/test/towerops/preseem/device_matcher_test.exs @@ -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