diff --git a/config/test.exs b/config/test.exs index 62382d20..be461258 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/towerops/alerts/alert_notifier.ex b/lib/towerops/alerts/alert_notifier.ex index 12050b3f..5be7cabc 100644 --- a/lib/towerops/alerts/alert_notifier.ex +++ b/lib/towerops/alerts/alert_notifier.ex @@ -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. ============================== """ diff --git a/lib/towerops/equipment/equipment.ex b/lib/towerops/equipment/equipment.ex index 981331b0..31b4cd57 100644 --- a/lib/towerops/equipment/equipment.ex +++ b/lib/towerops/equipment/equipment.ex @@ -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 diff --git a/lib/towerops/monitoring/equipment_monitor.ex b/lib/towerops/monitoring/equipment_monitor.ex index b5f019fa..b9c1eccb 100644 --- a/lib/towerops/monitoring/equipment_monitor.ex +++ b/lib/towerops/monitoring/equipment_monitor.ex @@ -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 diff --git a/lib/towerops/monitoring/ping.ex b/lib/towerops/monitoring/ping.ex index a41bcade..e59c7fda 100644 --- a/lib/towerops/monitoring/ping.ex +++ b/lib/towerops/monitoring/ping.ex @@ -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) diff --git a/lib/towerops/monitoring/ping_behaviour.ex b/lib/towerops/monitoring/ping_behaviour.ex new file mode 100644 index 00000000..9d9a014a --- /dev/null +++ b/lib/towerops/monitoring/ping_behaviour.ex @@ -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 diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex new file mode 100644 index 00000000..8ebd814b --- /dev/null +++ b/lib/towerops/snmp.ex @@ -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 diff --git a/lib/towerops/snmp/client.ex b/lib/towerops/snmp/client.ex new file mode 100644 index 00000000..6f293755 --- /dev/null +++ b/lib/towerops/snmp/client.ex @@ -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 diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex new file mode 100644 index 00000000..03523bc0 --- /dev/null +++ b/lib/towerops/snmp/device.ex @@ -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 diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex new file mode 100644 index 00000000..cc0a3d86 --- /dev/null +++ b/lib/towerops/snmp/discovery.ex @@ -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 diff --git a/lib/towerops/snmp/interface.ex b/lib/towerops/snmp/interface.ex new file mode 100644 index 00000000..1b772731 --- /dev/null +++ b/lib/towerops/snmp/interface.ex @@ -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 diff --git a/lib/towerops/snmp/interface_stat.ex b/lib/towerops/snmp/interface_stat.ex new file mode 100644 index 00000000..9d250f88 --- /dev/null +++ b/lib/towerops/snmp/interface_stat.ex @@ -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 diff --git a/lib/towerops/snmp/poller.ex b/lib/towerops/snmp/poller.ex new file mode 100644 index 00000000..ff171051 --- /dev/null +++ b/lib/towerops/snmp/poller.ex @@ -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 diff --git a/lib/towerops/snmp/poller_behaviour.ex b/lib/towerops/snmp/poller_behaviour.ex new file mode 100644 index 00000000..b9ccbaad --- /dev/null +++ b/lib/towerops/snmp/poller_behaviour.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex new file mode 100644 index 00000000..dc039eea --- /dev/null +++ b/lib/towerops/snmp/profiles/base.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/cisco.ex b/lib/towerops/snmp/profiles/cisco.ex new file mode 100644 index 00000000..0c3297cf --- /dev/null +++ b/lib/towerops/snmp/profiles/cisco.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/mikrotik.ex b/lib/towerops/snmp/profiles/mikrotik.ex new file mode 100644 index 00000000..60a77936 --- /dev/null +++ b/lib/towerops/snmp/profiles/mikrotik.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/net_snmp.ex b/lib/towerops/snmp/profiles/net_snmp.ex new file mode 100644 index 00000000..f8ff4cf1 --- /dev/null +++ b/lib/towerops/snmp/profiles/net_snmp.ex @@ -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 diff --git a/lib/towerops/snmp/sensor.ex b/lib/towerops/snmp/sensor.ex new file mode 100644 index 00000000..61245051 --- /dev/null +++ b/lib/towerops/snmp/sensor.ex @@ -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 diff --git a/lib/towerops/snmp/sensor_reading.ex b/lib/towerops/snmp/sensor_reading.ex new file mode 100644 index 00000000..c4bcd966 --- /dev/null +++ b/lib/towerops/snmp/sensor_reading.ex @@ -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 diff --git a/lib/towerops_web/live/equipment_live/form.ex b/lib/towerops_web/live/equipment_live/form.ex index 1b482d62..f9d525e7 100644 --- a/lib/towerops_web/live/equipment_live/form.ex +++ b/lib/towerops_web/live/equipment_live/form.ex @@ -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} -> diff --git a/lib/towerops_web/live/equipment_live/form.html.heex b/lib/towerops_web/live/equipment_live/form.html.heex index 5403e9bc..73a2b471 100644 --- a/lib/towerops_web/live/equipment_live/form.html.heex +++ b/lib/towerops_web/live/equipment_live/form.html.heex @@ -35,6 +35,65 @@ <.input field={@form[:monitoring_enabled]} type="checkbox" label="Enable Monitoring" /> +
+

SNMP Configuration

+

+ Enable SNMP to discover device details, monitor sensors, and track interface statistics. +

+ + <.input field={@form[:snmp_enabled]} type="checkbox" label="Enable SNMP Monitoring" /> + +
+ <.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} + /> + +
+
+ <.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" /> + {@snmp_test_result.message} +
+
+ + <.button type="button" phx-click="test_snmp" phx-disable-with="Testing..."> + Test SNMP Connection + +
+
+
<.button phx-disable-with="Saving..." variant="primary">Save Equipment <.button navigate={~p"/orgs/#{@organization.slug}/equipment"}>Cancel diff --git a/mix.exs b/mix.exs index 0200861e..9479c0a8 100644 --- a/mix.exs +++ b/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} diff --git a/mix.lock b/mix.lock index cb81728c..7cc62a3b 100644 --- a/mix.lock +++ b/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"}, diff --git a/priv/repo/migrations/20260103191834_add_snmp_fields_to_equipment.exs b/priv/repo/migrations/20260103191834_add_snmp_fields_to_equipment.exs new file mode 100644 index 00000000..e37f7064 --- /dev/null +++ b/priv/repo/migrations/20260103191834_add_snmp_fields_to_equipment.exs @@ -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 diff --git a/priv/repo/migrations/20260103191852_create_snmp_devices.exs b/priv/repo/migrations/20260103191852_create_snmp_devices.exs new file mode 100644 index 00000000..a3ceb523 --- /dev/null +++ b/priv/repo/migrations/20260103191852_create_snmp_devices.exs @@ -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 diff --git a/priv/repo/migrations/20260103191853_create_snmp_sensors.exs b/priv/repo/migrations/20260103191853_create_snmp_sensors.exs new file mode 100644 index 00000000..d7dcd1aa --- /dev/null +++ b/priv/repo/migrations/20260103191853_create_snmp_sensors.exs @@ -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 diff --git a/priv/repo/migrations/20260103191854_create_snmp_interfaces.exs b/priv/repo/migrations/20260103191854_create_snmp_interfaces.exs new file mode 100644 index 00000000..5d6e778c --- /dev/null +++ b/priv/repo/migrations/20260103191854_create_snmp_interfaces.exs @@ -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 diff --git a/priv/repo/migrations/20260103191858_create_snmp_sensor_readings.exs b/priv/repo/migrations/20260103191858_create_snmp_sensor_readings.exs new file mode 100644 index 00000000..320d8289 --- /dev/null +++ b/priv/repo/migrations/20260103191858_create_snmp_sensor_readings.exs @@ -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 diff --git a/priv/repo/migrations/20260103191859_create_snmp_interface_stats.exs b/priv/repo/migrations/20260103191859_create_snmp_interface_stats.exs new file mode 100644 index 00000000..2e246667 --- /dev/null +++ b/priv/repo/migrations/20260103191859_create_snmp_interface_stats.exs @@ -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 diff --git a/priv/repo/migrations/20260103192153_create_snmp_sensor_aggregates.exs b/priv/repo/migrations/20260103192153_create_snmp_sensor_aggregates.exs new file mode 100644 index 00000000..469bf6c2 --- /dev/null +++ b/priv/repo/migrations/20260103192153_create_snmp_sensor_aggregates.exs @@ -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 diff --git a/priv/repo/migrations/20260103192154_create_snmp_interface_aggregates.exs b/priv/repo/migrations/20260103192154_create_snmp_interface_aggregates.exs new file mode 100644 index 00000000..cdc6b95d --- /dev/null +++ b/priv/repo/migrations/20260103192154_create_snmp_interface_aggregates.exs @@ -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 diff --git a/test/integration/snmp_integration_test.exs b/test/integration/snmp_integration_test.exs new file mode 100644 index 00000000..d1ee59a4 --- /dev/null +++ b/test/integration/snmp_integration_test.exs @@ -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 diff --git a/test/support/ping_stub.ex b/test/support/ping_stub.ex new file mode 100644 index 00000000..eece12ff --- /dev/null +++ b/test/support/ping_stub.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 2aaaf4ec..fca72b06 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -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) diff --git a/test/towerops/alerts/alert_notifier_test.exs b/test/towerops/alerts/alert_notifier_test.exs index e2132a76..4e34a3d9 100644 --- a/test/towerops/alerts/alert_notifier_test.exs +++ b/test/towerops/alerts/alert_notifier_test.exs @@ -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 diff --git a/test/towerops/equipment_test.exs b/test/towerops/equipment_test.exs index 2848004c..d11d0c3c 100644 --- a/test/towerops/equipment_test.exs +++ b/test/towerops/equipment_test.exs @@ -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 diff --git a/test/towerops/monitoring/equipment_monitor_test.exs b/test/towerops/monitoring/equipment_monitor_test.exs index 036d3dca..394e2d76 100644 --- a/test/towerops/monitoring/equipment_monitor_test.exs +++ b/test/towerops/monitoring/equipment_monitor_test.exs @@ -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) diff --git a/test/towerops/monitoring_test.exs b/test/towerops/monitoring_test.exs index feb715f1..dbbdc7ca 100644 --- a/test/towerops/monitoring_test.exs +++ b/test/towerops/monitoring_test.exs @@ -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} diff --git a/test/towerops_web/live/equipment_live_test.exs b/test/towerops_web/live/equipment_live_test.exs index 80252183..343c989c 100644 --- a/test/towerops_web/live/equipment_live_test.exs +++ b/test/towerops_web/live/equipment_live_test.exs @@ -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()