500 lines
12 KiB
Elixir
500 lines
12 KiB
Elixir
defmodule Towerops.Snmp do
|
|
@moduledoc """
|
|
The SNMP context.
|
|
|
|
Provides the public API for SNMP discovery and monitoring functionality.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Devices.Device, as: DeviceSchema
|
|
alias Towerops.Repo
|
|
alias Towerops.Snmp.Client
|
|
alias Towerops.Snmp.Device
|
|
alias Towerops.Snmp.Discovery
|
|
alias Towerops.Snmp.Interface
|
|
alias Towerops.Snmp.InterfaceStat
|
|
alias Towerops.Snmp.Neighbor
|
|
alias Towerops.Snmp.Sensor
|
|
alias Towerops.Snmp.SensorReading
|
|
|
|
@doc """
|
|
Tests SNMP connectivity to a device.
|
|
|
|
## Examples
|
|
|
|
iex> test_connection("192.168.1.1", "public", "2c")
|
|
{:ok, "Connection successful"}
|
|
|
|
iex> test_connection("192.168.1.99", "wrong", "2c")
|
|
{:error, :timeout}
|
|
"""
|
|
def test_connection(ip, community, version, port \\ 161) do
|
|
Client.test_connection(
|
|
ip: ip,
|
|
community: community,
|
|
version: version,
|
|
port: port
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Runs SNMP discovery for a piece of device.
|
|
|
|
Discovers:
|
|
- Device information (manufacturer, model, firmware)
|
|
- Network interfaces
|
|
- Sensors (temperature, power, fans, etc.)
|
|
|
|
Updates the database with discovered data.
|
|
|
|
## Examples
|
|
|
|
iex> discover_device(device)
|
|
{:ok, %Device{}}
|
|
"""
|
|
def discover_device(%DeviceSchema{} = device) do
|
|
Discovery.discover_device(device)
|
|
end
|
|
|
|
@doc """
|
|
Runs SNMP discovery for all SNMP-enabled devices in an organization.
|
|
|
|
Returns a summary of successful and failed discoveries.
|
|
|
|
## Examples
|
|
|
|
iex> discover_all_for_org(org_id)
|
|
{:ok, %{success: 10, failed: 2, errors: [:timeout, :no_response]}}
|
|
"""
|
|
def discover_all_for_org(org_id) do
|
|
Discovery.discover_all(org_id)
|
|
end
|
|
|
|
# Device queries
|
|
|
|
@doc """
|
|
Gets the SNMP device for a piece of device.
|
|
|
|
## Examples
|
|
|
|
iex> get_device(device_id)
|
|
%Device{}
|
|
|
|
iex> get_device(nonexistent_id)
|
|
nil
|
|
"""
|
|
def get_device(device_id) do
|
|
Repo.get_by(Device, device_id: device_id)
|
|
end
|
|
|
|
@doc """
|
|
Gets the SNMP device for a device with preloaded associations.
|
|
"""
|
|
def get_device_with_associations(device_id) do
|
|
Device
|
|
|> where([d], d.device_id == ^device_id)
|
|
|> preload([:interfaces, :sensors])
|
|
|> Repo.one()
|
|
end
|
|
|
|
# Interface queries
|
|
|
|
@doc """
|
|
Lists all interfaces for a device.
|
|
"""
|
|
def list_interfaces(device_id) do
|
|
Interface
|
|
|> where([i], i.snmp_device_id == ^device_id)
|
|
|> order_by([i], i.if_index)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Lists only monitored interfaces for a device.
|
|
"""
|
|
def list_monitored_interfaces(device_id) do
|
|
Interface
|
|
|> where([i], i.snmp_device_id == ^device_id and i.monitored == true)
|
|
|> order_by([i], i.if_index)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Gets a specific interface.
|
|
"""
|
|
def get_interface(interface_id) do
|
|
Repo.get(Interface, interface_id)
|
|
end
|
|
|
|
@doc """
|
|
Updates an interface.
|
|
"""
|
|
def update_interface(%Interface{} = interface, attrs) do
|
|
interface
|
|
|> Interface.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
# Sensor queries
|
|
|
|
@doc """
|
|
Lists all sensors for a device.
|
|
"""
|
|
def list_sensors(device_id) do
|
|
Sensor
|
|
|> where([s], s.snmp_device_id == ^device_id)
|
|
|> order_by([s], [s.sensor_type, s.sensor_index])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Lists only monitored sensors for a device.
|
|
"""
|
|
def list_monitored_sensors(device_id) do
|
|
Sensor
|
|
|> where([s], s.snmp_device_id == ^device_id and s.monitored == true)
|
|
|> order_by([s], [s.sensor_type, s.sensor_index])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Lists sensors grouped by type.
|
|
"""
|
|
def list_sensors_by_type(device_id) do
|
|
sensors =
|
|
Sensor
|
|
|> where([s], s.snmp_device_id == ^device_id)
|
|
|> order_by([s], [s.sensor_type, s.sensor_index])
|
|
|> Repo.all()
|
|
|
|
Enum.group_by(sensors, & &1.sensor_type)
|
|
end
|
|
|
|
@doc """
|
|
Gets a specific sensor.
|
|
"""
|
|
def get_sensor(sensor_id) do
|
|
Repo.get(Sensor, sensor_id)
|
|
end
|
|
|
|
@doc """
|
|
Updates a sensor.
|
|
"""
|
|
def update_sensor(%Sensor{} = sensor, attrs) do
|
|
sensor
|
|
|> Sensor.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
# Sensor readings queries
|
|
|
|
@doc """
|
|
Gets recent sensor readings for a sensor.
|
|
|
|
## Options
|
|
|
|
- `:limit` - Maximum number of readings to return (default: 100)
|
|
- `:since` - Only return readings after this datetime
|
|
"""
|
|
def get_sensor_readings(sensor_id, opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, 100)
|
|
since = Keyword.get(opts, :since)
|
|
|
|
query =
|
|
SensorReading
|
|
|> where([r], r.sensor_id == ^sensor_id)
|
|
|> order_by([r], desc: r.checked_at)
|
|
|> limit(^limit)
|
|
|
|
query =
|
|
if since do
|
|
where(query, [r], r.checked_at >= ^since)
|
|
else
|
|
query
|
|
end
|
|
|
|
Repo.all(query)
|
|
end
|
|
|
|
@doc """
|
|
Gets the latest sensor reading for a sensor.
|
|
"""
|
|
def get_latest_sensor_reading(sensor_id) do
|
|
SensorReading
|
|
|> where([r], r.sensor_id == ^sensor_id)
|
|
|> order_by([r], desc: r.checked_at)
|
|
|> limit(1)
|
|
|> Repo.one()
|
|
end
|
|
|
|
@doc """
|
|
Gets the latest sensor readings for multiple sensors in a single query.
|
|
|
|
Returns a map of %{sensor_id => reading} for efficient batch loading.
|
|
Sensors without readings will not be present in the map.
|
|
|
|
## Examples
|
|
|
|
iex> get_latest_sensor_readings_batch([sensor_id1, sensor_id2])
|
|
%{sensor_id1 => %SensorReading{}, sensor_id2 => %SensorReading{}}
|
|
"""
|
|
def get_latest_sensor_readings_batch(sensor_ids) when is_list(sensor_ids) do
|
|
if Enum.empty?(sensor_ids) do
|
|
%{}
|
|
else
|
|
# Use DISTINCT ON to get only the latest reading per sensor
|
|
query =
|
|
from(r in SensorReading,
|
|
where: r.sensor_id in ^sensor_ids,
|
|
distinct: r.sensor_id,
|
|
order_by: [asc: r.sensor_id, desc: r.checked_at]
|
|
)
|
|
|
|
query
|
|
|> Repo.all()
|
|
|> Map.new(fn reading -> {reading.sensor_id, reading} end)
|
|
end
|
|
end
|
|
|
|
# Interface stats queries
|
|
|
|
@doc """
|
|
Gets recent interface statistics for an interface.
|
|
|
|
## Options
|
|
|
|
- `:limit` - Maximum number of stats to return (default: 100)
|
|
- `:since` - Only return stats after this datetime
|
|
"""
|
|
def get_interface_stats(interface_id, opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, 100)
|
|
since = Keyword.get(opts, :since)
|
|
|
|
query =
|
|
InterfaceStat
|
|
|> where([s], s.interface_id == ^interface_id)
|
|
|> order_by([s], desc: s.checked_at)
|
|
|> limit(^limit)
|
|
|
|
query =
|
|
if since do
|
|
where(query, [s], s.checked_at >= ^since)
|
|
else
|
|
query
|
|
end
|
|
|
|
Repo.all(query)
|
|
end
|
|
|
|
@doc """
|
|
Gets the latest interface stat for an interface.
|
|
"""
|
|
def get_latest_interface_stat(interface_id) do
|
|
InterfaceStat
|
|
|> where([s], s.interface_id == ^interface_id)
|
|
|> order_by([s], desc: s.checked_at)
|
|
|> limit(1)
|
|
|> Repo.one()
|
|
end
|
|
|
|
@doc """
|
|
Gets the latest interface stats for multiple interfaces in a single query.
|
|
|
|
Returns a map of %{interface_id => stat} for efficient batch loading.
|
|
Interfaces without stats will not be present in the map.
|
|
|
|
## Examples
|
|
|
|
iex> get_latest_interface_stats_batch([if_id1, if_id2])
|
|
%{if_id1 => %InterfaceStat{}, if_id2 => %InterfaceStat{}}
|
|
"""
|
|
def get_latest_interface_stats_batch(interface_ids) when is_list(interface_ids) do
|
|
if Enum.empty?(interface_ids) do
|
|
%{}
|
|
else
|
|
# Use DISTINCT ON to get only the latest stat per interface
|
|
query =
|
|
from(s in InterfaceStat,
|
|
where: s.interface_id in ^interface_ids,
|
|
distinct: s.interface_id,
|
|
order_by: [asc: s.interface_id, desc: s.checked_at]
|
|
)
|
|
|
|
query
|
|
|> Repo.all()
|
|
|> Map.new(fn stat -> {stat.interface_id, stat} end)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Records a new sensor reading.
|
|
"""
|
|
def create_sensor_reading(attrs) do
|
|
%SensorReading{}
|
|
|> SensorReading.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Records a new interface stat.
|
|
"""
|
|
def create_interface_stat(attrs) do
|
|
%InterfaceStat{}
|
|
|> InterfaceStat.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
# Neighbor queries
|
|
|
|
@doc """
|
|
Lists all neighbors for a piece of device.
|
|
"""
|
|
def list_neighbors(device_id) do
|
|
Neighbor
|
|
|> where([n], n.device_id == ^device_id)
|
|
|> preload(:interface)
|
|
|> order_by([n], [n.protocol, n.remote_system_name])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Lists all neighbors for device with enriched data linking to known device.
|
|
Adds a `matched_equipment` field to each neighbor if we can identify the remote device.
|
|
"""
|
|
def list_neighbors_with_equipment(device_id) do
|
|
neighbors = list_neighbors(device_id)
|
|
|
|
# device to search within the same org
|
|
device = Towerops.Devices.get_device!(device_id)
|
|
|
|
Enum.map(neighbors, fn neighbor ->
|
|
matched_device = find_matching_equipment(neighbor, device.site.organization_id)
|
|
Map.put(neighbor, :matched_device, matched_device)
|
|
end)
|
|
end
|
|
|
|
# device in the organization
|
|
defp find_matching_equipment(neighbor, organization_id) do
|
|
# Try matching strategies in order: chassis ID, port ID, system name
|
|
try_match_by_chassis(neighbor, organization_id) ||
|
|
try_match_by_port(neighbor, organization_id) ||
|
|
try_match_by_name(neighbor, organization_id)
|
|
end
|
|
|
|
defp try_match_by_chassis(neighbor, organization_id) do
|
|
if neighbor.remote_chassis_id && mac_address?(neighbor.remote_chassis_id) do
|
|
find_device_by_mac(neighbor.remote_chassis_id, organization_id)
|
|
end
|
|
end
|
|
|
|
defp try_match_by_port(neighbor, organization_id) do
|
|
if neighbor.remote_port_id && mac_address?(neighbor.remote_port_id) do
|
|
find_device_by_mac(neighbor.remote_port_id, organization_id)
|
|
end
|
|
end
|
|
|
|
defp try_match_by_name(neighbor, organization_id) do
|
|
if neighbor.remote_system_name do
|
|
find_device_by_name(neighbor.remote_system_name, organization_id)
|
|
end
|
|
end
|
|
|
|
# Check if a string looks like a MAC address
|
|
defp mac_address?(str) when is_binary(str) do
|
|
# Match patterns like "AA:BB:CC:DD:EE:FF" or "aa-bb-cc-dd-ee-ff"
|
|
String.match?(str, ~r/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/)
|
|
end
|
|
|
|
defp mac_address?(_), do: false
|
|
|
|
defp find_device_by_mac(mac_address, organization_id) do
|
|
# MAC address might be in format "aa:bb:cc:dd:ee:ff", "aa-bb-cc-dd-ee-ff", or "aabbccddeeff"
|
|
# Normalize to just the hex digits for comparison
|
|
normalized_mac = mac_address |> String.downcase() |> String.replace(~r/[:-]/, "")
|
|
|
|
Repo.one(
|
|
from(i in Interface,
|
|
join: d in Device,
|
|
on: i.snmp_device_id == d.id,
|
|
join: e in DeviceSchema,
|
|
on: d.device_id == e.id,
|
|
join: s in assoc(e, :site),
|
|
where: s.organization_id == ^organization_id,
|
|
where:
|
|
fragment("REPLACE(REPLACE(LOWER(?), ':', ''), '-', '')", i.if_phys_address) ==
|
|
^normalized_mac,
|
|
select: e,
|
|
limit: 1
|
|
)
|
|
)
|
|
end
|
|
|
|
defp find_device_by_name(system_name, organization_id) do
|
|
# Try exact match first, then partial match
|
|
normalized_name = String.downcase(system_name)
|
|
|
|
from(e in DeviceSchema,
|
|
join: s in assoc(e, :site),
|
|
where: s.organization_id == ^organization_id,
|
|
where: fragment("LOWER(?)", e.name) == ^normalized_name,
|
|
select: e,
|
|
limit: 1
|
|
)
|
|
|> Repo.one()
|
|
|> case do
|
|
nil ->
|
|
# Try partial match as fallback
|
|
Repo.one(
|
|
from(e in DeviceSchema,
|
|
join: s in assoc(e, :site),
|
|
where: s.organization_id == ^organization_id,
|
|
where: fragment("LOWER(?) LIKE ?", e.name, ^"%#{normalized_name}%"),
|
|
select: e,
|
|
limit: 1
|
|
)
|
|
)
|
|
|
|
device ->
|
|
device
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets a specific neighbor.
|
|
"""
|
|
def get_neighbor(neighbor_id) do
|
|
Repo.get(Neighbor, neighbor_id)
|
|
end
|
|
|
|
@doc """
|
|
Creates or updates a neighbor record.
|
|
"""
|
|
def upsert_neighbor(attrs) do
|
|
case Repo.get_by(Neighbor,
|
|
interface_id: attrs.interface_id,
|
|
remote_chassis_id: attrs[:remote_chassis_id],
|
|
protocol: attrs.protocol
|
|
) do
|
|
nil ->
|
|
%Neighbor{}
|
|
|> Neighbor.changeset(attrs)
|
|
|> Repo.insert()
|
|
|
|
neighbor ->
|
|
neighbor
|
|
|> Neighbor.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Deletes stale neighbors that haven't been seen since the given datetime.
|
|
"""
|
|
def delete_stale_neighbors(device_id, cutoff_datetime) do
|
|
Neighbor
|
|
|> where([n], n.device_id == ^device_id)
|
|
|> where([n], n.last_discovered_at < ^cutoff_datetime)
|
|
|> Repo.delete_all()
|
|
end
|
|
end
|