snmp bits

This commit is contained in:
Graham McIntire 2026-01-03 14:41:28 -06:00
parent 0f9266027f
commit 8cbca259fc
No known key found for this signature in database
40 changed files with 3033 additions and 42 deletions

View file

@ -42,3 +42,8 @@ config :towerops, ToweropsWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "XDSSqVUtRXUjEfzxefIAaPIzBfonpNMOrCRyP1a0kjPzzyOpVNSRmMBVae/bwTqj",
server: false
# Use mocks for testing
config :towerops,
ping_module: Towerops.Monitoring.PingMock,
poller_module: Towerops.Snmp.PollerMock

View file

@ -15,6 +15,7 @@ defmodule Towerops.Alerts.AlertNotifier do
Returns list of sent emails for tracking.
"""
@spec deliver_alert_notification(Alert) :: {:ok, [{:ok, Swoosh.Email.t()} | {:error, term()}]}
def deliver_alert_notification(%Alert{} = alert) do
equipment =
alert.equipment_id
@ -41,6 +42,8 @@ defmodule Towerops.Alerts.AlertNotifier do
defp deliver_equipment_down_alert(recipient_email, equipment, organization) do
subject = "[#{organization.name}] Equipment Down: #{equipment.name}"
check_method = if equipment.snmp_enabled, do: "SNMP", else: "ping"
body = """
==============================
@ -52,7 +55,7 @@ defmodule Towerops.Alerts.AlertNotifier do
IP Address: #{equipment.ip_address}
Site: #{equipment.site.name}
The equipment is not responding to ping checks.
The equipment is not responding to #{check_method} checks.
Please investigate as soon as possible.
@ -65,6 +68,8 @@ defmodule Towerops.Alerts.AlertNotifier do
defp deliver_equipment_up_alert(recipient_email, equipment, organization) do
subject = "[#{organization.name}] Equipment Recovered: #{equipment.name}"
check_method = if equipment.snmp_enabled, do: "SNMP", else: "ping"
body = """
==============================
@ -76,7 +81,7 @@ defmodule Towerops.Alerts.AlertNotifier do
IP Address: #{equipment.ip_address}
Site: #{equipment.site.name}
The equipment is now responding to ping checks.
The equipment is now responding to #{check_method} checks.
==============================
"""

View file

@ -16,8 +16,17 @@ defmodule Towerops.Equipment.Equipment do
field :monitoring_enabled, :boolean, default: true
field :check_interval_seconds, :integer, default: 300
# SNMP fields
field :snmp_enabled, :boolean, default: false
field :snmp_version, :string, default: "2c"
field :snmp_community, :string
field :snmp_port, :integer, default: 161
field :last_discovery_at, :utc_datetime
belongs_to :site, Towerops.Sites.Site
has_one :snmp_device, Towerops.Snmp.Device
timestamps(type: :utc_datetime)
end
@ -30,13 +39,18 @@ defmodule Towerops.Equipment.Equipment do
:description,
:site_id,
:monitoring_enabled,
:check_interval_seconds
:check_interval_seconds,
:snmp_enabled,
:snmp_version,
:snmp_community,
:snmp_port
])
|> validate_required([:name, :ip_address, :site_id])
|> validate_length(:name, min: 2, max: 200)
|> validate_length(:description, max: 1000)
|> validate_ip_address()
|> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
|> validate_snmp()
|> foreign_key_constraint(:site_id)
end
@ -61,4 +75,19 @@ defmodule Towerops.Equipment.Equipment do
|> String.to_charlist()
|> :inet.parse_address()
end
defp validate_snmp(changeset) do
snmp_enabled = get_field(changeset, :snmp_enabled)
if snmp_enabled do
changeset
|> validate_required([:snmp_version, :snmp_community],
message: "required when SNMP is enabled"
)
|> validate_inclusion(:snmp_version, ["1", "2c"], message: "must be either '1' or '2c'")
|> validate_number(:snmp_port, greater_than: 0, less_than: 65_536)
else
changeset
end
end
end

View file

@ -7,10 +7,13 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Monitoring.Ping
require Logger
# Allow dependency injection for testing
@ping_module Application.compile_env(:towerops, :ping_module, Towerops.Monitoring.Ping)
@poller_module Application.compile_env(:towerops, :poller_module, Towerops.Snmp.Poller)
# Client API
@doc """
@ -67,7 +70,15 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
defp perform_check(equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
check_result = Ping.ping(equipment.ip_address)
# Use SNMP if enabled, otherwise fallback to ping
check_result =
if equipment.snmp_enabled do
client_opts = @poller_module.build_client_opts(equipment)
@poller_module.check_device(client_opts)
else
@ping_module.ping(equipment.ip_address)
end
now = DateTime.utc_now()
{status, response_time} =
@ -93,7 +104,7 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
# Create alerts if status changed
_ =
if old_status != new_status do
handle_status_change(equipment_id, old_status, new_status)
handle_status_change(equipment, old_status, new_status)
end
# Broadcast status change via PubSub
@ -110,19 +121,26 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
end
end
defp handle_status_change(equipment_id, old_status, new_status) do
defp handle_status_change(equipment, old_status, new_status) do
now = DateTime.utc_now()
case {old_status, new_status} do
{_, :down} ->
# Equipment went down - create alert if one doesn't exist
if !Alerts.has_active_alert?(equipment_id, :equipment_down) do
if !Alerts.has_active_alert?(equipment.id, :equipment_down) do
alert_message =
if equipment.snmp_enabled do
"Equipment is not responding to SNMP"
else
"Equipment is not responding to ping"
end
{:ok, alert} =
Alerts.create_alert(%{
equipment_id: equipment_id,
equipment_id: equipment.id,
alert_type: :equipment_down,
triggered_at: now,
message: "Equipment is not responding to ping"
message: alert_message
})
# Send email notification in background (not in test environment)
@ -138,18 +156,25 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:new",
{:new_alert, equipment_id, :equipment_down}
{:new_alert, equipment.id, :equipment_down}
)
end
{_, :up} ->
# Equipment came back up - create recovery alert and resolve down alert
recovery_message =
if equipment.snmp_enabled do
"Equipment is now responding to SNMP"
else
"Equipment is now responding to ping"
end
{:ok, alert} =
Alerts.create_alert(%{
equipment_id: equipment_id,
equipment_id: equipment.id,
alert_type: :equipment_up,
triggered_at: now,
message: "Equipment is now responding to ping"
message: recovery_message
})
# Send email notification in background (not in test environment)
@ -162,7 +187,7 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
# Resolve any active equipment_down alerts
_ =
case Alerts.get_active_alert(equipment_id, :equipment_down) do
case Alerts.get_active_alert(equipment.id, :equipment_down) do
nil ->
:ok
@ -175,7 +200,7 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:resolved",
{:alert_resolved, equipment_id, :equipment_down}
{:alert_resolved, equipment.id, :equipment_down}
)
end
end

View file

@ -3,11 +3,14 @@ defmodule Towerops.Monitoring.Ping do
Handles ping operations for equipment monitoring.
"""
@behaviour Towerops.Monitoring.PingBehaviour
@doc """
Pings an IP address and returns the result.
Returns {:ok, response_time_ms} on success, {:error, reason} on failure.
"""
@impl true
def ping(ip_address, timeout_ms \\ 5000) do
start_time = System.monotonic_time(:millisecond)

View file

@ -0,0 +1,8 @@
defmodule Towerops.Monitoring.PingBehaviour do
@moduledoc """
Behaviour for ping implementations.
"""
@callback ping(String.t()) :: {:ok, number()} | {:error, term()}
@callback ping(String.t(), non_neg_integer()) :: {:ok, number()} | {:error, term()}
end

288
lib/towerops/snmp.ex Normal file
View file

@ -0,0 +1,288 @@
defmodule Towerops.Snmp do
@moduledoc """
The SNMP context.
Provides the public API for SNMP discovery and monitoring functionality.
"""
import Ecto.Query
alias Towerops.Equipment.Equipment
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.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 equipment.
Discovers:
- Device information (manufacturer, model, firmware)
- Network interfaces
- Sensors (temperature, power, fans, etc.)
Updates the database with discovered data.
## Examples
iex> discover_equipment(equipment)
{:ok, %Device{}}
"""
def discover_equipment(%Equipment{} = equipment) do
Discovery.discover_equipment(equipment)
end
@doc """
Runs SNMP discovery for all SNMP-enabled equipment 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 equipment.
## Examples
iex> get_device(equipment_id)
%Device{}
iex> get_device(nonexistent_id)
nil
"""
def get_device(equipment_id) do
Repo.get_by(Device, equipment_id: equipment_id)
end
@doc """
Gets the SNMP device for a piece of equipment with preloaded associations.
"""
def get_device_with_associations(equipment_id) do
Device
|> where([d], d.equipment_id == ^equipment_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
# 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 """
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
end

211
lib/towerops/snmp/client.ex Normal file
View file

