snmp bits
This commit is contained in:
parent
0f9266027f
commit
8cbca259fc
40 changed files with 3033 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
8
lib/towerops/monitoring/ping_behaviour.ex
Normal file
8
lib/towerops/monitoring/ping_behaviour.ex
Normal 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
288
lib/towerops/snmp.ex
Normal 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
211
lib/towerops/snmp/client.ex
Normal 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
|
||||
47
lib/towerops/snmp/device.ex
Normal file
47
lib/towerops/snmp/device.ex
Normal 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
|
||||
305
lib/towerops/snmp/discovery.ex
Normal file
305
lib/towerops/snmp/discovery.ex
Normal 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
|
||||
48
lib/towerops/snmp/interface.ex
Normal file
48
lib/towerops/snmp/interface.ex
Normal 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
|
||||
39
lib/towerops/snmp/interface_stat.ex
Normal file
39
lib/towerops/snmp/interface_stat.ex
Normal 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
|
||||
61
lib/towerops/snmp/poller.ex
Normal file
61
lib/towerops/snmp/poller.ex
Normal 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
|
||||
10
lib/towerops/snmp/poller_behaviour.ex
Normal file
10
lib/towerops/snmp/poller_behaviour.ex
Normal 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
|
||||
328
lib/towerops/snmp/profiles/base.ex
Normal file
328
lib/towerops/snmp/profiles/base.ex
Normal 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
|
||||
247
lib/towerops/snmp/profiles/cisco.ex
Normal file
247
lib/towerops/snmp/profiles/cisco.ex
Normal 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
|
||||
280
lib/towerops/snmp/profiles/mikrotik.ex
Normal file
280
lib/towerops/snmp/profiles/mikrotik.ex
Normal 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
|
||||
297
lib/towerops/snmp/profiles/net_snmp.ex
Normal file
297
lib/towerops/snmp/profiles/net_snmp.ex
Normal 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
|
||||
46
lib/towerops/snmp/sensor.ex
Normal file
46
lib/towerops/snmp/sensor.ex
Normal 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
|
||||
27
lib/towerops/snmp/sensor_reading.ex
Normal file
27
lib/towerops/snmp/sensor_reading.ex
Normal 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
|
||||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -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}
|
||||
|
|
|
|||
3
mix.lock
3
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
26
priv/repo/migrations/20260103191852_create_snmp_devices.exs
Normal file
26
priv/repo/migrations/20260103191852_create_snmp_devices.exs
Normal 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
|
||||
28
priv/repo/migrations/20260103191853_create_snmp_sensors.exs
Normal file
28
priv/repo/migrations/20260103191853_create_snmp_sensors.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
144
test/integration/snmp_integration_test.exs
Normal file
144
test/integration/snmp_integration_test.exs
Normal 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
18
test/support/ping_stub.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue