towerops/lib/towerops/snmp/discovery.ex

352 lines
12 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.Equipment.Equipment
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.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()
@type discovery_summary :: %{
success: non_neg_integer(),
failed: non_neg_integer(),
errors: [term()]
}
@doc """
Runs discovery for a single piece of equipment.
Returns {:ok, device} or {:error, reason}.
## Examples
iex> discover_equipment(equipment)
{:ok, %Device{manufacturer: "Cisco", model: "C2960"}}
iex> discover_equipment(equipment_without_snmp)
{:error, :snmp_not_enabled}
"""
@spec discover_equipment(Equipment.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_equipment(%Equipment{} = equipment) do
if equipment.snmp_enabled do
Logger.info("Starting SNMP discovery for equipment: #{equipment.name} (#{equipment.ip_address})")
client_opts = build_client_opts(equipment)
with {:ok, _} <- Client.test_connection(client_opts),
{:ok, system_info} <- discover_system(client_opts),
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, device} <- save_discovery_results(equipment, device_info, interfaces, sensors),
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, device.interfaces),
:ok <- save_neighbors(equipment.id, neighbors) do
update_equipment_discovery_time(equipment)
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
# Broadcast discovery completion for real-time updates
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment.id}",
{:discovery_completed, equipment.id}
)
{:ok, device}
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{equipment.name}: #{inspect(reason)}")
error
end
else
{:error, :snmp_not_enabled}
end
end
@doc """
Runs discovery for all SNMP-enabled equipment 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
equipment_list =
Equipment
|> 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(equipment_list)} devices in org #{org_id}")
results =
equipment_list
|> Task.async_stream(
&discover_equipment/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(Equipment.t()) :: Client.connection_opts()
defp build_client_opts(equipment) do
[
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.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.
## 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
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, profile) do
# Let the profile identify the device (adds manufacturer, model, firmware_version)
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, 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, 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(
Equipment.t(),
device_info(),
[interface_data()],
[sensor_data()]
) :: {:ok, Device.t()} | {:error, term()}
defp save_discovery_results(equipment, device_info, interfaces, sensors) do
Repo.transaction(fn ->
# Upsert Device
device = upsert_device(equipment, 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 equipment_id added
%{device | interfaces: Enum.map(new_interfaces, &Map.put(&1, :equipment_id, equipment.id))}
end)
end
@spec upsert_device(Equipment.t(), device_info()) :: Device.t()
defp upsert_device(equipment, device_info) do
case Repo.get_by(Device, equipment_id: equipment.id) do
nil ->
%Device{}
|> Device.changeset(Map.put(device_info, :equipment_id, equipment.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_equipment_discovery_time(Equipment.t()) ::
{:ok, Equipment.t()} | {:error, Ecto.Changeset.t()}
defp update_equipment_discovery_time(equipment) do
equipment
|> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
|> Repo.update()
end
@spec save_neighbors(binary(), [map()]) :: :ok
defp save_neighbors(equipment_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(equipment_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 equipment #{equipment_id}")
:ok
end
end