towerops/lib/towerops/snmp.ex
2026-01-17 15:13:56 -06:00

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