@ -0,0 +1,211 @@
defmodule Towerops.Snmp.Client do
@moduledoc """
SNMP client wrapper around snmpkit for device communication.
Provides clean interface for SNMP get, walk, and get_bulk operations.
"""
require Logger
@type connection_opts :: [
ip: String.t(),
community: String.t(),
version: String.t(),
port: non_neg_integer(),
timeout: non_neg_integer()
]
@type oid :: String.t() | [non_neg_integer()]
@type snmp_value :: term()
@type snmp_result :: {:ok, snmp_value()} | {:error, term()}
@default_timeout 5000
@default_retries 2
@doc """
Performs an SNMP GET operation for a single OID.
## Examples
iex> get([ip: "192.168.1.1", community: "public", version: "2c"], "1.3.6.1.2.1.1.1.0")
{:ok, "Cisco IOS Software..."}
iex> get([ip: "192.168.1.1", community: "public", version: "2c"], [1, 3, 6, 1, 2, 1, 1, 1, 0])
{:ok, "Cisco IOS Software..."}
"""
@spec get(connection_opts(), oid()) :: snmp_result()
def get(opts, oid) do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
case SnmpKit.get(target, oid, snmp_opts) do
{:ok, value} ->
{:ok, extract_snmp_value(value)}
{:error, reason} = error ->
Logger.warning("SNMP GET failed for #{inspect(oid)}: #{inspect(reason)}")
error
end
end
@doc """
Performs an SNMP GET operation for multiple OIDs.
## Examples
iex> get_multiple([ip: "192.168.1.1", community: "public", version: "2c"],
...> ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0"])
{:ok, ["Cisco IOS Software...", 12345]}
"""
@spec get_multiple(connection_opts(), [oid()]) :: {:ok, [snmp_value()]} | {:error, term()}
def get_multiple(opts, oids) when is_list(oids) do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
# Get each OID and collect results
results =
Enum.map(oids, fn oid ->
case SnmpKit.get(target, oid, snmp_opts) do
{:ok, value} -> {:ok, extract_snmp_value(value)}
error -> error
end
end)
# Check if any failed
if Enum.any?(results, fn result -> match?({:error, _}, result) end) do
{:error, :partial_failure}
else
values = Enum.map(results, fn {:ok, value} -> value end)
{:ok, values}
end
end
@doc """
Performs an SNMP WALK operation starting from the given OID.
Returns all OIDs and values under the specified subtree.
## Examples
iex> walk([ip: "192.168.1.1", community: "public", version: "2c"], "1.3.6.1.2.1.2.2.1.2")
{:ok, %{
"1.3.6.1.2.1.2.2.1.2.1" => "Ethernet0",
"1.3.6.1.2.1.2.2.1.2.2" => "Ethernet1"
}}
"""
@spec walk(connection_opts(), oid()) :: {:ok, %{String.t() => snmp_value()}} | {:error, term()}
def walk(opts, start_oid) do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
case SnmpKit.walk(target, start_oid, snmp_opts) do
{:ok, results} when is_list(results) ->
# snmpkit returns a list of maps, convert to OID -> value map
walked_data =
Map.new(results, fn %{oid: oid, value: value} -> {oid, value} end)
{:ok, walked_data}
{:error, reason} = error ->
Logger.warning("SNMP WALK failed for #{inspect(start_oid)}: #{inspect(reason)}")
error
end
end
@doc """
Performs an SNMP GET-BULK operation for efficient retrieval of multiple values.
Useful for retrieving table data.
## Examples
iex> get_bulk([ip: "192.168.1.1", community: "public", version: "2c"],
...> "1.3.6.1.2.1.2.2.1.2", max_repetitions: 10)
{:ok, %{"1.3.6.1.2.1.2.2.1.2.1" => "Ethernet0", ...}}
"""
@spec get_bulk(connection_opts(), oid(), keyword()) ::
{:ok, %{String.t() => snmp_value()}} | {:error, term()}
def get_bulk(opts, start_oid, bulk_opts \\ []) do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
max_repetitions = Keyword.get(bulk_opts, :max_repetitions, 10)
snmp_opts = Keyword.put(snmp_opts, :max_repetitions, max_repetitions)
case SnmpKit.get_bulk(target, start_oid, snmp_opts) do
{:ok, results} when is_list(results) ->
# snmpkit returns a list of maps, convert to OID -> value map
bulk_data =
Map.new(results, fn %{oid: oid, value: value} -> {oid, value} end)
{:ok, bulk_data}
{:error, reason} = error ->
Logger.warning("SNMP GET-BULK failed for #{inspect(start_oid)}: #{inspect(reason)}")
error
end
end
@doc """
Tests connectivity to an SNMP agent by retrieving sysUpTime.
## Examples
iex> test_connection([ip: "192.168.1.1", community: "public", version: "2c"])
{:ok, "Connection successful"}
iex> test_connection([ip: "192.168.1.99", community: "wrong", version: "2c"])
{:error, :timeout}
"""
@spec test_connection(connection_opts()) :: {:ok, String.t()} | {:error, term()}
def test_connection(opts) do
# Try to get sysUpTime (1.3.6.1.2.1.1.3.0) as a connectivity test
case get(opts, "1.3.6.1.2.1.1.3.0") do
{:ok, _uptime} ->
{:ok, "Connection successful"}
{:error, reason} = error ->
Logger.warning("SNMP connection test failed: #{inspect(reason)}")
error
end
end
# Private functions
defp build_target(opts) do
Keyword.fetch!(opts, :ip)
end
defp build_snmp_opts(opts) do
version = parse_version(Keyword.fetch!(opts, :version))
community = Keyword.fetch!(opts, :community)
port = Keyword.get(opts, :port, 161)
timeout = Keyword.get(opts, :timeout, @default_timeout)
[
community: community,
version: version,
port: port,
timeout: timeout,
retries: @default_retries
]
end
defp parse_version("1"), do: :v1
defp parse_version("2c"), do: :v2c
defp parse_version(:v1), do: :v1
defp parse_version(:v2c), do: :v2c
defp parse_version(_other), do: :v2c
# Extract value from snmpkit's typed responses
defp extract_snmp_value({:octet_string, value}), do: value
defp extract_snmp_value({:integer, value}), do: value
defp extract_snmp_value({:counter32, value}), do: value
defp extract_snmp_value({:counter64, value}), do: value
defp extract_snmp_value({:gauge32, value}), do: value
defp extract_snmp_value({:timeticks, value}), do: value
defp extract_snmp_value({:ip_address, value}), do: value
defp extract_snmp_value({:object_identifier, value}), do: value
defp extract_snmp_value({:opaque, value}), do: value
# Handle map responses from snmpkit (with :value key)
defp extract_snmp_value(%{value: value}), do: value
# Fallback for unknown types
defp extract_snmp_value(value), do: value
end

View file

@ -0,0 +1,47 @@
defmodule Towerops.Snmp.Device do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_devices" do
field :sys_descr, :string
field :sys_object_id, :string
field :sys_name, :string
field :sys_uptime, :integer
field :sys_contact, :string
field :sys_location, :string
field :manufacturer, :string
field :model, :string
field :firmware_version, :string
belongs_to :equipment, Towerops.Equipment.Equipment
has_many :interfaces, Towerops.Snmp.Interface, foreign_key: :snmp_device_id
has_many :sensors, Towerops.Snmp.Sensor, foreign_key: :snmp_device_id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(device, attrs) do
device
|> cast(attrs, [
:equipment_id,
:sys_descr,
:sys_object_id,
:sys_name,
:sys_uptime,
:sys_contact,
:sys_location,
:manufacturer,
:model,
:firmware_version
])
|> validate_required([:equipment_id])
|> unique_constraint(:equipment_id)
|> foreign_key_constraint(:equipment_id)
end
end

View file

