154 lines
4.5 KiB
Elixir
154 lines
4.5 KiB
Elixir
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
|