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" /> +
+ Enable SNMP to discover device details, monitor sensors, and track interface statistics. +
+ + <.input field={@form[:snmp_enabled]} type="checkbox" label="Enable SNMP Monitoring" /> + +