@ -0,0 +1,305 @@
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.Profiles.Base
alias Towerops.Snmp.Profiles.Cisco
alias Towerops.Snmp.Profiles.Mikrotik
alias Towerops.Snmp.Profiles.NetSnmp
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) do
update_equipment_discovery_time(equipment)
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
{: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
|> where([e], e.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
@spec select_profile(system_info()) :: profile()
defp select_profile(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
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
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)
insert_interfaces(device, interfaces)
insert_sensors(device, sensors)
device
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.utc_now())
|> Repo.update()
end
end

View file

@ -0,0 +1,48 @@
defmodule Towerops.Snmp.Interface do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_interfaces" do
field :if_index, :integer
field :if_name, :string
field :if_descr, :string
field :if_alias, :string
field :if_type, :integer
field :if_speed, :integer
field :if_phys_address, :string
field :if_admin_status, :string
field :if_oper_status, :string
field :monitored, :boolean, default: true
belongs_to :snmp_device, Towerops.Snmp.Device
has_many :stats, Towerops.Snmp.InterfaceStat, foreign_key: :interface_id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(interface, attrs) do
interface
|> cast(attrs, [
:snmp_device_id,
:if_index,
:if_name,
:if_descr,
:if_alias,
:if_type,
:if_speed,
:if_phys_address,
:if_admin_status,
:if_oper_status,
:monitored
])
|> validate_required([:snmp_device_id, :if_index])
|> unique_constraint([:snmp_device_id, :if_index])
|> foreign_key_constraint(:snmp_device_id)
end
end

View file

@ -0,0 +1,39 @@
defmodule Towerops.Snmp.InterfaceStat do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_interface_stats" do
field :if_in_octets, :integer
field :if_out_octets, :integer
field :if_in_errors, :integer
field :if_out_errors, :integer
field :if_in_discards, :integer
field :if_out_discards, :integer
field :checked_at, :utc_datetime
belongs_to :interface, Towerops.Snmp.Interface
timestamps(type: :utc_datetime, updated_at: false)
end
@doc false
def changeset(stat, attrs) do
stat
|> cast(attrs, [
:interface_id,
:if_in_octets,
:if_out_octets,
:if_in_errors,
:if_out_errors,
:if_in_discards,
:if_out_discards,
:checked_at
])
|> validate_required([:interface_id, :checked_at])
|> foreign_key_constraint(:interface_id)
end
end

View file

@ -0,0 +1,61 @@
defmodule Towerops.Snmp.Poller do
@moduledoc """
SNMP polling functions for monitoring device reachability.
Instead of pinging, SNMP-enabled devices are checked by polling sysUpTime.0,
which provides both connectivity confirmation and device uptime information.
"""
@behaviour Towerops.Snmp.PollerBehaviour
alias Towerops.Snmp.Client
require Logger
@doc """
Checks if an SNMP device is reachable by querying sysUpTime.0.
Returns:
- `{:ok, response_time_ms}` if successful
- `{:error, reason}` if the check failed
## Examples
iex> check_device(ip: "192.168.1.1", community: "public", version: "2c", port: 161)
{:ok, 42.3}
iex> check_device(ip: "192.168.1.99", community: "wrong", version: "2c", port: 161)
{:error, :timeout}
"""
@impl true
@spec check_device(Client.connection_opts()) :: {:ok, float()} | {:error, term()}
def check_device(client_opts) do
start_time = System.monotonic_time(:millisecond)
case Client.get(client_opts, "1.3.6.1.2.1.1.3.0") do
{:ok, _uptime} ->
end_time = System.monotonic_time(:millisecond)
response_time = end_time - start_time
{:ok, response_time / 1.0}
{:error, reason} = error ->
Logger.debug("SNMP check failed: #{inspect(reason)}")
error
end
end
@doc """
Builds client options from equipment record.
"""
@impl true
@spec build_client_opts(map()) :: Client.connection_opts()
def 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
end

View file

@ -0,0 +1,10 @@
defmodule Towerops.Snmp.PollerBehaviour do
@moduledoc """
Behaviour for SNMP poller implementations.
"""
alias Towerops.Snmp.Client
@callback check_device(Client.connection_opts()) :: {:ok, number()} | {:error, term()}
@callback build_client_opts(map()) :: Client.connection_opts()
end

View file

@ -0,0 +1,328 @@
defmodule Towerops.Snmp.Profiles.Base do
@moduledoc """
Base SNMP device profile for generic device discovery.
Uses standard MIBs: SNMPv2-MIB, IF-MIB, ENTITY-MIB, ENTITY-SENSOR-MIB.
This profile works with any SNMP-capable device and discovers:
- System information (sysDescr, sysObjectID, sysName, etc.)
- Network interfaces from IF-MIB
- Generic sensors from ENTITY-SENSOR-MIB (if available)
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
require Logger
# Standard MIB OIDs
@system_oids %{
# SNMPv2-MIB::system group
sys_descr: "1.3.6.1.2.1.1.1.0",
sys_object_id: "1.3.6.1.2.1.1.2.0",
sys_uptime: "1.3.6.1.2.1.1.3.0",
sys_contact: "1.3.6.1.2.1.1.4.0",
sys_name: "1.3.6.1.2.1.1.5.0",
sys_location: "1.3.6.1.2.1.1.6.0"
}
@interface_oids %{
# IF-MIB::ifTable - Standard interface table
if_index: "1.3.6.1.2.1.2.2.1.1",
if_descr: "1.3.6.1.2.1.2.2.1.2",
if_type: "1.3.6.1.2.1.2.2.1.3",
if_speed: "1.3.6.1.2.1.2.2.1.5",
if_phys_address: "1.3.6.1.2.1.2.2.1.6",
if_admin_status: "1.3.6.1.2.1.2.2.1.7",
if_oper_status: "1.3.6.1.2.1.2.2.1.8",
# IF-MIB::ifXTable - Extended interface table (64-bit counters, names)
if_name: "1.3.6.1.2.1.31.1.1.1.1",
if_alias: "1.3.6.1.2.1.31.1.1.1.18"
}
@entity_sensor_oids %{
# ENTITY-SENSOR-MIB - Generic sensor support
ent_phys_sensor_type: "1.3.6.1.2.1.99.1.1.1.1",
ent_phys_sensor_scale: "1.3.6.1.2.1.99.1.1.1.2",
ent_phys_sensor_value: "1.3.6.1.2.1.99.1.1.1.4",
ent_phys_sensor_oper_status: "1.3.6.1.2.1.99.1.1.1.5"
}
@doc """
Discovers system information from SNMPv2-MIB.
Returns a map with device metadata.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(client_opts) do
oids = Map.values(@system_oids)
case Client.get_multiple(client_opts, oids) do
{:ok, values} ->
system_info =
@system_oids
|> Map.keys()
|> Enum.zip(values)
|> Map.new()
|> parse_system_info()
{:ok, system_info}
{:error, reason} = error ->
Logger.error("Failed to discover system info: #{inspect(reason)}")
error
end
end
@doc """
Discovers network interfaces from IF-MIB.
Returns a list of interface maps.
"""
@spec discover_interfaces(Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(client_opts) do
with {:ok, if_indices} <- Client.walk(client_opts, @interface_oids.if_index) do
# Extract interface indices
indices = if_indices |> Map.values() |> Enum.map(&parse_integer/1)
# Fetch all interface data in parallel
interface_data =
indices
|> Task.async_stream(
fn index -> fetch_interface_data(client_opts, index) end,
max_concurrency: 10,
timeout: 10_000
)
|> Enum.map(fn
{:ok, {:ok, interface}} -> interface
_ -> nil
end)
|> Enum.reject(&is_nil/1)
{:ok, interface_data}
end
end
@doc """
Discovers sensors from ENTITY-SENSOR-MIB.
Returns a list of sensor maps.
"""
@spec discover_sensors(Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
def discover_sensors(client_opts) do
# Try to walk the sensor type OID to see if device supports ENTITY-SENSOR-MIB
case Client.walk(client_opts, @entity_sensor_oids.ent_phys_sensor_type) do
{:ok, sensor_types} when map_size(sensor_types) > 0 ->
# Device supports ENTITY-SENSOR-MIB
sensor_indices = extract_indices_from_oids(sensor_types)
sensors =
sensor_indices
|> Enum.map(fn index ->
build_sensor_from_entity_mib(client_opts, index)
end)
|> Enum.reject(&is_nil/1)
{:ok, sensors}
_ ->
# Device doesn't support ENTITY-SENSOR-MIB, return empty list
Logger.debug("Device does not support ENTITY-SENSOR-MIB")
{:ok, []}
end
end
@doc """
Identifies device manufacturer and model from sysDescr and sysObjectID.
Can be overridden by vendor-specific profiles.
"""
@spec identify_device(Discovery.system_info()) :: Discovery.device_info()
def identify_device(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
sys_object_id = Map.get(system_info, :sys_object_id, "")
{manufacturer, model} = parse_sys_descr(sys_descr, sys_object_id)
Map.merge(system_info, %{
manufacturer: manufacturer,
model: model
})
end
# Private functions
defp parse_system_info(raw_info) do
%{
sys_descr: Map.get(raw_info, :sys_descr),
sys_object_id: parse_oid(Map.get(raw_info, :sys_object_id)),
sys_name: Map.get(raw_info, :sys_name),
sys_uptime: parse_integer(Map.get(raw_info, :sys_uptime)),
sys_contact: Map.get(raw_info, :sys_contact),
sys_location: Map.get(raw_info, :sys_location)
}
end
defp parse_oid(oid) when is_list(oid) do
Enum.map_join(oid, ".", &to_string/1)
end
defp parse_oid(oid) when is_binary(oid), do: oid
defp parse_oid(_), do: ""
defp fetch_interface_data(client_opts, index) do
# Build OIDs for this specific interface index
oids = [
@interface_oids.if_descr <> ".#{index}",
@interface_oids.if_type <> ".#{index}",
@interface_oids.if_speed <> ".#{index}",
@interface_oids.if_phys_address <> ".#{index}",
@interface_oids.if_admin_status <> ".#{index}",
@interface_oids.if_oper_status <> ".#{index}",
@interface_oids.if_name <> ".#{index}",
@interface_oids.if_alias <> ".#{index}"
]
case Client.get_multiple(client_opts, oids) do
{:ok, [if_descr, if_type, if_speed, if_phys_addr, if_admin, if_oper, if_name, if_alias]} ->
{:ok,
%{
if_index: index,
if_descr: if_descr,
if_name: if_name,
if_alias: if_alias,
if_type: parse_integer(if_type),
if_speed: parse_integer(if_speed),
if_phys_address: format_mac_address(if_phys_addr),
if_admin_status: parse_if_status(if_admin),
if_oper_status: parse_if_status(if_oper)
}}
{:error, _} = error ->
error
end
end
defp build_sensor_from_entity_mib(client_opts, index) do
oids = [
@entity_sensor_oids.ent_phys_sensor_type <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_scale <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_value <> ".#{index}",
@entity_sensor_oids.ent_phys_sensor_oper_status <> ".#{index}"
]
case Client.get_multiple(client_opts, oids) do
{:ok, [type, scale, value, status]} ->
%{
sensor_type: parse_sensor_type(type),
sensor_index: "#{index}",
sensor_oid: @entity_sensor_oids.ent_phys_sensor_value <> ".#{index}",
sensor_descr: "Sensor #{index}",
sensor_unit: sensor_type_to_unit(parse_sensor_type(type)),
sensor_divisor: scale_to_divisor(scale),
last_value: parse_float(value),
status: parse_sensor_status(status)
}
_ ->
nil
end
end
defp extract_indices_from_oids(oid_map) do
oid_map
|> Map.keys()
|> Enum.map(fn oid_string ->
# Extract the last part of the OID which is the index
oid_string
|> String.split(".")
|> List.last()
|> String.to_integer()
end)
end
defp parse_sys_descr(sys_descr, _sys_object_id) do
cond do
String.contains?(sys_descr, ["Cisco", "IOS"]) ->
{"Cisco", extract_cisco_model(sys_descr)}
String.contains?(sys_descr, "Linux") ->
{"Linux", "Server"}
String.contains?(sys_descr, "Windows") ->
{"Microsoft", "Windows Server"}
true ->
{"Unknown", "Generic Device"}
end
end
defp extract_cisco_model(sys_descr) do
# Try to extract model from sysDescr
# Example: "Cisco IOS Software, C2960 Software..."
case Regex.run(~r/\b(C\d+\w*|WS-C\d+\w*)\b/, sys_descr) do
[_, model] -> model
_ -> "Unknown Model"
end
end
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(value) when is_binary(value), do: String.to_integer(value)
defp parse_integer(_), do: nil
defp parse_float(value) when is_float(value), do: value
defp parse_float(value) when is_integer(value), do: value / 1.0
defp parse_float(value) when is_binary(value), do: String.to_float(value)
defp parse_float(_), do: nil
defp parse_if_status(1), do: "up"
defp parse_if_status(2), do: "down"
defp parse_if_status(3), do: "testing"
defp parse_if_status(_), do: "unknown"
defp format_mac_address(<<>>) when is_binary(<<>>), do: nil
defp format_mac_address(nil), do: nil
defp format_mac_address(mac) when is_binary(mac) do
mac
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
defp format_mac_address(_), do: nil
# ENTITY-SENSOR-MIB sensor types
defp parse_sensor_type(1), do: "other"
defp parse_sensor_type(2), do: "unknown"
defp parse_sensor_type(3), do: "volts"
defp parse_sensor_type(4), do: "volts"
defp parse_sensor_type(5), do: "amperes"
defp parse_sensor_type(6), do: "watts"
defp parse_sensor_type(7), do: "hertz"
defp parse_sensor_type(8), do: "celsius"
defp parse_sensor_type(9), do: "percent"
defp parse_sensor_type(10), do: "rpm"
defp parse_sensor_type(11), do: "cmm"
defp parse_sensor_type(_), do: "unknown"
defp sensor_type_to_unit("celsius"), do: "°C"
defp sensor_type_to_unit("volts"), do: "V"
defp sensor_type_to_unit("amperes"), do: "A"
defp sensor_type_to_unit("watts"), do: "W"
defp sensor_type_to_unit("hertz"), do: "Hz"
defp sensor_type_to_unit("percent"), do: "%"
defp sensor_type_to_unit("rpm"), do: "RPM"
defp sensor_type_to_unit(_), do: ""
# ENTITY-SENSOR-MIB scale values (10^scale)
defp scale_to_divisor(-24), do: 1_000_000_000_000_000_000_000_000
defp scale_to_divisor(-9), do: 1_000_000_000
defp scale_to_divisor(-3), do: 1_000
defp scale_to_divisor(0), do: 1
defp scale_to_divisor(_), do: 1
defp parse_sensor_status(1), do: "ok"
defp parse_sensor_status(2), do: "unavailable"
defp parse_sensor_status(3), do: "nonoperational"
defp parse_sensor_status(_), do: "unknown"
end

View file

@ -0,0 +1,247 @@
defmodule Towerops.Snmp.Profiles.Cisco do
@moduledoc """
Cisco-specific SNMP device profile.
Extends Base profile with Cisco-specific sensor discovery from CISCO-ENTITY-SENSOR-MIB.
Discovers additional sensors:
- Temperature sensors (chassis, modules, power supplies)
- Power supply sensors
- Fan speed sensors
- Voltage sensors
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Profiles.Base
require Logger
@cisco_sensor_oids %{
# CISCO-ENTITY-SENSOR-MIB
ent_sensor_type: "1.3.6.1.4.1.9.9.91.1.1.1.1.1",
ent_sensor_scale: "1.3.6.1.4.1.9.9.91.1.1.1.1.2",
ent_sensor_precision: "1.3.6.1.4.1.9.9.91.1.1.1.1.3",
ent_sensor_value: "1.3.6.1.4.1.9.9.91.1.1.1.1.4",
ent_sensor_status: "1.3.6.1.4.1.9.9.91.1.1.1.1.5",
# ENTITY-MIB for physical descriptions
ent_phys_descr: "1.3.6.1.2.1.47.1.1.1.1.2",
ent_phys_name: "1.3.6.1.2.1.47.1.1.1.1.7"
}
@doc """
Discovers system information using Base profile.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(client_opts) do
Base.discover_system_info(client_opts)
end
@doc """
Discovers network interfaces using Base profile.
"""
@spec discover_interfaces(Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(client_opts) do
Base.discover_interfaces(client_opts)
end
@doc """
Discovers sensors from both standard ENTITY-SENSOR-MIB and CISCO-ENTITY-SENSOR-MIB.
Cisco-specific sensors provide more detailed temperature, power, and fan monitoring.
"""
@spec discover_sensors(Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
def discover_sensors(client_opts) do
# Try Cisco-specific sensors first
case discover_cisco_sensors(client_opts) do
{:ok, [_ | _] = sensors} ->
{:ok, sensors}
_ ->
# Fall back to standard ENTITY-SENSOR-MIB
Logger.debug("Cisco-specific sensors not available, using standard discovery")
Base.discover_sensors(client_opts)
end
end
@doc """
Identifies Cisco device model more accurately from sysDescr.
"""
@spec identify_device(Discovery.system_info()) :: Discovery.device_info()
def identify_device(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
model = extract_cisco_model_detailed(sys_descr)
Map.merge(system_info, %{
manufacturer: "Cisco",
model: model,
firmware_version: extract_firmware_version(sys_descr)
})
end
# Private functions
defp discover_cisco_sensors(client_opts) do
case Client.walk(client_opts, @cisco_sensor_oids.ent_sensor_type) do
{:ok, sensor_types} when map_size(sensor_types) > 0 ->
sensor_indices = extract_indices_from_oids(sensor_types)
sensors =
sensor_indices
|> Enum.map(fn index ->
build_cisco_sensor(client_opts, index)
end)
|> Enum.reject(&is_nil/1)
{:ok, sensors}
_ ->
{:error, :cisco_sensors_not_available}
end
end
defp build_cisco_sensor(client_opts, index) do
oids = [
@cisco_sensor_oids.ent_sensor_type <> ".#{index}",
@cisco_sensor_oids.ent_sensor_scale <> ".#{index}",
@cisco_sensor_oids.ent_sensor_precision <> ".#{index}",
@cisco_sensor_oids.ent_sensor_value <> ".#{index}",
@cisco_sensor_oids.ent_sensor_status <> ".#{index}",
@cisco_sensor_oids.ent_phys_descr <> ".#{index}",
@cisco_sensor_oids.ent_phys_name <> ".#{index}"
]
case Client.get_multiple(client_opts, oids) do
{:ok, [type, scale, precision, value, status, descr, name]} ->
sensor_type = parse_cisco_sensor_type(type)
divisor = calculate_divisor(scale, precision)
%{
sensor_type: sensor_type,
sensor_index: "#{index}",
sensor_oid: @cisco_sensor_oids.ent_sensor_value <> ".#{index}",
sensor_descr: parse_sensor_description(descr, name),
sensor_unit: cisco_sensor_type_to_unit(sensor_type),
sensor_divisor: divisor,
last_value: parse_sensor_value(value, divisor),
status: parse_cisco_sensor_status(status)
}
{:error, reason} ->
Logger.debug("Failed to fetch Cisco sensor #{index}: #{inspect(reason)}")
nil
end
end
defp extract_indices_from_oids(oid_map) do
oid_map
|> Map.keys()
|> Enum.map(fn oid_string ->
oid_string
|> String.split(".")
|> List.last()
|> String.to_integer()
end)
end
defp extract_cisco_model_detailed(sys_descr) do
cond do
# Catalyst switches: "Cisco IOS Software, C2960 Software..."
match = Regex.run(~r/\b(C\d+\w*|WS-C\d+\w*)\b/, sys_descr) ->
[_, model] = match
model
# ISR routers: "Cisco IOS Software, 1900 Software..."
match = Regex.run(~r/\b(ISR\d+\w*|[0-9]{4})\b/, sys_descr) ->
[_, model] = match
model
# ASR routers
match = Regex.run(~r/\b(ASR\d+\w*)\b/, sys_descr) ->
[_, model] = match
model
true ->
"Unknown Cisco Model"
end
end
defp extract_firmware_version(sys_descr) do
# Try to extract version like "Version 15.2(4)E7"
case Regex.run(~r/Version\s+([\d.()A-Za-z]+)/, sys_descr) do
[_, version] -> version
_ -> nil
end
end
# CISCO-ENTITY-SENSOR-MIB sensor types
defp parse_cisco_sensor_type(1), do: "other"
defp parse_cisco_sensor_type(2), do: "unknown"
defp parse_cisco_sensor_type(3), do: "volts_ac"
defp parse_cisco_sensor_type(4), do: "volts_dc"
defp parse_cisco_sensor_type(5), do: "amperes"
defp parse_cisco_sensor_type(6), do: "watts"
defp parse_cisco_sensor_type(7), do: "hertz"
defp parse_cisco_sensor_type(8), do: "celsius"
defp parse_cisco_sensor_type(9), do: "percent_rh"
defp parse_cisco_sensor_type(10), do: "rpm"
defp parse_cisco_sensor_type(11), do: "cmm"
defp parse_cisco_sensor_type(12), do: "truthvalue"
defp parse_cisco_sensor_type(13), do: "specialenum"
defp parse_cisco_sensor_type(14), do: "dBm"
defp parse_cisco_sensor_type(_), do: "unknown"
defp cisco_sensor_type_to_unit("celsius"), do: "°C"
defp cisco_sensor_type_to_unit("volts_ac"), do: "VAC"
defp cisco_sensor_type_to_unit("volts_dc"), do: "VDC"
defp cisco_sensor_type_to_unit("amperes"), do: "A"
defp cisco_sensor_type_to_unit("watts"), do: "W"
defp cisco_sensor_type_to_unit("hertz"), do: "Hz"
defp cisco_sensor_type_to_unit("percent_rh"), do: "%RH"
defp cisco_sensor_type_to_unit("rpm"), do: "RPM"
defp cisco_sensor_type_to_unit("dBm"), do: "dBm"
defp cisco_sensor_type_to_unit(_), do: ""
# Calculate divisor from scale and precision
# Scale: 10^scale (e.g., -3 = milli, 0 = units)
# Precision: number of decimal places
defp calculate_divisor(scale, _precision) when is_integer(scale) do
case scale do
-24 -> 1_000_000_000_000_000_000_000_000
-12 -> 1_000_000_000_000
-9 -> 1_000_000_000
-6 -> 1_000_000
-3 -> 1_000
0 -> 1
3 -> 0.001
_ -> 1
end
end
defp calculate_divisor(_, _), do: 1
defp parse_sensor_value(value, divisor) when is_integer(value) and is_number(divisor) do
value / divisor
end
defp parse_sensor_value(_, _), do: nil
defp parse_sensor_description(descr, _name) when is_binary(descr) and byte_size(descr) > 0 do
descr
end
defp parse_sensor_description(_descr, name) when is_binary(name) and byte_size(name) > 0 do
name
end
defp parse_sensor_description(_, _), do: "Unknown Sensor"
# CISCO-ENTITY-SENSOR-MIB sensor status
defp parse_cisco_sensor_status(1), do: "ok"
defp parse_cisco_sensor_status(2), do: "unavailable"
defp parse_cisco_sensor_status(3), do: "nonoperational"
defp parse_cisco_sensor_status(_), do: "unknown"
end

View file

@ -0,0 +1,280 @@
defmodule Towerops.Snmp.Profiles.Mikrotik do
@moduledoc """
MikroTik RouterOS device profile.
Extends Base profile with MikroTik-specific monitoring via MIKROTIK-MIB.
Discovers and monitors:
- RouterBoard hardware information
- Health sensors (temperature, voltage, current, fan speed)
- CPU and memory usage
- Power supply status
- Wireless interfaces (if present)
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Profiles.Base
require Logger
# MikroTik enterprise OID: 1.3.6.1.4.1.14988
@mikrotik_oids %{
# System Information (mtxrSystem)
serial_number: "1.3.6.1.4.1.14988.1.1.7.3.0",
firmware_version: "1.3.6.1.4.1.14988.1.1.7.4.0",
license_version: "1.3.6.1.4.1.14988.1.1.4.4.0",
# Health (mtxrHealth)
temperature: "1.3.6.1.4.1.14988.1.1.3.10.0",
cpu_temperature: "1.3.6.1.4.1.14988.1.1.3.11.0",
voltage: "1.3.6.1.4.1.14988.1.1.3.8.0",
current: "1.3.6.1.4.1.14988.1.1.3.9.0",
power_supply_state: "1.3.6.1.4.1.14988.1.1.3.12.0",
fan_speed_1: "1.3.6.1.4.1.14988.1.1.3.17.0",
fan_speed_2: "1.3.6.1.4.1.14988.1.1.3.18.0",
# System Resources
cpu_load: "1.3.6.1.2.1.25.3.3.1.2.1",
total_memory: "1.3.6.1.2.1.25.2.2.0",
used_memory: "1.3.6.1.4.1.14988.1.1.1.2.0",
total_hdd: "1.3.6.1.4.1.14988.1.1.1.6.0",
used_hdd: "1.3.6.1.4.1.14988.1.1.1.7.0"
}
@doc """
Discovers system information using Base profile and adds MikroTik-specific details.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(client_opts) do
with {:ok, base_info} <- Base.discover_system_info(client_opts),
{:ok, mikrotik_info} <- discover_mikrotik_system_info(client_opts) do
{:ok, Map.merge(base_info, mikrotik_info)}
end
end
@doc """
Discovers network interfaces using Base profile.
"""
@spec discover_interfaces(Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(client_opts) do
Base.discover_interfaces(client_opts)
end
@doc """
Discovers MikroTik health sensors and system resources.
Returns comprehensive sensor list including temperature, voltage, current, fans, CPU, memory, disk.
"""
@spec discover_sensors(Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
def discover_sensors(client_opts) do
Logger.debug("Discovering MikroTik sensors...")
health_sensors = discover_health_sensors(client_opts)
resource_sensors = discover_resource_sensors(client_opts)
all_sensors = health_sensors ++ resource_sensors
if length(all_sensors) > 0 do
Logger.debug("Discovered #{length(all_sensors)} MikroTik sensors")
{:ok, all_sensors}
else
Logger.warning("No MikroTik sensors found")
{:ok, []}
end
end
@doc """
Identifies MikroTik device from sysDescr.
"""
@spec identify_device(Discovery.system_info()) :: Discovery.device_info()
def identify_device(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
# Extract model from sysDescr (e.g., "RouterOS RB5009UG+S+")
model =
case Regex.run(~r/RouterOS\s+([A-Z0-9\+\-]+)/, sys_descr) do
[_, model] -> model
_ -> "RouterOS Device"
end
firmware = Map.get(system_info, :firmware_version) || extract_firmware_from_sysdescr(sys_descr)
Map.merge(system_info, %{
manufacturer: "MikroTik",
model: model,
firmware_version: firmware
})
end
# Private functions
defp discover_mikrotik_system_info(client_opts) do
oids = [
@mikrotik_oids.serial_number,
@mikrotik_oids.firmware_version,
@mikrotik_oids.license_version
]
case Client.get_multiple(client_opts, oids) do
{:ok, [serial, firmware, license]} ->
{:ok,
%{
serial_number: serial,
firmware_version: firmware,
license_version: license
}}
{:error, _} ->
# MikroTik-specific OIDs not available or partially available
{:ok, %{}}
end
end
defp discover_health_sensors(client_opts) do
# Try to get all health OIDs
health_oids = [
{@mikrotik_oids.temperature, "temperature", "°C", 10},
{@mikrotik_oids.cpu_temperature, "cpu_temperature", "°C", 10},
{@mikrotik_oids.voltage, "voltage", "V", 10},
{@mikrotik_oids.current, "current", "mA", 1},
{@mikrotik_oids.fan_speed_1, "fan1", "RPM", 1},
{@mikrotik_oids.fan_speed_2, "fan2", "RPM", 1}
]
health_oids
|> Enum.map(fn {oid, type, unit, divisor} ->
case Client.get(client_opts, oid) do
{:ok, value} when is_integer(value) and value > 0 ->
%{
sensor_type: type,
sensor_index: type,
sensor_oid: oid,
sensor_descr: format_sensor_name(type),
sensor_unit: unit,
sensor_divisor: divisor,
last_value: value / divisor,
status: "ok"
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp discover_resource_sensors(client_opts) do
cpu_sensors = discover_cpu_sensors(client_opts)
storage_sensors = discover_storage_sensors(client_opts)
cpu_sensors ++ storage_sensors
end
defp discover_cpu_sensors(client_opts) do
# Walk hrProcessorLoad (1.3.6.1.2.1.25.3.3.1.2) to get CPU load per core
case Client.walk(client_opts, "1.3.6.1.2.1.25.3.3.1.2") do
{:ok, results} ->
Enum.map(results, fn {oid, load} ->
index = oid |> String.split(".") |> List.last()
%{
sensor_type: "cpu_load",
sensor_index: "cpu#{index}",
sensor_oid: oid,
sensor_descr: "CPU #{index} Load",
sensor_unit: "%",
sensor_divisor: 1,
last_value: load / 1.0,
status: cpu_status(load)
}
end)
_ ->
[]
end
end
defp discover_storage_sensors(client_opts) do
# Walk hrStorageTable to get memory and disk usage
with {:ok, descr_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.3"),
{:ok, size_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.5"),
{:ok, used_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.6") do
# Get indices
indices =
descr_results
|> Map.keys()
|> Enum.map(fn oid -> oid |> String.split(".") |> List.last() end)
indices
|> Enum.map(fn index ->
descr_oid = "1.3.6.1.2.1.25.2.3.1.3.#{index}"
size_oid = "1.3.6.1.2.1.25.2.3.1.5.#{index}"
used_oid = "1.3.6.1.2.1.25.2.3.1.6.#{index}"
descr = Map.get(descr_results, descr_oid, "")
size = Map.get(size_results, size_oid, 0)
used = Map.get(used_results, used_oid, 0)
if size > 0 do
percent = used / size * 100
{type, descr_name} =
cond do
String.contains?(descr, "memory") -> {"memory", "Memory"}
String.contains?(descr, "disk") -> {"disk", "Disk"}
true -> {"storage", descr}
end
%{
sensor_type: "#{type}_usage",
sensor_index: index,
sensor_oid: used_oid,
sensor_descr: "#{descr_name} Usage",
sensor_unit: "%",
sensor_divisor: 1,
last_value: percent,
status: storage_status(type, percent)
}
end
end)
|> Enum.reject(&is_nil/1)
else
_ -> []
end
end
defp format_sensor_name("temperature"), do: "Board Temperature"
defp format_sensor_name("cpu_temperature"), do: "CPU Temperature"
defp format_sensor_name("voltage"), do: "Input Voltage"
defp format_sensor_name("current"), do: "Current Draw"
defp format_sensor_name("fan1"), do: "Fan 1 Speed"
defp format_sensor_name("fan2"), do: "Fan 2 Speed"
defp format_sensor_name(other), do: String.capitalize(other)
defp cpu_status(load) when load < 70, do: "ok"
defp cpu_status(load) when load < 90, do: "warning"
defp cpu_status(_), do: "critical"
defp storage_status("memory", percent) when percent < 80, do: "ok"
defp storage_status("memory", percent) when percent < 95, do: "warning"
defp storage_status("memory", _), do: "critical"
defp storage_status("disk", percent) when percent < 85, do: "ok"
defp storage_status("disk", percent) when percent < 95, do: "warning"
defp storage_status("disk", _), do: "critical"
defp storage_status(_, percent) when percent < 85, do: "ok"
defp storage_status(_, percent) when percent < 95, do: "warning"
defp storage_status(_, _), do: "critical"
defp extract_firmware_from_sysdescr(sys_descr) do
# Try to extract version from sysDescr
# Example: "RouterOS RB5009UG+S+ (stable) 7.13.2"
case Regex.run(~r/RouterOS.*?(\d+\.\d+(?:\.\d+)?)/, sys_descr) do
[_, version] -> version
_ -> nil
end
end
end

View file

@ -0,0 +1,297 @@
defmodule Towerops.Snmp.Profiles.NetSnmp do
@moduledoc """
Net-SNMP device profile for Linux/Unix servers.
Extends Base profile with Net-SNMP specific sensor discovery from:
- LM-SENSORS-MIB (lm-sensors hardware monitoring)
- UCD-SNMP-MIB (system statistics)
Discovers:
- CPU temperature, fan speed, voltage from lm-sensors
- System load, memory, disk usage from UCD-SNMP-MIB
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Profiles.Base
require Logger
@lm_sensors_oids %{
# LM-SENSORS-MIB
lm_temp_sensors_device: "1.3.6.1.4.1.2021.13.16.2.1.2",
lm_temp_sensors_value: "1.3.6.1.4.1.2021.13.16.2.1.3",
lm_fans_device: "1.3.6.1.4.1.2021.13.16.3.1.2",
lm_fans_value: "1.3.6.1.4.1.2021.13.16.3.1.3",
lm_voltage_device: "1.3.6.1.4.1.2021.13.16.4.1.2",
lm_voltage_value: "1.3.6.1.4.1.2021.13.16.4.1.3"
}
@ucd_snmp_oids %{
# UCD-SNMP-MIB system statistics
load_average_1: "1.3.6.1.4.1.2021.10.1.3.1",
load_average_5: "1.3.6.1.4.1.2021.10.1.3.2",
load_average_15: "1.3.6.1.4.1.2021.10.1.3.3",
mem_total: "1.3.6.1.4.1.2021.4.5.0",
mem_available: "1.3.6.1.4.1.2021.4.6.0",
mem_buffer: "1.3.6.1.4.1.2021.4.14.0",
mem_cached: "1.3.6.1.4.1.2021.4.15.0"
}
@doc """
Discovers system information using Base profile.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(client_opts) do
Base.discover_system_info(client_opts)
end
@doc """
Discovers network interfaces using Base profile.
"""
@spec discover_interfaces(Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(client_opts) do
Base.discover_interfaces(client_opts)
end
@doc """
Discovers sensors from LM-SENSORS-MIB and UCD-SNMP-MIB.
Combines hardware sensors (temperature, fans, voltage) with system stats (load, memory).
"""
def discover_sensors(client_opts) do
lm_sensors = discover_lm_sensors(client_opts)
ucd_sensors = discover_ucd_sensors(client_opts)
all_sensors = lm_sensors ++ ucd_sensors
if length(all_sensors) > 0 do
{:ok, all_sensors}
else
# Fall back to standard ENTITY-SENSOR-MIB
Logger.debug("Net-SNMP specific sensors not available, using standard discovery")
Base.discover_sensors(client_opts)
end
end
@doc """
Identifies Linux/Unix device from sysDescr.
"""
def identify_device(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
{os, version} = extract_linux_info(sys_descr)
Map.merge(system_info, %{
manufacturer: "Linux",
model: os,
firmware_version: version
})
end
# Private functions
defp discover_lm_sensors(client_opts) do
temp_sensors = discover_temperature_sensors(client_opts)
fan_sensors = discover_fan_sensors(client_opts)
voltage_sensors = discover_voltage_sensors(client_opts)
temp_sensors ++ fan_sensors ++ voltage_sensors
end
defp discover_temperature_sensors(client_opts) do
with {:ok, devices} <- Client.walk(client_opts, @lm_sensors_oids.lm_temp_sensors_device),
{:ok, values} <- Client.walk(client_opts, @lm_sensors_oids.lm_temp_sensors_value) do
build_lm_sensors(devices, values, "celsius", "°C")
else
_ -> []
end
end
defp discover_fan_sensors(client_opts) do
with {:ok, devices} <- Client.walk(client_opts, @lm_sensors_oids.lm_fans_device),
{:ok, values} <- Client.walk(client_opts, @lm_sensors_oids.lm_fans_value) do
build_lm_sensors(devices, values, "rpm", "RPM")
else
_ -> []
end
end
defp discover_voltage_sensors(client_opts) do
with {:ok, devices} <- Client.walk(client_opts, @lm_sensors_oids.lm_voltage_device),
{:ok, values} <- Client.walk(client_opts, @lm_sensors_oids.lm_voltage_value) do
build_lm_sensors(devices, values, "volts", "V")
else
_ -> []
end
end
defp build_lm_sensors(devices, values, sensor_type, unit) do
Enum.map(devices, fn {oid, device_name} ->
index = extract_last_oid_part(oid)
value_oid = find_matching_oid(values, index)
%{
sensor_type: sensor_type,
sensor_index: index,
sensor_oid: value_oid,
sensor_descr: device_name,
sensor_unit: unit,
sensor_divisor: divisor_for_type(sensor_type),
last_value: get_sensor_value(values, index),
status: "ok"
}
end)
end
defp discover_ucd_sensors(client_opts) do
load_sensors = discover_load_sensors(client_opts)
memory_sensors = discover_memory_sensors(client_opts)
load_sensors ++ memory_sensors
end
defp discover_load_sensors(client_opts) do
case Client.get_multiple(client_opts, [
@ucd_snmp_oids.load_average_1,
@ucd_snmp_oids.load_average_5,
@ucd_snmp_oids.load_average_15
]) do
{:ok, [load1, load5, load15]} ->
[
%{
sensor_type: "load",
sensor_index: "1min",
sensor_oid: @ucd_snmp_oids.load_average_1,
sensor_descr: "System Load (1 min)",
sensor_unit: "",
sensor_divisor: 100,
last_value: parse_float(load1),
status: "ok"
},
%{
sensor_type: "load",
sensor_index: "5min",
sensor_oid: @ucd_snmp_oids.load_average_5,
sensor_descr: "System Load (5 min)",
sensor_unit: "",
sensor_divisor: 100,
last_value: parse_float(load5),
status: "ok"
},
%{
sensor_type: "load",
sensor_index: "15min",
sensor_oid: @ucd_snmp_oids.load_average_15,
sensor_descr: "System Load (15 min)",
sensor_unit: "",
sensor_divisor: 100,
last_value: parse_float(load15),
status: "ok"
}
]
_ ->
[]
end
end
defp discover_memory_sensors(client_opts) do
case Client.get_multiple(client_opts, [
@ucd_snmp_oids.mem_total,
@ucd_snmp_oids.mem_available
]) do
{:ok, [total, available]} ->
total_kb = parse_integer(total)
available_kb = parse_integer(available)
used_kb = if total_kb && available_kb, do: total_kb - available_kb
used_percent =
if total_kb && total_kb > 0 && used_kb do
used_kb / total_kb * 100
end
[
%{
sensor_type: "memory",
sensor_index: "usage",
sensor_oid: @ucd_snmp_oids.mem_available,
sensor_descr: "Memory Usage",
sensor_unit: "%",
sensor_divisor: 1,
last_value: used_percent,
status: "ok"
}
]
_ ->
[]
end
end
defp extract_linux_info(sys_descr) do
cond do
String.contains?(sys_descr, "Ubuntu") ->
version = extract_version(sys_descr, ~r/Ubuntu[\/\s]+([\d.]+)/)
{"Ubuntu", version}
String.contains?(sys_descr, "Debian") ->
version = extract_version(sys_descr, ~r/Debian[\/\s]+([\d.]+)/)
{"Debian", version}
String.contains?(sys_descr, "CentOS") ->
version = extract_version(sys_descr, ~r/CentOS[\/\s]+([\d.]+)/)
{"CentOS", version}
String.contains?(sys_descr, "Red Hat") ->
version = extract_version(sys_descr, ~r/Red Hat[\/\s]+([\d.]+)/)
{"Red Hat Enterprise Linux", version}
String.contains?(sys_descr, "Linux") ->
version = extract_version(sys_descr, ~r/Linux[\/\s]+([\d.]+)/)
{"Linux", version}
true ->
{"Unix", nil}
end
end
defp extract_version(sys_descr, regex) do
case Regex.run(regex, sys_descr) do
[_, version] -> version
_ -> nil
end
end
defp extract_last_oid_part(oid_string) do
oid_string
|> String.split(".")
|> List.last()
end
defp find_matching_oid(values, index) do
values
|> Map.keys()
|> Enum.find(fn oid -> String.ends_with?(oid, ".#{index}") end)
end
defp get_sensor_value(values, index) do
oid = find_matching_oid(values, index)
value = Map.get(values, oid)
parse_float(value)
end
defp divisor_for_type("celsius"), do: 1000
defp divisor_for_type("volts"), do: 1000
defp divisor_for_type("rpm"), do: 1
defp divisor_for_type(_), do: 1
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(value) when is_binary(value), do: String.to_integer(value)
defp parse_integer(_), do: nil
defp parse_float(value) when is_float(value), do: value
defp parse_float(value) when is_integer(value), do: value / 1.0
defp parse_float(value) when is_binary(value), do: String.to_float(value)
defp parse_float(_), do: nil
end

View file

@ -0,0 +1,46 @@
defmodule Towerops.Snmp.Sensor do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_sensors" do
field :sensor_type, :string
field :sensor_index, :string
field :sensor_oid, :string
field :sensor_descr, :string
field :sensor_unit, :string
field :sensor_divisor, :integer, default: 1
field :monitored, :boolean, default: true
field :last_value, :float
field :last_checked_at, :utc_datetime
belongs_to :snmp_device, Towerops.Snmp.Device
has_many :readings, Towerops.Snmp.SensorReading, foreign_key: :sensor_id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(sensor, attrs) do
sensor
|> cast(attrs, [
:snmp_device_id,
:sensor_type,
:sensor_index,
:sensor_oid,
:sensor_descr,
:sensor_unit,
:sensor_divisor,
:monitored,
:last_value,
:last_checked_at
])
|> validate_required([:snmp_device_id, :sensor_type, :sensor_index, :sensor_oid])
|> unique_constraint([:snmp_device_id, :sensor_index])
|> foreign_key_constraint(:snmp_device_id)
end
end

View file

@ -0,0 +1,27 @@
defmodule Towerops.Snmp.SensorReading do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_sensor_readings" do
field :value, :float
field :status, :string
field :checked_at, :utc_datetime
belongs_to :sensor, Towerops.Snmp.Sensor
timestamps(type: :utc_datetime, updated_at: false)
end
@doc false
def changeset(reading, attrs) do
reading
|> cast(attrs, [:sensor_id, :value, :status, :checked_at])
|> validate_required([:sensor_id, :status, :checked_at])
|> validate_inclusion(:status, ["ok", "warning", "critical"])
|> foreign_key_constraint(:sensor_id)
end
end

View file

@ -5,6 +5,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do
alias Towerops.Equipment
alias Towerops.Equipment.Equipment, as: EquipmentSchema
alias Towerops.Sites
alias Towerops.Snmp
@impl true
def mount(params, _session, socket) do
@ -15,7 +16,8 @@ defmodule ToweropsWeb.EquipmentLive.Form do
socket
|> assign(:organization, organization)
|> assign(:available_sites, sites)
|> assign(:preselected_site_id, params["site_id"])}
|> assign(:preselected_site_id, params["site_id"])
|> assign(:snmp_test_result, nil)}
end
@impl true
@ -24,7 +26,13 @@ defmodule ToweropsWeb.EquipmentLive.Form do
end
defp apply_action(socket, :new, _params) do
equipment_attrs = %{monitoring_enabled: true, check_interval_seconds: 300}
equipment_attrs = %{
monitoring_enabled: true,
check_interval_seconds: 300,
snmp_enabled: true,
snmp_version: "2c",
snmp_port: 161
}
equipment_attrs =
if socket.assigns.preselected_site_id do
@ -66,12 +74,50 @@ defmodule ToweropsWeb.EquipmentLive.Form do
save_equipment(socket, socket.assigns.live_action, equipment_params)
end
@impl true
def handle_event("test_snmp", _params, socket) do
form_data = socket.assigns.form.data
params = socket.assigns.form.params
ip_address = params["ip_address"] || form_data.ip_address
snmp_community = params["snmp_community"] || form_data.snmp_community
snmp_version = params["snmp_version"] || form_data.snmp_version
snmp_port = params["snmp_port"] || form_data.snmp_port || 161
snmp_port =
case snmp_port do
port when is_integer(port) -> port
port when is_binary(port) -> String.to_integer(port)
_ -> 161
end
result =
case Snmp.test_connection(ip_address, snmp_community, snmp_version, snmp_port) do
{:ok, message} ->
%{success: true, message: message}
{:error, reason} ->
%{success: false, message: "Connection failed: #{inspect(reason)}"}
end
{:noreply, assign(socket, :snmp_test_result, result)}
end
defp save_equipment(socket, :new, equipment_params) do
case Equipment.create_equipment(equipment_params) do
{:ok, equipment} ->
# Start SNMP discovery in background if enabled
flash_message =
if equipment.snmp_enabled do
Task.start(fn -> Snmp.discover_equipment(equipment) end)
"Equipment created successfully. SNMP discovery started in background."
else
"Equipment created successfully"
end
{:noreply,
socket
|> put_flash(:info, "Equipment created successfully")
|> put_flash(:info, flash_message)
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
@ -80,11 +126,27 @@ defmodule ToweropsWeb.EquipmentLive.Form do
end
defp save_equipment(socket, :edit, equipment_params) do
case Equipment.update_equipment(socket.assigns.equipment, equipment_params) do
old_equipment = socket.assigns.equipment
case Equipment.update_equipment(old_equipment, equipment_params) do
{:ok, equipment} ->
# Start SNMP discovery if SNMP was just enabled or configuration changed
flash_message =
if equipment.snmp_enabled and
(!old_equipment.snmp_enabled or
equipment.snmp_community != old_equipment.snmp_community or
equipment.snmp_version != old_equipment.snmp_version or
equipment.snmp_port != old_equipment.snmp_port or
equipment.ip_address != old_equipment.ip_address) do
Task.start(fn -> Snmp.discover_equipment(equipment) end)
"Equipment updated successfully. SNMP discovery started in background."
else
"Equipment updated successfully"
end
{:noreply,
socket
|> put_flash(:info, "Equipment updated successfully")
|> put_flash(:info, flash_message)
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
{:error, %Ecto.Changeset{} = changeset} ->

View file

@ -35,6 +35,65 @@
<.input field={@form[:monitoring_enabled]} type="checkbox" label="Enable Monitoring" />
<div class="mt-8 border-t pt-6">
<h3 class="text-lg font-medium mb-4">SNMP Configuration</h3>
<p class="text-sm text-gray-600 mb-4">
Enable SNMP to discover device details, monitor sensors, and track interface statistics.
</p>
<.input field={@form[:snmp_enabled]} type="checkbox" label="Enable SNMP Monitoring" />
<div
:if={@form[:snmp_enabled].value}
class="mt-4 space-y-4 pl-6 border-l-2 border-blue-200"
>
<.input
field={@form[:snmp_version]}
type="select"
label="SNMP Version"
required
value={@form[:snmp_version].value || "2c"}
options={[{"SNMPv1", "1"}, {"SNMPv2c", "2c"}]}
/>
<.input
field={@form[:snmp_community]}
type="text"
label="Community String"
required
autocomplete="off"
/>
<.input
field={@form[:snmp_port]}
type="number"
label="SNMP Port"
min="1"
max="65535"
value={@form[:snmp_port].value || 161}
/>
<div
:if={@snmp_test_result}
class="rounded-md p-3"
class={[
@snmp_test_result.success && "bg-green-50 text-green-800",
!@snmp_test_result.success && "bg-red-50 text-red-800"
]}
>
<div class="flex items-center gap-2">
<.icon :if={@snmp_test_result.success} name="hero-check-circle" class="w-5 h-5" />
<.icon :if={!@snmp_test_result.success} name="hero-x-circle" class="w-5 h-5" />
<span class="text-sm font-medium">{@snmp_test_result.message}</span>
</div>
</div>
<.button type="button" phx-click="test_snmp" phx-disable-with="Testing...">
Test SNMP Connection
</.button>
</div>
</div>
<div class="flex gap-3 mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Equipment</.button>
<.button navigate={~p"/orgs/#{@organization.slug}/equipment"}>Cancel</.button>

View file

@ -57,6 +57,7 @@ defmodule Towerops.MixProject do
github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:snmpkit, "~> 1.3"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"},
@ -64,6 +65,7 @@ defmodule Towerops.MixProject do
{:dns_cluster, "~> 0.2.0"},
{:libcluster, "~> 3.4"},
{:bandit, "~> 1.5"},
{:mox, "~> 1.0", only: :test},
{:styler, "~> 1.10", only: [:dev, :test], runtime: false},
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}

View file

@ -26,7 +26,9 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
@ -40,6 +42,7 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
"snmpkit": {:hex, :snmpkit, "1.3.19", "b09c38cea619a0a67d4fb0f3bfa3b9b749d42c400c2e7bd9446fef82f0d728a7", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: true]}], "hexpm", "b05c1f3911204c4d781234187a6f1350c369fcffe2058a85b49735b1259eb973"},
"styler": {:hex, :styler, "1.10.0", "343f1f7bb19a8893c2841a9ae90665b68d2edf6cc37b964a5099e60c78815c2e", [:mix], [], "hexpm", "6a78876611869466139e63722df4cbbb56b18a842d88c19f23ca844d914ad91a"},
"swoosh": {:hex, :swoosh, "1.19.9", "4eb2c471b8cf06adbdcaa1d57a0ad53c0ed9348ce8586a06cc491f9f0dbcb553", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "516898263a64925c31723c56bc7999a26e97b04e869707f681f4c9bca7ee1688"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},

View file

@ -0,0 +1,13 @@
defmodule Towerops.Repo.Migrations.AddSnmpFieldsToEquipment do
use Ecto.Migration
def change do
alter table(:equipment) do
add :snmp_enabled, :boolean, default: false, null: false
add :snmp_version, :string
add :snmp_community, :string
add :snmp_port, :integer, default: 161
add :last_discovery_at, :utc_datetime
end
end
end

View file

@ -0,0 +1,26 @@
defmodule Towerops.Repo.Migrations.CreateSnmpDevices do
use Ecto.Migration
def change do
create table(:snmp_devices, primary_key: false) do
add :id, :binary_id, primary_key: true
add :equipment_id, references(:equipment, type: :binary_id, on_delete: :delete_all),
null: false
add :sys_descr, :text
add :sys_object_id, :string
add :sys_name, :string
add :sys_uptime, :bigint
add :sys_contact, :string
add :sys_location, :string
add :manufacturer, :string
add :model, :string
add :firmware_version, :string
timestamps(type: :utc_datetime)
end
create unique_index(:snmp_devices, [:equipment_id])
end
end

View file

@ -0,0 +1,28 @@
defmodule Towerops.Repo.Migrations.CreateSnmpSensors do
use Ecto.Migration
def change do
create table(:snmp_sensors, primary_key: false) do
add :id, :binary_id, primary_key: true
add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all),
null: false
add :sensor_type, :string, null: false
add :sensor_index, :string, null: false
add :sensor_oid, :string, null: false
add :sensor_descr, :string
add :sensor_unit, :string
add :sensor_divisor, :integer, default: 1, null: false
add :monitored, :boolean, default: true, null: false
add :last_value, :float
add :last_checked_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create index(:snmp_sensors, [:snmp_device_id])
create unique_index(:snmp_sensors, [:snmp_device_id, :sensor_index])
create index(:snmp_sensors, [:sensor_type])
end
end

View file

@ -0,0 +1,28 @@
defmodule Towerops.Repo.Migrations.CreateSnmpInterfaces do
use Ecto.Migration
def change do
create table(:snmp_interfaces, primary_key: false) do
add :id, :binary_id, primary_key: true
add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all),
null: false
add :if_index, :integer, null: false
add :if_name, :string
add :if_descr, :string
add :if_alias, :string
add :if_type, :integer
add :if_speed, :bigint
add :if_phys_address, :string
add :if_admin_status, :string
add :if_oper_status, :string
add :monitored, :boolean, default: true, null: false
timestamps(type: :utc_datetime)
end
create index(:snmp_interfaces, [:snmp_device_id])
create unique_index(:snmp_interfaces, [:snmp_device_id, :if_index])
end
end

View file

@ -0,0 +1,52 @@
defmodule Towerops.Repo.Migrations.CreateSnmpSensorReadings do
use Ecto.Migration
def up do
# Create the table with composite primary key (id, checked_at)
# This is required for TimescaleDB hypertables
create table(:snmp_sensor_readings, primary_key: false) do
add :id, :binary_id, null: false
add :sensor_id, references(:snmp_sensors, type: :binary_id, on_delete: :delete_all),
null: false
add :value, :float
add :status, :string, null: false
add :checked_at, :utc_datetime, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
# Add composite primary key
execute("ALTER TABLE snmp_sensor_readings ADD PRIMARY KEY (id, checked_at)")
# Convert to TimescaleDB hypertable (partitioned by checked_at)
execute("SELECT create_hypertable('snmp_sensor_readings', 'checked_at')")
# Create indexes
create index(:snmp_sensor_readings, [:sensor_id])
create index(:snmp_sensor_readings, [:sensor_id, :checked_at])
create index(:snmp_sensor_readings, [:status])
# Add retention policy: automatically drop data older than 90 days
execute("""
SELECT add_retention_policy('snmp_sensor_readings', INTERVAL '90 days')
""")
# Add compression policy: compress chunks older than 7 days
execute("""
ALTER TABLE snmp_sensor_readings SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'sensor_id'
)
""")
execute("""
SELECT add_compression_policy('snmp_sensor_readings', INTERVAL '7 days')
""")
end
def down do
drop table(:snmp_sensor_readings)
end
end

View file

@ -0,0 +1,55 @@
defmodule Towerops.Repo.Migrations.CreateSnmpInterfaceStats do
use Ecto.Migration
def up do
# Create the table with composite primary key (id, checked_at)
# This is required for TimescaleDB hypertables
create table(:snmp_interface_stats, primary_key: false) do
add :id, :binary_id, null: false
add :interface_id, references(:snmp_interfaces, type: :binary_id, on_delete: :delete_all),
null: false
add :if_in_octets, :bigint
add :if_out_octets, :bigint
add :if_in_errors, :bigint
add :if_out_errors, :bigint
add :if_in_discards, :bigint
add :if_out_discards, :bigint
add :checked_at, :utc_datetime, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
# Add composite primary key
execute("ALTER TABLE snmp_interface_stats ADD PRIMARY KEY (id, checked_at)")
# Convert to TimescaleDB hypertable (partitioned by checked_at)
execute("SELECT create_hypertable('snmp_interface_stats', 'checked_at')")
# Create indexes
create index(:snmp_interface_stats, [:interface_id])
create index(:snmp_interface_stats, [:interface_id, :checked_at])
# Add retention policy: automatically drop data older than 90 days
execute("""
SELECT add_retention_policy('snmp_interface_stats', INTERVAL '90 days')
""")
# Add compression policy: compress chunks older than 7 days
execute("""
ALTER TABLE snmp_interface_stats SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'interface_id'
)
""")
execute("""
SELECT add_compression_policy('snmp_interface_stats', INTERVAL '7 days')
""")
end
def down do
drop table(:snmp_interface_stats)
end
end

View file

@ -0,0 +1,70 @@
defmodule Towerops.Repo.Migrations.CreateSnmpSensorAggregates do
use Ecto.Migration
# Disable DDL transaction for TimescaleDB continuous aggregates
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Hourly continuous aggregate for sensor readings
execute("""
CREATE MATERIALIZED VIEW snmp_sensor_readings_hourly
WITH (timescaledb.continuous) AS
SELECT
sensor_id,
time_bucket('1 hour', checked_at) AS bucket,
COUNT(*) AS total_readings,
AVG(value) AS avg_value,
MIN(value) AS min_value,
MAX(value) AS max_value,
COUNT(*) FILTER (WHERE status = 'ok') AS ok_readings,
COUNT(*) FILTER (WHERE status = 'warning') AS warning_readings,
COUNT(*) FILTER (WHERE status = 'critical') AS critical_readings
FROM snmp_sensor_readings
GROUP BY sensor_id, bucket
""")
# Add refresh policy for hourly aggregate (refresh every hour)
execute("""
SELECT add_continuous_aggregate_policy('snmp_sensor_readings_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour')
""")
# Daily continuous aggregate for sensor readings
execute("""
CREATE MATERIALIZED VIEW snmp_sensor_readings_daily
WITH (timescaledb.continuous) AS
SELECT
sensor_id,
time_bucket('1 day', checked_at) AS bucket,
COUNT(*) AS total_readings,
AVG(value) AS avg_value,
MIN(value) AS min_value,
MAX(value) AS max_value,
COUNT(*) FILTER (WHERE status = 'ok') AS ok_readings,
COUNT(*) FILTER (WHERE status = 'warning') AS warning_readings,
COUNT(*) FILTER (WHERE status = 'critical') AS critical_readings
FROM snmp_sensor_readings
GROUP BY sensor_id, bucket
""")
# Add refresh policy for daily aggregate (refresh every day)
execute("""
SELECT add_continuous_aggregate_policy('snmp_sensor_readings_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day')
""")
# Create indexes on the materialized views
create index(:snmp_sensor_readings_hourly, [:sensor_id, :bucket])
create index(:snmp_sensor_readings_daily, [:sensor_id, :bucket])
end
def down do
execute("DROP MATERIALIZED VIEW IF EXISTS snmp_sensor_readings_daily")
execute("DROP MATERIALIZED VIEW IF EXISTS snmp_sensor_readings_hourly")
end
end

View file

@ -0,0 +1,74 @@
defmodule Towerops.Repo.Migrations.CreateSnmpInterfaceAggregates do
use Ecto.Migration
# Disable DDL transaction for TimescaleDB continuous aggregates
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Hourly continuous aggregate for interface stats
execute("""
CREATE MATERIALIZED VIEW snmp_interface_stats_hourly
WITH (timescaledb.continuous) AS
SELECT
interface_id,
time_bucket('1 hour', checked_at) AS bucket,
COUNT(*) AS total_samples,
AVG(if_in_octets) AS avg_in_octets,
MAX(if_in_octets) AS max_in_octets,
AVG(if_out_octets) AS avg_out_octets,
MAX(if_out_octets) AS max_out_octets,
SUM(if_in_errors) AS total_in_errors,
SUM(if_out_errors) AS total_out_errors,
SUM(if_in_discards) AS total_in_discards,
SUM(if_out_discards) AS total_out_discards
FROM snmp_interface_stats
GROUP BY interface_id, bucket
""")
# Add refresh policy for hourly aggregate (refresh every hour)
execute("""
SELECT add_continuous_aggregate_policy('snmp_interface_stats_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour')
""")
# Daily continuous aggregate for interface stats
execute("""
CREATE MATERIALIZED VIEW snmp_interface_stats_daily
WITH (timescaledb.continuous) AS
SELECT
interface_id,
time_bucket('1 day', checked_at) AS bucket,
COUNT(*) AS total_samples,
AVG(if_in_octets) AS avg_in_octets,
MAX(if_in_octets) AS max_in_octets,
AVG(if_out_octets) AS avg_out_octets,
MAX(if_out_octets) AS max_out_octets,
SUM(if_in_errors) AS total_in_errors,
SUM(if_out_errors) AS total_out_errors,
SUM(if_in_discards) AS total_in_discards,
SUM(if_out_discards) AS total_out_discards
FROM snmp_interface_stats
GROUP BY interface_id, bucket
""")
# Add refresh policy for daily aggregate (refresh every day)
execute("""
SELECT add_continuous_aggregate_policy('snmp_interface_stats_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day')
""")
# Create indexes on the materialized views
create index(:snmp_interface_stats_hourly, [:interface_id, :bucket])
create index(:snmp_interface_stats_daily, [:interface_id, :bucket])
end
def down do
execute("DROP MATERIALIZED VIEW IF EXISTS snmp_interface_stats_daily")
execute("DROP MATERIALIZED VIEW IF EXISTS snmp_interface_stats_hourly")
end
end

View file

@ -0,0 +1,144 @@
defmodule Towerops.Integration.SnmpIntegrationTest do
@moduledoc """
Integration tests for SNMP functionality against real devices.
These tests are excluded from the main test suite by default.
To run them: mix test --only integration
Requirements:
- Network access to test devices
- SNMP-enabled equipment (configure IP/community below)
"""
use ExUnit.Case, async: false
alias Towerops.Monitoring.EquipmentMonitor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Poller
@moduletag :integration
# Configure your test device here
@test_device_opts [
ip: "10.0.19.254",
community: "kdyyJrT0Mm",
version: "2c",
port: 161,
timeout: 5000
]
describe "SNMP Client against real device" do
test "can perform GET operation" do
case Client.get(@test_device_opts, "1.3.6.1.2.1.1.3.0") do
{:ok, uptime} ->
assert is_integer(uptime)
{:error, reason} ->
flunk("SNMP GET failed: #{inspect(reason)}")
end
end
test "can perform WALK operation" do
case Client.walk(@test_device_opts, "1.3.6.1.2.1.2.2.1.1") do
{:ok, results} ->
assert is_map(results)
assert map_size(results) > 0
{:error, reason} ->
flunk("SNMP WALK failed: #{inspect(reason)}")
end
end
test "can test connection" do
case Client.test_connection(@test_device_opts) do
{:ok, _message} ->
assert true
{:error, reason} ->
flunk("Connection test failed: #{inspect(reason)}")
end
end
end
describe "SNMP Poller against real device" do
test "can check device reachability" do
case Poller.check_device(@test_device_opts) do
{:ok, response_time} ->
assert is_float(response_time)
assert response_time > 0
{:error, reason} ->
flunk("Poller check failed: #{inspect(reason)}")
end
end
end
describe "Discovery against real device" do
@tag :full_discovery
test "can discover MikroTik device" do
alias Towerops.Snmp.Profiles.Mikrotik
# Discover system info
{:ok, system_info} = Mikrotik.discover_system_info(@test_device_opts)
assert system_info.sys_descr =~ "RouterOS"
# Discover interfaces
{:ok, interfaces} = Mikrotik.discover_interfaces(@test_device_opts)
assert length(interfaces) > 0
# Discover sensors
{:ok, sensors} = Mikrotik.discover_sensors(@test_device_opts)
assert length(sensors) > 0
# Identify device
device_info = Mikrotik.identify_device(system_info)
assert device_info.manufacturer == "MikroTik"
assert device_info.model
end
end
describe "Equipment Monitor with SNMP" do
test "creates SNMP-specific alert messages when device goes down" do
# This test makes real SNMP calls to an unreachable IP to verify timeout handling
import Towerops.AccountsFixtures
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "SNMP Router",
ip_address: "192.0.2.1",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
monitoring_enabled: true
})
# Set to up first so status change will trigger
{:ok, equipment} = Towerops.Equipment.update_equipment_status(equipment, :up)
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check - will timeout trying to reach 192.0.2.1
{:noreply, _state} =
EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(200)
alerts = Towerops.Alerts.list_equipment_alerts(equipment.id)
assert length(alerts) > 0
alert = hd(alerts)
assert alert.alert_type == :equipment_down
assert String.contains?(alert.message, "SNMP")
end
end
end

18
test/support/ping_stub.ex Normal file
View file

@ -0,0 +1,18 @@
defmodule Towerops.Monitoring.PingStub do
@moduledoc """
Stub implementation of PingBehaviour for testing.
Always returns successful ping responses.
"""
@behaviour Towerops.Monitoring.PingBehaviour
@impl true
def ping(_ip_address) do
{:ok, 10}
end
@impl true
def ping(_ip_address, _timeout) do
{:ok, 10}
end
end

View file

@ -1,2 +1,11 @@
ExUnit.start()
# Exclude integration tests by default
# Run them with: mix test --only integration
ExUnit.configure(exclude: [:integration])
# Define mocks for testing
Mox.defmock(Towerops.Monitoring.PingMock, for: Towerops.Monitoring.PingBehaviour)
Mox.defmock(Towerops.Snmp.PollerMock, for: Towerops.Snmp.PollerBehaviour)
Ecto.Adapters.SQL.Sandbox.mode(Towerops.Repo, :manual)

View file

@ -121,7 +121,7 @@ defmodule Towerops.Alerts.AlertNotifierTest do
email.text_body =~ equipment.name &&
email.text_body =~ equipment.ip_address &&
email.text_body =~ "Test Site" &&
email.text_body =~ "not responding to ping checks"
email.text_body =~ "not responding"
end)
end
@ -147,7 +147,7 @@ defmodule Towerops.Alerts.AlertNotifierTest do
email.text_body =~ equipment.name &&
email.text_body =~ equipment.ip_address &&
email.text_body =~ "Test Site" &&
email.text_body =~ "now responding to ping checks"
email.text_body =~ "now responding"
end)
end

View file

@ -155,8 +155,8 @@ defmodule Towerops.EquipmentTest do
first_change_at = equipment.last_status_change_at
first_checked_at = equipment.last_checked_at
# Sleep for over a second to ensure timestamps differ
Process.sleep(1100)
# Sleep for 1+ second to ensure timestamps differ (truncated to seconds in update_equipment_status)
Process.sleep(1010)
{:ok, updated} = Equipment.update_equipment_status(equipment, :up)
# Status change timestamp should not change
assert updated.last_status_change_at == first_change_at

View file

@ -1,14 +1,23 @@
defmodule Towerops.Monitoring.EquipmentMonitorTest do
use Towerops.DataCase, async: false
import Mox
import Towerops.AccountsFixtures
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Monitoring.EquipmentMonitor
alias Towerops.Monitoring.PingMock
# Set up Mox to verify expectations on exit
setup :verify_on_exit!
setup :set_mox_from_context
setup do
# Stub default ping behavior using the PingStub module
Mox.stub_with(PingMock, Towerops.Monitoring.PingStub)
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
@ -53,8 +62,8 @@ defmodule Towerops.Monitoring.EquipmentMonitorTest do
# Trigger a check
EquipmentMonitor.trigger_check(equipment.id)
# Give it time to process
Process.sleep(100)
# Give it time to process (GenServer needs time to handle the message)
Process.sleep(200)
# Should have created a monitoring check
checks = Monitoring.list_equipment_checks(equipment.id)
@ -75,6 +84,33 @@ defmodule Towerops.Monitoring.EquipmentMonitorTest do
end
end
describe "SNMP monitoring configuration" do
test "equipment can be configured with SNMP settings", %{equipment: equipment} do
{:ok, updated_equipment} =
Equipment.update_equipment(equipment, %{
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
assert updated_equipment.snmp_enabled == true
assert updated_equipment.snmp_version == "2c"
assert updated_equipment.snmp_community == "public"
assert updated_equipment.snmp_port == 161
end
test "SNMP version defaults to 2c", %{equipment: equipment} do
{:ok, updated_equipment} =
Equipment.update_equipment(equipment, %{
snmp_enabled: true,
snmp_community: "public"
})
assert updated_equipment.snmp_version == "2c"
end
end
describe "alert creation" do
setup %{equipment: equipment} do
# Set equipment to up status first
@ -83,17 +119,17 @@ defmodule Towerops.Monitoring.EquipmentMonitorTest do
end
test "creates equipment_down alert when equipment goes down", %{equipment: equipment} do
# Update to use an unreachable IP
{:ok, equipment} =
Equipment.update_equipment(equipment, %{ip_address: "192.0.2.1"})
# Mock failed ping
expect(PingMock, :ping, fn _ip ->
{:error, :timeout_or_unreachable}
end)
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check
{:noreply, _state} = EquipmentMonitor.handle_info(:check_equipment, state)
# Should create an alert
Process.sleep(100)
# Should create an alert (no need to sleep since test env doesn't send emails)
alerts = Alerts.list_equipment_alerts(equipment.id)
assert length(alerts) > 0
@ -116,11 +152,9 @@ defmodule Towerops.Monitoring.EquipmentMonitorTest do
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check (127.0.0.1 should be reachable)
# Trigger check
{:noreply, _state} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
# Should create recovery alert
alerts = Alerts.list_equipment_alerts(equipment.id, 10)
up_alerts = Enum.filter(alerts, &(&1.alert_type == :equipment_up))
@ -128,17 +162,16 @@ defmodule Towerops.Monitoring.EquipmentMonitorTest do
end
test "does not create duplicate down alerts", %{equipment: equipment} do
# Update to unreachable IP
{:ok, equipment} =
Equipment.update_equipment(equipment, %{ip_address: "192.0.2.1"})
# Mock failed ping for both calls
expect(PingMock, :ping, 2, fn _ip ->
{:error, :timeout_or_unreachable}
end)
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check twice
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
# Should only have one alert
alerts =
@ -164,9 +197,8 @@ defmodule Towerops.Monitoring.EquipmentMonitorTest do
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Check (should succeed for 127.0.0.1)
# Check
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
# Down alert should be resolved
updated_alert = Alerts.get_alert!(down_alert.id)

View file

@ -110,6 +110,7 @@ defmodule Towerops.MonitoringTest do
describe "ping" do
alias Towerops.Monitoring.Ping
@tag :integration
test "ping/1 returns ok tuple with response time for localhost" do
# Test with localhost which should always be reachable
case Ping.ping("127.0.0.1", 2000) do
@ -123,6 +124,7 @@ defmodule Towerops.MonitoringTest do
end
end
@tag :integration
test "ping/1 returns error tuple for unreachable host" do
# Use a non-routable IP
case Ping.ping("192.0.2.1", 1000) do
@ -131,6 +133,7 @@ defmodule Towerops.MonitoringTest do
end
end
@tag :integration
test "ping/1 with custom timeout" do
result = Ping.ping("127.0.0.1", 5000)
# Result should be either {:ok, time} or {:error, reason}

View file

@ -185,7 +185,11 @@ defmodule ToweropsWeb.EquipmentLiveTest do
site_id: site.id,
description: "Test router",
monitoring_enabled: true,
check_interval_seconds: 300
check_interval_seconds: 300,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
}
)
|> render_submit()