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.
431 lines
14 KiB
Elixir
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
|