towerops/lib/towerops/snmp.ex
2026-01-23 09:22:08 -06:00

887 lines
21 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.ArpEntry
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.MacAddress
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Processor
alias Towerops.Snmp.ProcessorReading
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.StateSensor
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
alias Towerops.Snmp.Vlan
@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, :state_sensors, :processors, :storage])
|> 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
# State sensor queries
@doc """
Updates a state sensor.
"""
def update_state_sensor(%StateSensor{} = state_sensor, attrs) do
state_sensor
|> StateSensor.changeset(attrs)
|> Repo.update()
end
@doc """
Lists all state sensors for a device.
"""
def list_state_sensors(device_id) do
StateSensor
|> where([s], s.snmp_device_id == ^device_id)
|> order_by([s], s.sensor_index)
|> Repo.all()
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
# VLAN queries
@doc """
Lists all VLANs for a device.
"""
def list_vlans(snmp_device_id) do
Vlan
|> where([v], v.snmp_device_id == ^snmp_device_id)
|> order_by([v], v.vlan_id)
|> Repo.all()
end
@doc """
Gets a specific VLAN.
"""
def get_vlan(vlan_id) do
Repo.get(Vlan, vlan_id)
end
# IP Address queries
@doc """
Lists all IP addresses for a device (across all its interfaces).
"""
def list_ip_addresses(snmp_device_id) do
IpAddress
|> join(:inner, [ip], i in Interface, on: ip.snmp_interface_id == i.id)
|> where([ip, i], i.snmp_device_id == ^snmp_device_id)
|> preload(:snmp_interface)
|> order_by([ip], [ip.ip_type, ip.ip_address])
|> Repo.all()
end
@doc """
Lists IP addresses for a specific interface.
"""
def list_interface_ip_addresses(interface_id) do
IpAddress
|> where([ip], ip.snmp_interface_id == ^interface_id)
|> order_by([ip], [ip.ip_type, ip.ip_address])
|> Repo.all()
end
# Processor queries
@doc """
Lists all processors for a device.
"""
def list_processors(snmp_device_id) do
Processor
|> where([p], p.snmp_device_id == ^snmp_device_id)
|> order_by([p], p.processor_index)
|> Repo.all()
end
@doc """
Gets a specific processor.
"""
def get_processor(processor_id) do
Repo.get(Processor, processor_id)
end
@doc """
Updates a processor.
"""
def update_processor(processor, attrs) do
processor
|> Processor.changeset(attrs)
|> Repo.update()
end
# Processor reading queries
@doc """
Gets recent processor readings for a processor.
## Options
- `:limit` - Maximum number of readings to return (default: 100)
- `:since` - Only return readings after this datetime
"""
def get_processor_readings(processor_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
since = Keyword.get(opts, :since)
query =
ProcessorReading
|> where([r], r.processor_id == ^processor_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 """
Records a new processor reading.
"""
def create_processor_reading(attrs) do
%ProcessorReading{}
|> ProcessorReading.changeset(attrs)
|> Repo.insert()
end
# Storage queries
@doc """
Lists all storage entries for a device.
"""
def list_storage(snmp_device_id) do
Storage
|> where([s], s.snmp_device_id == ^snmp_device_id)
|> order_by([s], s.storage_index)
|> Repo.all()
end
@doc """
Gets a specific storage entry.
"""
def get_storage(storage_id) do
Repo.get(Storage, storage_id)
end
@doc """
Updates a storage entry.
"""
def update_storage(storage, attrs) do
storage
|> Storage.changeset(attrs)
|> Repo.update()
end
# Storage reading queries
@doc """
Gets recent storage readings for a storage entry.
## Options
- `:limit` - Maximum number of readings to return (default: 100)
- `:since` - Only return readings after this datetime
"""
def get_storage_readings(storage_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
since = Keyword.get(opts, :since)
query =
StorageReading
|> where([r], r.storage_id == ^storage_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 storage reading for a storage entry.
"""
def get_latest_storage_reading(storage_id) do
StorageReading
|> where([r], r.storage_id == ^storage_id)
|> order_by([r], desc: r.checked_at)
|> limit(1)
|> Repo.one()
end
@doc """
Gets the latest storage readings for multiple storage entries in a single query.
Returns a map of %{storage_id => reading} for efficient batch loading.
Storage entries without readings will not be present in the map.
## Examples
iex> get_latest_storage_readings_batch([storage_id1, storage_id2])
%{storage_id1 => %StorageReading{}, storage_id2 => %StorageReading{}}
"""
def get_latest_storage_readings_batch(storage_ids) when is_list(storage_ids) do
if Enum.empty?(storage_ids) do
%{}
else
# Use DISTINCT ON to get only the latest reading per storage
query =
from(r in StorageReading,
where: r.storage_id in ^storage_ids,
distinct: r.storage_id,
order_by: [asc: r.storage_id, desc: r.checked_at]
)
query
|> Repo.all()
|> Map.new(fn reading -> {reading.storage_id, reading} end)
end
end
@doc """
Records a new storage reading.
"""
def create_storage_reading(attrs) do
%StorageReading{}
|> StorageReading.changeset(attrs)
|> Repo.insert()
end
# ARP Entry queries
@doc """
Lists all ARP entries for a device.
"""
def list_arp_entries(device_id) do
ArpEntry
|> where([a], a.device_id == ^device_id)
|> order_by([a], a.ip_address)
|> preload(:interface)
|> Repo.all()
end
@doc """
Gets a specific ARP entry.
"""
def get_arp_entry(arp_entry_id) do
Repo.get(ArpEntry, arp_entry_id)
end
@doc """
Creates or updates an ARP entry.
"""
def upsert_arp_entry(attrs) do
case Repo.get_by(ArpEntry,
device_id: attrs.device_id,
ip_address: attrs.ip_address,
mac_address: attrs.mac_address
) do
nil ->
%ArpEntry{}
|> ArpEntry.changeset(attrs)
|> Repo.insert()
arp_entry ->
arp_entry
|> ArpEntry.changeset(attrs)
|> Repo.update()
end
end
@doc """
Deletes stale ARP entries that haven't been seen since the given datetime.
"""
def delete_stale_arp_entries(device_id, cutoff_datetime) do
ArpEntry
|> where([a], a.device_id == ^device_id)
|> where([a], a.last_seen_at < ^cutoff_datetime)
|> Repo.delete_all()
end
@doc """
Bulk upserts ARP entries for a device.
Returns the number of entries processed.
"""
def upsert_arp_entries(device_id, arp_entries, interfaces) do
interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end)
Enum.each(arp_entries, fn entry ->
interface_id = Map.get(interface_map, entry.if_index)
attrs =
entry
|> Map.put(:device_id, device_id)
|> Map.put(:interface_id, interface_id)
upsert_arp_entry(attrs)
end)
length(arp_entries)
end
# MAC Address queries
@doc """
Lists all MAC address entries for a device.
"""
def list_mac_addresses(device_id) do
MacAddress
|> where([m], m.device_id == ^device_id)
|> order_by([m], [m.vlan_id, m.mac_address])
|> preload(:interface)
|> Repo.all()
end
@doc """
Creates or updates a MAC address entry.
"""
def upsert_mac_address(attrs) do
case Repo.get_by(MacAddress,
device_id: attrs.device_id,
mac_address: attrs.mac_address,
vlan_id: attrs[:vlan_id]
) do
nil ->
%MacAddress{}
|> MacAddress.changeset(attrs)
|> Repo.insert()
mac_address ->
mac_address
|> MacAddress.changeset(attrs)
|> Repo.update()
end
end
@doc """
Deletes stale MAC address entries that haven't been seen since the given datetime.
"""
def delete_stale_mac_addresses(device_id, cutoff_datetime) do
MacAddress
|> where([m], m.device_id == ^device_id)
|> where([m], m.last_seen_at < ^cutoff_datetime)
|> Repo.delete_all()
end
@doc """
Bulk upserts MAC address entries for a device.
Returns the number of entries processed.
"""
def upsert_mac_addresses(device_id, mac_entries, interfaces) do
# Build a map from bridge port index to interface ID
# Note: port_index from BRIDGE-MIB is not the same as if_index
# For now, we'll use a simple mapping if they're available
interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end)
Enum.each(mac_entries, fn entry ->
interface_id = Map.get(interface_map, entry.port_index)
attrs =
entry
|> Map.put(:device_id, device_id)
|> Map.put(:interface_id, interface_id)
upsert_mac_address(attrs)
end)
length(mac_entries)
end
end