towerops/lib/towerops/snmp/discovery.ex
Graham McIntire 007adcc489
feat: add database-driven SNMP device profiles
Implement dynamic device profile system using LibreNMS YAML definitions:

- Create 7 database tables for device profiles, detection rules, sensors, processors, memory pools
- Add DeviceProfiles context module with profile matching logic
- Add YAML importer to parse LibreNMS os_detection and os_discovery files
- Add mix task for importing profiles from LibreNMS repository
- Create Dynamic profile module to interpret database definitions at runtime
- Update discovery.ex to check database profiles before hard-coded modules
- Fix Redis PubSub configuration to support unnamed nodes for Mix tasks

Imported 5 Ubiquiti/Cambium profiles: epmp, airos-af, airos-af-ltu, airos-af60, unifi

The system now supports 671+ device profiles from LibreNMS without requiring
code changes for each device type.
2026-01-17 18:13:53 -06:00

431 lines
14 KiB
Elixir

defmodule Towerops.Snmp.Discovery do
@moduledoc """
Core SNMP discovery orchestrator.
Handles the complete discovery workflow:
1. Test SNMP connectivity
2. Discover system information
3. Select appropriate device profile (Cisco, NetSNMP, or Base)
4. Discover interfaces and sensors using profile
5. Save discovered data to database
Can be run manually or scheduled as a background job.
"""
import Ecto.Query
alias Towerops.DeviceProfiles
alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Repo
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Cisco
alias Towerops.Snmp.Profiles.Dynamic
alias Towerops.Snmp.Profiles.Mikrotik
alias Towerops.Snmp.Profiles.NetSnmp
alias Towerops.Snmp.Profiles.Ubiquiti
alias Towerops.Snmp.Sensor
require Logger
@type system_info :: %{
optional(:sys_descr) => String.t(),
optional(:sys_object_id) => String.t(),
optional(:sys_name) => String.t(),
optional(:sys_uptime) => non_neg_integer(),
optional(:sys_contact) => String.t(),
optional(:sys_location) => String.t(),
optional(atom()) => term()
}
@type device_info :: %{
optional(:manufacturer) => String.t(),
optional(:model) => String.t(),
optional(:firmware_version) => String.t() | nil,
optional(:serial_number) => String.t() | nil,
optional(atom()) => term()
}
@type interface_data :: %{
required(:if_index) => non_neg_integer(),
required(:if_descr) => String.t(),
optional(:if_name) => String.t() | nil,
optional(:if_alias) => String.t() | nil,
optional(:if_type) => non_neg_integer() | nil,
optional(:if_mtu) => non_neg_integer() | nil,
optional(:if_speed) => non_neg_integer() | nil,
optional(:if_phys_address) => String.t() | nil,
optional(:if_admin_status) => non_neg_integer() | nil,
optional(:if_oper_status) => non_neg_integer() | nil,
optional(atom()) => term()
}
@type sensor_data :: %{
required(:sensor_type) => String.t(),
required(:sensor_index) => String.t(),
required(:sensor_oid) => String.t(),
required(:sensor_descr) => String.t(),
required(:sensor_unit) => String.t(),
required(:sensor_divisor) => number(),
optional(:last_value) => float() | nil,
optional(:status) => String.t(),
optional(atom()) => term()
}
@type profile :: module() | {:dynamic, DeviceProfiles.DeviceProfile.t()}
@type discovery_summary :: %{
success: non_neg_integer(),
failed: non_neg_integer(),
errors: [term()]
}
@doc """
Runs discovery for a single device.
Returns {:ok, device} or {:error, reason}.
## Examples
iex> discover_device(device)
{:ok, %Device{manufacturer: "Cisco", model: "C2960"}}
iex> discover_device(device_without_snmp)
{:error, :snmp_not_enabled}
"""
@spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_device(%DeviceSchema{} = device) do
if device.snmp_enabled do
Logger.info("Starting SNMP discovery for device: #{device.name} (#{device.ip_address})")
client_opts = build_client_opts(device)
with {:ok, _} <- Client.test_connection(client_opts),
{:ok, system_info} <- discover_system(client_opts),
{:ok, device} <- update_device_name_from_snmp(device, system_info),
profile = select_profile(system_info),
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces(client_opts, profile),
{:ok, sensors} <- discover_sensors(client_opts, profile),
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors),
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, discovered_device.interfaces),
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
_ = update_device_discovery_time(device)
Logger.info("SNMP discovery completed successfully for: #{device.name}")
# Broadcast discovery completion for real-time updates
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:discovery_completed, device.id}
)
{:ok, discovered_device}
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
end
else
{:error, :snmp_not_enabled}
end
end
@doc """
Runs discovery for all SNMP-enabled devices in an organization.
Returns a summary of successful and failed discoveries.
"""
@spec discover_all(String.t()) :: {:ok, discovery_summary()}
def discover_all(org_id) do
device_list =
DeviceSchema
|> join(:inner, [e], s in assoc(e, :site))
|> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true)
|> Repo.all()
Logger.info("Starting SNMP discovery for #{length(device_list)} devices in org #{org_id}")
results =
device_list
|> Task.async_stream(
&discover_device/1,
max_concurrency: 5,
timeout: 60_000,
on_timeout: :kill_task
)
|> Enum.reduce(%{success: 0, failed: 0, errors: []}, fn
{:ok, {:ok, _device}}, acc ->
%{acc | success: acc.success + 1}
{:ok, {:error, reason}}, acc ->
%{acc | failed: acc.failed + 1, errors: [reason | acc.errors]}
{:exit, :timeout}, acc ->
%{acc | failed: acc.failed + 1, errors: [:timeout | acc.errors]}
end)
Logger.info("Discovery completed: #{results.success} succeeded, #{results.failed} failed")
{:ok, results}
end
# Private functions
@spec build_client_opts(DeviceSchema.t()) :: Client.connection_opts()
defp build_client_opts(device) do
# Get SNMP config with hierarchical fallback (device -> site -> organization)
snmp_config = Devices.get_snmp_config(device)
[
ip: device.ip_address,
community: snmp_config.community,
version: snmp_config.version,
port: device.snmp_port || 161,
timeout: 5000
]
end
@spec discover_system(Client.connection_opts()) :: {:ok, system_info()} | {:error, term()}
defp discover_system(client_opts) do
# Use Base profile for initial system discovery
Base.discover_system_info(client_opts)
end
@doc """
Selects the appropriate SNMP profile based on system information.
First tries to match against database-stored profiles from LibreNMS.
If no database match is found, falls back to hard-coded profile modules.
## Examples
iex> select_profile(%{sys_descr: "Cisco IOS"})
Towerops.Snmp.Profiles.Cisco
iex> select_profile(%{sys_object_id: "1.3.6.1.4.1.41112"})
Towerops.Snmp.Profiles.Ubiquiti
"""
@spec select_profile(system_info()) :: profile()
def select_profile(system_info) do
# First try to match against database profiles
case DeviceProfiles.match_profile(system_info) do
%DeviceProfiles.DeviceProfile{} = profile ->
Logger.debug("Selected database profile: #{profile.os}")
{:dynamic, profile}
nil ->
# Fall back to hard-coded profile selection
select_hardcoded_profile(system_info)
end
end
defp select_hardcoded_profile(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
sys_object_id = Map.get(system_info, :sys_object_id, "")
cond do
String.contains?(sys_descr, "RouterOS") ->
Logger.debug("Selected MikroTik profile")
Mikrotik
String.contains?(sys_descr, ["Cisco", "IOS"]) ->
Logger.debug("Selected Cisco profile")
Cisco
# Ubiquiti devices (check before generic Linux since they run Linux)
String.contains?(sys_object_id, "41112") or
String.contains?(sys_descr, ["airOS", "AirFiber", "airMAX", "Ubiquiti"]) ->
Logger.debug("Selected Ubiquiti profile")
Ubiquiti
String.contains?(sys_descr, "Linux") ->
Logger.debug("Selected NetSNMP profile")
NetSnmp
true ->
Logger.debug("Selected Base profile")
Base
end
end
@spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
{:ok, device_info()}
defp build_device_info(_client_opts, system_info, {:dynamic, profile}) do
# Use dynamic profile to identify device
identified_info = Dynamic.identify_device(profile, system_info)
{:ok, identified_info}
rescue
_error ->
Logger.error("Failed to identify device with dynamic profile")
{:ok, system_info}
end
defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do
# Let the hard-coded profile identify the device
identified_info = profile.identify_device(system_info)
{:ok, identified_info}
rescue
_error ->
Logger.error("Failed to identify device")
{:ok, system_info}
end
@spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]}
defp discover_interfaces(client_opts, {:dynamic, profile}) do
case Dynamic.discover_interfaces(profile, client_opts) do
{:ok, interfaces} ->
Logger.debug("Discovered #{length(interfaces)} interfaces")
{:ok, interfaces}
{:error, _} ->
Logger.warning("Interface discovery failed, continuing without interfaces")
{:ok, []}
end
end
defp discover_interfaces(client_opts, profile) when is_atom(profile) do
case profile.discover_interfaces(client_opts) do
{:ok, interfaces} ->
Logger.debug("Discovered #{length(interfaces)} interfaces")
{:ok, interfaces}
{:error, _} ->
Logger.warning("Interface discovery failed, continuing without interfaces")
{:ok, []}
end
end
@spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]}
defp discover_sensors(client_opts, {:dynamic, profile}) do
case Dynamic.discover_sensors(profile, client_opts) do
{:ok, sensors} ->
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
{:error, _} ->
Logger.warning("Sensor discovery failed, continuing without sensors")
{:ok, []}
end
end
defp discover_sensors(client_opts, profile) when is_atom(profile) do
case profile.discover_sensors(client_opts) do
{:ok, sensors} ->
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
{:error, _} ->
Logger.warning("Sensor discovery failed, continuing without sensors")
{:ok, []}
end
end
@spec save_discovery_results(
DeviceSchema.t(),
device_info(),
[interface_data()],
[sensor_data()]
) :: {:ok, Device.t()} | {:error, term()}
defp save_discovery_results(device, device_info, interfaces, sensors) do
Repo.transaction(fn ->
# Upsert Device
device = upsert_device(device, device_info)
# Delete old interfaces/sensors and insert new ones
_ = delete_old_data(device)
new_interfaces = insert_interfaces(device, interfaces)
_ = insert_sensors(device, sensors)
# Return device with interfaces loaded and device_id added
%{device | interfaces: Enum.map(new_interfaces, &Map.put(&1, :device_id, device.id))}
end)
end
@spec upsert_device(DeviceSchema.t(), device_info()) :: Device.t()
defp upsert_device(device, device_info) do
case Repo.get_by(Device, device_id: device.id) do
nil ->
%Device{}
|> Device.changeset(Map.put(device_info, :device_id, device.id))
|> Repo.insert!()
existing_device ->
existing_device
|> Device.changeset(device_info)
|> Repo.update!()
end
end
@spec delete_old_data(Device.t()) :: {non_neg_integer(), nil | [term()]}
defp delete_old_data(device) do
# Delete old interfaces and sensors (cascade will handle stats/readings)
Repo.delete_all(from i in Interface, where: i.snmp_device_id == ^device.id)
Repo.delete_all(from s in Sensor, where: s.snmp_device_id == ^device.id)
end
@spec insert_interfaces(Device.t(), [interface_data()]) :: [Interface.t()]
defp insert_interfaces(device, interfaces) do
Enum.map(interfaces, fn interface_data ->
%Interface{}
|> Interface.changeset(Map.put(interface_data, :snmp_device_id, device.id))
|> Repo.insert!()
end)
end
@spec insert_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
defp insert_sensors(device, sensors) do
Enum.map(sensors, fn sensor_data ->
%Sensor{}
|> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
|> Repo.insert!()
end)
end
@spec update_device_discovery_time(DeviceSchema.t()) ::
{:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
defp update_device_discovery_time(device) do
device
|> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
|> Repo.update()
end
@spec save_neighbors(binary(), [map()]) :: :ok
defp save_neighbors(device_id, neighbors) do
# Delete stale neighbors (not seen in last 5 minutes)
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
Towerops.Snmp.delete_stale_neighbors(device_id, cutoff)
# Upsert each discovered neighbor
Enum.each(neighbors, fn neighbor_data ->
Towerops.Snmp.upsert_neighbor(neighbor_data)
end)
Logger.info("Saved #{length(neighbors)} neighbors for device #{device_id}")
:ok
end
@spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
{:ok, DeviceSchema.t()} | {:error, term()}
defp update_device_name_from_snmp(device, system_info) do
# Only update device name if it's currently empty and we got a sysName from SNMP
if (is_nil(device.name) or device.name == "") and Map.has_key?(system_info, :sys_name) do
sys_name = Map.get(system_info, :sys_name)
if sys_name && sys_name != "" do
Logger.info("Updating device name from SNMP sysName: #{sys_name}")
device
|> Ecto.Changeset.change(name: sys_name)
|> Repo.update()
else
{:ok, device}
end
else
{:ok, device}
end
end
end