From 56bb8559968d07ea16b8312d3fa3c42aa62e58a6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 22 Jan 2026 08:34:35 -0600 Subject: [PATCH] refactor: extract vendor-specific SNMP discovery to modules Mirrors LibreNMS architecture where vendor-specific discovery logic lives in OS PHP classes. Creates modular vendor system with: - Vendor behaviour defining callbacks for profile_names, detect_hardware, discover_wireless_sensors, wireless_oid_defs - Epmp vendor module for Cambium ePMP devices - Airos vendor module for Ubiquiti AirOS devices - Routeros vendor module for MikroTik devices with table-based discovery - Registry module for dispatching to appropriate vendor by profile name Removes 250+ lines of inline vendor code from dynamic.ex. --- lib/towerops/snmp/profiles/dynamic.ex | 322 +--------- lib/towerops/snmp/profiles/vendors/airos.ex | 150 +++++ lib/towerops/snmp/profiles/vendors/epmp.ex | 145 +++++ .../snmp/profiles/vendors/registry.ex | 76 +++ .../snmp/profiles/vendors/routeros.ex | 577 ++++++++++++++++++ lib/towerops/snmp/profiles/vendors/vendor.ex | 130 ++++ 6 files changed, 1087 insertions(+), 313 deletions(-) create mode 100644 lib/towerops/snmp/profiles/vendors/airos.ex create mode 100644 lib/towerops/snmp/profiles/vendors/epmp.ex create mode 100644 lib/towerops/snmp/profiles/vendors/registry.ex create mode 100644 lib/towerops/snmp/profiles/vendors/routeros.ex create mode 100644 lib/towerops/snmp/profiles/vendors/vendor.ex diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex index 02c77611..993ec37b 100644 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -7,6 +7,7 @@ defmodule Towerops.Snmp.Profiles.Dynamic do alias Towerops.Snmp.Client alias Towerops.Snmp.Discovery alias Towerops.Snmp.MibTranslator + alias Towerops.Snmp.Profiles.Vendors.Registry, as: VendorRegistry require Logger @@ -120,57 +121,13 @@ defmodule Towerops.Snmp.Profiles.Dynamic do # Private helper functions - # Vendor-specific hardware detection (like LibreNMS OS PHP files) - # ePMP: Uses wirelessInterfaceMode and cambiumSubModeType to determine AP/SM and ePTP/ePMP - defp detect_vendor_hardware(%{name: "epmp"}, client_opts) do - detect_epmp_hardware(client_opts) - end - - # AirOS: Uses IEEE802dot11-MIB::dot11manufacturerProductName (mirrors Airos.php) - defp detect_vendor_hardware(%{name: name}, client_opts) - when name in ["airos", "airos-af", "airos-af60", "airos-af-ltu"] do - detect_airos_hardware(client_opts) + # Vendor-specific hardware detection using vendor modules (like LibreNMS OS PHP files) + defp detect_vendor_hardware(%{name: name}, client_opts) do + VendorRegistry.detect_hardware(name, client_opts) end defp detect_vendor_hardware(_profile, _client_opts), do: nil - # AirOS hardware detection - mirrors LibreNMS Airos.php discoverOS() - # Uses GETNEXT on IEEE802dot11-MIB::dot11manufacturerProductName table - defp detect_airos_hardware(client_opts) do - # IEEE802dot11-MIB::dot11manufacturerProductName OID base - product_name_oid = "1.2.840.10036.1.1.1.9" - - # Use walk to get the first entry (LibreNMS uses GETNEXT) - case Client.walk(client_opts, product_name_oid) do - {:ok, [%{value: value} | _]} when is_binary(value) and value != "" -> - value - - _ -> - nil - end - end - - # ePMP hardware detection - mirrors LibreNMS Epmp.php discoverOS() - defp detect_epmp_hardware(client_opts) do - interface_mode_oid = "1.3.6.1.4.1.17713.21.1.1.1.0" - sub_mode_oid = "1.3.6.1.4.1.17713.21.1.1.2.0" - - with {:ok, interface_mode} <- Client.get(client_opts, interface_mode_oid), - {:ok, sub_mode} <- Client.get(client_opts, sub_mode_oid) do - case {interface_mode, sub_mode} do - # AP mode (1) - {1, 5} -> "ePTP Master" - {1, _} -> "ePMP AP" - # SM mode (2) - {2, 4} -> "ePTP Slave" - {2, _} -> "ePMP SM" - _ -> nil - end - else - _ -> nil - end - end - defp format_firmware_version(%{vendor: vendor}, device_data) when vendor in ["MikroTik", "Mikrotik RouterOS"] do case {Map.get(device_data, :firmware_version), Map.get(device_data, :license_version)} do {fw, license} when not is_nil(fw) and not is_nil(license) -> @@ -401,277 +358,16 @@ defmodule Towerops.Snmp.Profiles.Dynamic do end # Get the wireless OID definitions for a profile (for debug data collection) - defp get_vendor_wireless_oids(%{name: name}) when name in ["airos", "airos-af", "airos-af60", "airos-af-ltu"] do - airos_wireless_oid_defs() - end - - defp get_vendor_wireless_oids(%{name: "epmp"}) do - epmp_wireless_oid_defs() + defp get_vendor_wireless_oids(%{name: name}) do + VendorRegistry.get_wireless_oid_defs(name) end defp get_vendor_wireless_oids(_profile), do: [] - # Vendor-specific wireless sensor discovery - # Mirrors LibreNMS PHP-based discovery (e.g., Airos.php, Epmp.php) - - defp discover_vendor_wireless_sensors(%{name: name}, client_opts) - when name in ["airos", "airos-af", "airos-af60", "airos-af-ltu"] do - discover_airos_wireless_sensors(client_opts) - end - - defp discover_vendor_wireless_sensors(%{name: "epmp"}, client_opts) do - discover_epmp_wireless_sensors(client_opts) + # Vendor-specific wireless sensor discovery using vendor modules + defp discover_vendor_wireless_sensors(%{name: name}, client_opts) do + VendorRegistry.discover_wireless_sensors(name, client_opts) end defp discover_vendor_wireless_sensors(_profile, _client_opts), do: [] - - # AirOS wireless sensors - mirrors LibreNMS/OS/Airos.php - # Implements: frequency, capacity, ccq, clients, distance, noise-floor, power, quality, rate, rssi, utilization - defp discover_airos_wireless_sensors(client_opts) do - fetch_wireless_sensors(airos_wireless_oid_defs(), client_opts) - end - - # AirOS wireless OID definitions (reused for discovery and debug data) - defp airos_wireless_oid_defs do - [ - # Frequency in MHz - %{ - oid: "1.3.6.1.4.1.41112.1.4.1.1.4.1", - sensor_type: "frequency", - sensor_descr: "Frequency", - sensor_unit: "MHz", - sensor_divisor: 1 - }, - # Capacity percentage - %{ - oid: "1.3.6.1.4.1.41112.1.4.6.1.4.1", - sensor_type: "capacity", - sensor_descr: "Capacity", - sensor_unit: "%", - sensor_divisor: 1 - }, - # Client Connection Quality - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.7.1", - sensor_type: "ccq", - sensor_descr: "CCQ", - sensor_unit: "%", - sensor_divisor: 1 - }, - # Connected clients count - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.15.1", - sensor_type: "clients", - sensor_descr: "Clients", - sensor_unit: "", - sensor_divisor: 1 - }, - # Distance in meters (value is in mm, divide by 1000) - %{ - oid: "1.3.6.1.4.1.41112.1.4.1.1.7.1", - sensor_type: "distance", - sensor_descr: "Distance", - sensor_unit: "m", - sensor_divisor: 1000 - }, - # Noise floor in dBm - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.8.1", - sensor_type: "noise-floor", - sensor_descr: "Noise Floor", - sensor_unit: "dBm", - sensor_divisor: 1 - }, - # TX Power in dBm - %{ - oid: "1.3.6.1.4.1.41112.1.4.1.1.6.1", - sensor_type: "power", - sensor_descr: "TX Power", - sensor_unit: "dBm", - sensor_divisor: 1 - }, - # Signal quality percentage - %{ - oid: "1.3.6.1.4.1.41112.1.4.6.1.3.1", - sensor_type: "quality", - sensor_descr: "Signal Quality", - sensor_unit: "%", - sensor_divisor: 1 - }, - # TX Rate in Mbps - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.9.1", - sensor_type: "rate-tx", - sensor_descr: "TX Rate", - sensor_unit: "Mbps", - sensor_divisor: 1 - }, - # RX Rate in Mbps - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.10.1", - sensor_type: "rate-rx", - sensor_descr: "RX Rate", - sensor_unit: "Mbps", - sensor_divisor: 1 - }, - # RSSI in dBm - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.6.1", - sensor_type: "rssi", - sensor_descr: "RSSI", - sensor_unit: "dBm", - sensor_divisor: 1 - }, - # Signal level in dBm - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.5.1", - sensor_type: "signal", - sensor_descr: "Signal Level", - sensor_unit: "dBm", - sensor_divisor: 1 - }, - # Utilization percentage (divide by 10) - %{ - oid: "1.3.6.1.4.1.41112.1.4.6.1.7.1", - sensor_type: "utilization", - sensor_descr: "Utilization", - sensor_unit: "%", - sensor_divisor: 10 - } - ] - end - - # ePMP wireless sensors - mirrors LibreNMS/OS/Epmp.php - # Implements: rssi, snr, frequency, clients - defp discover_epmp_wireless_sensors(client_opts) do - fetch_wireless_sensors(epmp_wireless_oid_defs(), client_opts) - end - - # ePMP wireless OID definitions (reused for discovery and debug data) - # Mirrors LibreNMS/OS/Epmp.php discoverWireless* and pollOS methods - defp epmp_wireless_oid_defs do - [ - # === Wireless sensors (from discoverWireless* methods) === - # RSSI in dBm (SM only - will be "No Such Instance" on AP) - %{ - oid: "1.3.6.1.4.1.17713.21.1.2.3.0", - sensor_type: "rssi", - sensor_descr: "RSSI", - sensor_unit: "dBm", - sensor_divisor: 1 - }, - # SNR in dB (SM only - will be "No Such Instance" on AP) - %{ - oid: "1.3.6.1.4.1.17713.21.1.2.18.0", - sensor_type: "snr", - sensor_descr: "SNR", - sensor_unit: "dB", - sensor_divisor: 1 - }, - # Frequency in MHz - %{ - oid: "1.3.6.1.4.1.17713.21.1.2.1.0", - sensor_type: "frequency", - sensor_descr: "Frequency", - sensor_unit: "MHz", - sensor_divisor: 1 - }, - # Connected clients count (AP only) - %{ - oid: "1.3.6.1.4.1.17713.21.1.2.10.0", - sensor_type: "clients", - sensor_descr: "Connected Clients", - sensor_unit: "", - sensor_divisor: 1 - }, - # === GPS sensors (from pollOS) === - # GPS Tracked Satellites - %{ - oid: "1.3.6.1.4.1.17713.21.1.2.64.0", - sensor_type: "count", - sensor_descr: "GPS Tracked Satellites", - sensor_unit: "", - sensor_divisor: 1 - }, - # GPS Visible Satellites - %{ - oid: "1.3.6.1.4.1.17713.21.1.2.65.0", - sensor_type: "count", - sensor_descr: "GPS Visible Satellites", - sensor_unit: "", - sensor_divisor: 1 - }, - # === Radio settings === - # Transmit Power in dBm - %{ - oid: "1.3.6.1.4.1.17713.21.1.2.5.0", - sensor_type: "power", - sensor_descr: "Transmit Power", - sensor_unit: "dBm", - sensor_divisor: 1 - }, - # Channel Width in MHz - %{ - oid: "1.3.6.1.4.1.17713.21.1.1.20.0", - sensor_type: "frequency", - sensor_descr: "Channel Width", - sensor_unit: "MHz", - sensor_divisor: 1 - }, - # === Network entry stats (from pollOS) === - # Network Entry Attempts - %{ - oid: "1.3.6.1.4.1.17713.21.2.1.33.0", - sensor_type: "count", - sensor_descr: "Network Entry Attempts", - sensor_unit: "", - sensor_divisor: 1 - }, - # Network Entry Success - %{ - oid: "1.3.6.1.4.1.17713.21.2.1.34.0", - sensor_type: "count", - sensor_descr: "Network Entry Success", - sensor_unit: "", - sensor_divisor: 1 - }, - # Authentication Failures - %{ - oid: "1.3.6.1.4.1.17713.21.2.1.35.0", - sensor_type: "count", - sensor_descr: "Auth Failures", - sensor_unit: "", - sensor_divisor: 1 - } - ] - end - - # Fetch wireless sensor values from scalar OIDs - defp fetch_wireless_sensors(sensor_defs, client_opts) do - sensor_defs - |> Enum.map(&fetch_wireless_sensor_value(&1, client_opts)) - |> Enum.reject(&is_nil/1) - end - - defp fetch_wireless_sensor_value(sensor_def, client_opts) do - case Client.get(client_opts, sensor_def.oid) do - {:ok, value} when is_integer(value) -> - # Use OID suffix to make sensor_index unique (avoids collisions like two "frequency" sensors) - oid_suffix = sensor_def.oid |> String.split(".") |> Enum.take(-2) |> Enum.join("_") - - %{ - sensor_type: sensor_def.sensor_type, - sensor_index: - "wireless_#{sensor_def.sensor_descr |> String.downcase() |> String.replace(" ", "_")}_#{oid_suffix}", - sensor_oid: sensor_def.oid, - sensor_descr: sensor_def.sensor_descr, - sensor_unit: sensor_def.sensor_unit, - sensor_divisor: sensor_def.sensor_divisor, - last_value: value / 1 - } - - _ -> - nil - end - end end diff --git a/lib/towerops/snmp/profiles/vendors/airos.ex b/lib/towerops/snmp/profiles/vendors/airos.ex new file mode 100644 index 00000000..55ea9cb8 --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/airos.ex @@ -0,0 +1,150 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Airos do + @moduledoc """ + Ubiquiti AirOS device-specific SNMP handling. + + Mirrors LibreNMS/OS/Airos.php for wireless sensor discovery and hardware detection. + + Supported devices: + - AirMax (LiteBeam, PowerBeam, NanoStation, etc.) + - AirFiber + - AirFiber 60GHz + - AirFiber LTU + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + # OID for hardware detection + @product_name_oid "1.2.840.10036.1.1.1.9.5" + + @impl true + def profile_names, do: ["airos", "airos-af", "airos-af60", "airos-af-ltu"] + + @impl true + def detect_hardware(client_opts) do + case Client.get(client_opts, @product_name_oid) do + {:ok, product_name} when is_binary(product_name) -> + product_name + + _ -> + nil + end + end + + @impl true + def discover_wireless_sensors(client_opts) do + Vendor.fetch_sensors(wireless_oid_defs(), client_opts) + end + + @impl true + def wireless_oid_defs do + [ + # Frequency in MHz + %{ + oid: "1.3.6.1.4.1.41112.1.4.1.1.4.1", + sensor_type: "frequency", + sensor_descr: "Frequency", + sensor_unit: "MHz", + sensor_divisor: 1 + }, + # Capacity in Mbps + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.9.1", + sensor_type: "rate", + sensor_descr: "Capacity", + sensor_unit: "Mbps", + sensor_divisor: 1 + }, + # CCQ (Client Connection Quality) % + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.7.1", + sensor_type: "ccq", + sensor_descr: "CCQ", + sensor_unit: "%", + sensor_divisor: 1 + }, + # Connected clients + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.15.1", + sensor_type: "clients", + sensor_descr: "Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # Distance in meters + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.11.1", + sensor_type: "distance", + sensor_descr: "Distance", + sensor_unit: "m", + sensor_divisor: 1 + }, + # Noise floor in dBm + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.8.1", + sensor_type: "noise-floor", + sensor_descr: "Noise Floor", + sensor_unit: "dBm", + sensor_divisor: 1 + }, + # Tx Power in dBm + %{ + oid: "1.3.6.1.4.1.41112.1.4.1.1.6.1", + sensor_type: "power", + sensor_descr: "Tx Power", + sensor_unit: "dBm", + sensor_divisor: 1 + }, + # Signal Quality % + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.5.1", + sensor_type: "quality", + sensor_descr: "Signal Quality", + sensor_unit: "%", + sensor_divisor: 1 + }, + # Tx Rate in Mbps + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.2.1", + sensor_type: "rate", + sensor_descr: "Tx Rate", + sensor_unit: "Mbps", + sensor_divisor: 1 + }, + # Rx Rate in Mbps + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.3.1", + sensor_type: "rate", + sensor_descr: "Rx Rate", + sensor_unit: "Mbps", + sensor_divisor: 1 + }, + # RSSI in dBm + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.5.1", + sensor_type: "rssi", + sensor_descr: "RSSI", + sensor_unit: "dBm", + sensor_divisor: 1 + }, + # Channel utilization Tx % + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.12.1", + sensor_type: "utilization", + sensor_descr: "Channel Utilization Tx", + sensor_unit: "%", + sensor_divisor: 1 + }, + # Channel utilization Rx % + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.13.1", + sensor_type: "utilization", + sensor_descr: "Channel Utilization Rx", + sensor_unit: "%", + sensor_divisor: 1 + } + ] + end +end diff --git a/lib/towerops/snmp/profiles/vendors/epmp.ex b/lib/towerops/snmp/profiles/vendors/epmp.ex new file mode 100644 index 00000000..9fe24890 --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/epmp.ex @@ -0,0 +1,145 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Epmp do + @moduledoc """ + Cambium ePMP device-specific SNMP handling. + + Mirrors LibreNMS/OS/Epmp.php for wireless sensor discovery and hardware detection. + + Supported devices: + - ePMP AP (Access Point) + - ePMP SM (Subscriber Module) + - ePTP Master/Slave + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + # OIDs for hardware detection + @interface_mode_oid "1.3.6.1.4.1.17713.21.1.1.1.0" + @sub_mode_oid "1.3.6.1.4.1.17713.21.1.1.2.0" + + @impl true + def profile_names, do: ["epmp"] + + @impl true + def detect_hardware(client_opts) do + with {:ok, interface_mode} <- Client.get(client_opts, @interface_mode_oid), + {:ok, sub_mode} <- Client.get(client_opts, @sub_mode_oid) do + case {interface_mode, sub_mode} do + # AP mode + {1, 5} -> "ePTP Master" + {1, _} -> "ePMP AP" + # SM mode + {2, 4} -> "ePTP Slave" + {2, _} -> "ePMP SM" + _ -> nil + end + else + _ -> nil + end + end + + @impl true + def discover_wireless_sensors(client_opts) do + Vendor.fetch_sensors(wireless_oid_defs(), client_opts) + end + + @impl true + def wireless_oid_defs do + [ + # === Wireless sensors (from discoverWireless* methods) === + # RSSI in dBm (SM only - will be "No Such Instance" on AP) + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.3.0", + sensor_type: "rssi", + sensor_descr: "RSSI", + sensor_unit: "dBm", + sensor_divisor: 1 + }, + # SNR in dB (SM only - will be "No Such Instance" on AP) + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.18.0", + sensor_type: "snr", + sensor_descr: "SNR", + sensor_unit: "dB", + sensor_divisor: 1 + }, + # Frequency in MHz + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.1.0", + sensor_type: "frequency", + sensor_descr: "Frequency", + sensor_unit: "MHz", + sensor_divisor: 1 + }, + # Connected clients count (AP only) + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.10.0", + sensor_type: "clients", + sensor_descr: "Connected Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # === GPS sensors (from pollOS) === + # GPS Tracked Satellites + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.64.0", + sensor_type: "count", + sensor_descr: "GPS Tracked Satellites", + sensor_unit: "", + sensor_divisor: 1 + }, + # GPS Visible Satellites + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.65.0", + sensor_type: "count", + sensor_descr: "GPS Visible Satellites", + sensor_unit: "", + sensor_divisor: 1 + }, + # === Radio settings === + # Transmit Power in dBm + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.5.0", + sensor_type: "power", + sensor_descr: "Transmit Power", + sensor_unit: "dBm", + sensor_divisor: 1 + }, + # Channel Width in MHz + %{ + oid: "1.3.6.1.4.1.17713.21.1.1.20.0", + sensor_type: "frequency", + sensor_descr: "Channel Width", + sensor_unit: "MHz", + sensor_divisor: 1 + }, + # === Network entry stats (from pollOS) === + # Network Entry Attempts + %{ + oid: "1.3.6.1.4.1.17713.21.2.1.33.0", + sensor_type: "count", + sensor_descr: "Network Entry Attempts", + sensor_unit: "", + sensor_divisor: 1 + }, + # Network Entry Success + %{ + oid: "1.3.6.1.4.1.17713.21.2.1.34.0", + sensor_type: "count", + sensor_descr: "Network Entry Success", + sensor_unit: "", + sensor_divisor: 1 + }, + # Authentication Failures + %{ + oid: "1.3.6.1.4.1.17713.21.2.1.35.0", + sensor_type: "count", + sensor_descr: "Auth Failures", + sensor_unit: "", + sensor_divisor: 1 + } + ] + end +end diff --git a/lib/towerops/snmp/profiles/vendors/registry.ex b/lib/towerops/snmp/profiles/vendors/registry.ex new file mode 100644 index 00000000..8cb1bc8c --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/registry.ex @@ -0,0 +1,76 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Registry do + @moduledoc """ + Registry for vendor-specific SNMP modules. + + Maps profile names to their vendor module implementations, + similar to how LibreNMS maps OS names to PHP classes. + """ + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Airos + alias Towerops.Snmp.Profiles.Vendors.Epmp + alias Towerops.Snmp.Profiles.Vendors.Routeros + + @vendors [Epmp, Airos, Routeros] + + @doc """ + Get the vendor module for a given profile name. + Returns nil if no vendor-specific module exists. + """ + @spec get_vendor(String.t()) :: module() | nil + def get_vendor(profile_name) do + Enum.find(@vendors, fn vendor -> + profile_name in vendor.profile_names() + end) + end + + @doc """ + List all registered vendor modules. + """ + @spec list_vendors() :: [module()] + def list_vendors, do: @vendors + + @doc """ + List all profile names handled by vendor modules. + """ + @spec list_profile_names() :: [String.t()] + def list_profile_names do + Enum.flat_map(@vendors, & &1.profile_names()) + end + + @doc """ + Detect hardware using the appropriate vendor module. + Returns nil if no vendor module matches or detection fails. + """ + @spec detect_hardware(String.t(), Client.connection_opts()) :: String.t() | nil + def detect_hardware(profile_name, client_opts) do + case get_vendor(profile_name) do + nil -> nil + vendor -> vendor.detect_hardware(client_opts) + end + end + + @doc """ + Discover wireless sensors using the appropriate vendor module. + Returns empty list if no vendor module matches. + """ + @spec discover_wireless_sensors(String.t(), Client.connection_opts()) :: [map()] + def discover_wireless_sensors(profile_name, client_opts) do + case get_vendor(profile_name) do + nil -> [] + vendor -> vendor.discover_wireless_sensors(client_opts) + end + end + + @doc """ + Get wireless OID definitions for debug data collection. + Returns empty list if no vendor module matches. + """ + @spec get_wireless_oid_defs(String.t()) :: [map()] + def get_wireless_oid_defs(profile_name) do + case get_vendor(profile_name) do + nil -> [] + vendor -> vendor.wireless_oid_defs() + end + end +end diff --git a/lib/towerops/snmp/profiles/vendors/routeros.ex b/lib/towerops/snmp/profiles/vendors/routeros.ex new file mode 100644 index 00000000..81c43846 --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/routeros.ex @@ -0,0 +1,577 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Routeros do + @moduledoc """ + MikroTik RouterOS device-specific SNMP handling. + + Mirrors LibreNMS/OS/Routeros.php for wireless sensor discovery and hardware detection. + + Supported features: + - Wireless AP/Station sensors (CCQ, clients, frequency, noise floor, rate) + - 60GHz wireless sensors (RSSI, quality, distance) + - LTE modem sensors (RSRQ, RSRP, SINR) + - DHCP leases and PPPoE sessions + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + require Logger + + # OID for license level (used in firmware version display) + @license_oid "1.3.6.1.4.1.14988.1.1.4.4.0" + + # Table OIDs for walking + @wl_ap_table "1.3.6.1.4.1.14988.1.1.1.3.1" + @wl_stat_table "1.3.6.1.4.1.14988.1.1.1.1.1" + @wl_60g_table "1.3.6.1.4.1.14988.1.1.1.8.1" + @lte_modem_table "1.3.6.1.4.1.14988.1.1.16.1.1" + + @impl true + def profile_names, do: ["routeros"] + + @impl true + def detect_hardware(client_opts) do + # MikroTik hardware is typically identified by sysDescr + # We enhance the firmware version with license level + case Client.get(client_opts, @license_oid) do + {:ok, license} when is_integer(license) -> + "RouterOS Level #{license}" + + _ -> + nil + end + end + + @impl true + def discover_wireless_sensors(client_opts) do + # Discover from multiple tables like LibreNMS does + sensors = + discover_ap_sensors(client_opts) ++ + discover_station_sensors(client_opts) ++ + discover_60g_sensors(client_opts) ++ + discover_lte_sensors(client_opts) ++ + discover_system_sensors(client_opts) + + Logger.debug("RouterOS: Discovered #{length(sensors)} wireless sensors") + sensors + end + + @impl true + def wireless_oid_defs do + # Scalar OIDs for basic discovery (tables are walked separately) + [ + # DHCP Leases + %{ + oid: "1.3.6.1.4.1.14988.1.1.6.1.0", + sensor_type: "count", + sensor_descr: "DHCP Leases", + sensor_unit: "", + sensor_divisor: 1 + }, + # PPPoE Sessions (Cisco OID that RouterOS also supports) + %{ + oid: "1.3.6.1.4.1.9.9.150.1.1.1.0", + sensor_type: "count", + sensor_descr: "PPPoE Sessions", + sensor_unit: "", + sensor_divisor: 1 + } + ] + end + + # Discover sensors from mtxrWlApTable (AP mode) + defp discover_ap_sensors(client_opts) do + case Client.walk(client_opts, @wl_ap_table) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + parse_ap_table(results) + + _ -> + [] + end + end + + defp parse_ap_table(results) do + # Group by index (last OID component) + grouped = group_by_index(results) + + Enum.flat_map(grouped, fn {idx, values} -> + ssid = get_string_value(values, 5) || "wlan#{idx}" + freq = get_int_value(values, 7) + freq_band = if freq && freq > 0, do: "#{div(freq, 1000)}G", else: "" + prefix = if freq_band == "", do: ssid, else: "#{freq_band}: #{ssid}" + + build_ap_sensors(idx, prefix, values) + end) + end + + defp build_ap_sensors(idx, prefix, values) do + sensors = [] + + # Client count (.6) + sensors = + if client_count = get_int_value(values, 6) do + [ + %{ + sensor_type: "clients", + sensor_index: "mikrotik_ap_clients_#{idx}", + sensor_oid: "#{@wl_ap_table}.6.#{idx}", + sensor_descr: "#{prefix} Clients", + sensor_unit: "", + sensor_divisor: 1, + last_value: client_count / 1 + } + | sensors + ] + else + sensors + end + + # Frequency (.7) + sensors = + if freq = get_int_value(values, 7) do + [ + %{ + sensor_type: "frequency", + sensor_index: "mikrotik_ap_freq_#{idx}", + sensor_oid: "#{@wl_ap_table}.7.#{idx}", + sensor_descr: "#{prefix} Frequency", + sensor_unit: "MHz", + sensor_divisor: 1, + last_value: freq / 1 + } + | sensors + ] + else + sensors + end + + # Noise floor (.9) + sensors = + if noise = get_int_value(values, 9) do + [ + %{ + sensor_type: "noise-floor", + sensor_index: "mikrotik_ap_noise_#{idx}", + sensor_oid: "#{@wl_ap_table}.9.#{idx}", + sensor_descr: "#{prefix} Noise Floor", + sensor_unit: "dBm", + sensor_divisor: 1, + last_value: noise / 1 + } + | sensors + ] + else + sensors + end + + # CCQ (.10) + sensors = + if ccq = get_int_value(values, 10) do + [ + %{ + sensor_type: "ccq", + sensor_index: "mikrotik_ap_ccq_#{idx}", + sensor_oid: "#{@wl_ap_table}.10.#{idx}", + sensor_descr: "#{prefix} CCQ", + sensor_unit: "%", + sensor_divisor: 1, + last_value: ccq / 1 + } + | sensors + ] + else + sensors + end + + # Tx Rate (.2) + sensors = + if tx_rate = get_int_value(values, 2) do + [ + %{ + sensor_type: "rate", + sensor_index: "mikrotik_ap_tx_rate_#{idx}", + sensor_oid: "#{@wl_ap_table}.2.#{idx}", + sensor_descr: "#{prefix} Tx Rate", + sensor_unit: "bps", + sensor_divisor: 1, + last_value: tx_rate / 1 + } + | sensors + ] + else + sensors + end + + # Rx Rate (.3) + sensors = + if rx_rate = get_int_value(values, 3) do + [ + %{ + sensor_type: "rate", + sensor_index: "mikrotik_ap_rx_rate_#{idx}", + sensor_oid: "#{@wl_ap_table}.3.#{idx}", + sensor_descr: "#{prefix} Rx Rate", + sensor_unit: "bps", + sensor_divisor: 1, + last_value: rx_rate / 1 + } + | sensors + ] + else + sensors + end + + sensors + end + + # Discover sensors from mtxrWlStatTable (Station mode) + defp discover_station_sensors(client_opts) do + case Client.walk(client_opts, @wl_stat_table) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + parse_station_table(results) + + _ -> + [] + end + end + + defp parse_station_table(results) do + grouped = group_by_index(results) + + Enum.flat_map(grouped, fn {idx, values} -> + ssid = get_string_value(values, 5) || "wlan#{idx}" + freq = get_int_value(values, 7) + freq_band = if freq && freq > 0, do: "#{div(freq, 1000)}G", else: "" + prefix = if freq_band == "", do: ssid, else: "#{freq_band}: #{ssid}" + + build_station_sensors(idx, prefix, values) + end) + end + + defp build_station_sensors(idx, prefix, values) do + sensors = [] + + # Frequency (.7) + sensors = + if freq = get_int_value(values, 7) do + [ + %{ + sensor_type: "frequency", + sensor_index: "mikrotik_sta_freq_#{idx}", + sensor_oid: "#{@wl_stat_table}.7.#{idx}", + sensor_descr: "#{prefix} Frequency", + sensor_unit: "MHz", + sensor_divisor: 1, + last_value: freq / 1 + } + | sensors + ] + else + sensors + end + + # Tx CCQ (.9) + sensors = + if tx_ccq = get_int_value(values, 9) do + [ + %{ + sensor_type: "ccq", + sensor_index: "mikrotik_sta_tx_ccq_#{idx}", + sensor_oid: "#{@wl_stat_table}.9.#{idx}", + sensor_descr: "#{prefix} Tx CCQ", + sensor_unit: "%", + sensor_divisor: 1, + last_value: tx_ccq / 1 + } + | sensors + ] + else + sensors + end + + # Rx CCQ (.10) + sensors = + if rx_ccq = get_int_value(values, 10) do + [ + %{ + sensor_type: "ccq", + sensor_index: "mikrotik_sta_rx_ccq_#{idx}", + sensor_oid: "#{@wl_stat_table}.10.#{idx}", + sensor_descr: "#{prefix} Rx CCQ", + sensor_unit: "%", + sensor_divisor: 1, + last_value: rx_ccq / 1 + } + | sensors + ] + else + sensors + end + + # Tx Rate (.2) + sensors = + if tx_rate = get_int_value(values, 2) do + [ + %{ + sensor_type: "rate", + sensor_index: "mikrotik_sta_tx_rate_#{idx}", + sensor_oid: "#{@wl_stat_table}.2.#{idx}", + sensor_descr: "#{prefix} Tx Rate", + sensor_unit: "bps", + sensor_divisor: 1, + last_value: tx_rate / 1 + } + | sensors + ] + else + sensors + end + + # Rx Rate (.3) + sensors = + if rx_rate = get_int_value(values, 3) do + [ + %{ + sensor_type: "rate", + sensor_index: "mikrotik_sta_rx_rate_#{idx}", + sensor_oid: "#{@wl_stat_table}.3.#{idx}", + sensor_descr: "#{prefix} Rx Rate", + sensor_unit: "bps", + sensor_divisor: 1, + last_value: rx_rate / 1 + } + | sensors + ] + else + sensors + end + + sensors + end + + # Discover sensors from mtxrWl60GTable (60GHz wireless) + defp discover_60g_sensors(client_opts) do + case Client.walk(client_opts, @wl_60g_table) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + parse_60g_table(results) + + _ -> + [] + end + end + + defp parse_60g_table(results) do + grouped = group_by_index(results) + + Enum.flat_map(grouped, fn {idx, values} -> + ssid = get_string_value(values, 2) || "60G-#{idx}" + prefix = "60G: #{ssid}" + + build_60g_sensors(idx, prefix, values) + end) + end + + defp build_60g_sensors(idx, prefix, values) do + sensors = [] + + # Frequency (.6) + sensors = + if freq = get_int_value(values, 6) do + [ + %{ + sensor_type: "frequency", + sensor_index: "mikrotik_60g_freq_#{idx}", + sensor_oid: "#{@wl_60g_table}.6.#{idx}", + sensor_descr: "#{prefix} Frequency", + sensor_unit: "MHz", + sensor_divisor: 1, + last_value: freq / 1 + } + | sensors + ] + else + sensors + end + + # Signal Quality (.8) + sensors = + if quality = get_int_value(values, 8) do + [ + %{ + sensor_type: "quality", + sensor_index: "mikrotik_60g_quality_#{idx}", + sensor_oid: "#{@wl_60g_table}.8.#{idx}", + sensor_descr: "#{prefix} Signal Quality", + sensor_unit: "%", + sensor_divisor: 1, + last_value: quality / 1 + } + | sensors + ] + else + sensors + end + + # RSSI (.12) + sensors = + if rssi = get_int_value(values, 12) do + [ + %{ + sensor_type: "rssi", + sensor_index: "mikrotik_60g_rssi_#{idx}", + sensor_oid: "#{@wl_60g_table}.12.#{idx}", + sensor_descr: "#{prefix} RSSI", + sensor_unit: "dBm", + sensor_divisor: 1, + last_value: rssi / 1 + } + | sensors + ] + else + sensors + end + + # Phy Rate (.13) + sensors = + if phy_rate = get_int_value(values, 13) do + [ + %{ + sensor_type: "rate", + sensor_index: "mikrotik_60g_phy_rate_#{idx}", + sensor_oid: "#{@wl_60g_table}.13.#{idx}", + sensor_descr: "#{prefix} Phy Rate", + sensor_unit: "Mbps", + sensor_divisor: 1, + last_value: phy_rate / 1 + } + | sensors + ] + else + sensors + end + + sensors + end + + # Discover sensors from mtxrLTEModemTable + defp discover_lte_sensors(client_opts) do + case Client.walk(client_opts, @lte_modem_table) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + parse_lte_table(results) + + _ -> + [] + end + end + + defp parse_lte_table(results) do + grouped = group_by_index(results) + + Enum.flat_map(grouped, fn {idx, values} -> + prefix = "LTE #{idx}" + build_lte_sensors(idx, prefix, values) + end) + end + + defp build_lte_sensors(idx, prefix, values) do + sensors = [] + + # RSRQ (.3) + sensors = + if rsrq = get_int_value(values, 3) do + [ + %{ + sensor_type: "rsrq", + sensor_index: "mikrotik_lte_rsrq_#{idx}", + sensor_oid: "#{@lte_modem_table}.3.#{idx}", + sensor_descr: "#{prefix} RSRQ", + sensor_unit: "dB", + sensor_divisor: 1, + last_value: rsrq / 1 + } + | sensors + ] + else + sensors + end + + # RSRP (.4) + sensors = + if rsrp = get_int_value(values, 4) do + [ + %{ + sensor_type: "rsrp", + sensor_index: "mikrotik_lte_rsrp_#{idx}", + sensor_oid: "#{@lte_modem_table}.4.#{idx}", + sensor_descr: "#{prefix} RSRP", + sensor_unit: "dBm", + sensor_divisor: 1, + last_value: rsrp / 1 + } + | sensors + ] + else + sensors + end + + # SINR (.7) + sensors = + if sinr = get_int_value(values, 7) do + [ + %{ + sensor_type: "sinr", + sensor_index: "mikrotik_lte_sinr_#{idx}", + sensor_oid: "#{@lte_modem_table}.7.#{idx}", + sensor_descr: "#{prefix} SINR", + sensor_unit: "dB", + sensor_divisor: 1, + last_value: sinr / 1 + } + | sensors + ] + else + sensors + end + + sensors + end + + # Discover system-level sensors (DHCP leases, PPPoE sessions) + defp discover_system_sensors(client_opts) do + Vendor.fetch_sensors(wireless_oid_defs(), client_opts) + end + + # Helper: Group OID results by their index (last component) + defp group_by_index(results) do + Enum.reduce(results, %{}, fn {oid, value}, acc -> + parts = String.split(oid, ".") + # Get the sub-OID (second to last) and index (last) + {sub_oid, idx} = + case Enum.take(parts, -2) do + [sub, index] -> {String.to_integer(sub), index} + _ -> {0, "0"} + end + + Map.update(acc, idx, %{sub_oid => value}, fn existing -> + Map.put(existing, sub_oid, value) + end) + end) + end + + # Helper: Get integer value from grouped results + defp get_int_value(values, sub_oid) do + case Map.get(values, sub_oid) do + value when is_integer(value) and value != 0 -> value + _ -> nil + end + end + + # Helper: Get string value from grouped results + defp get_string_value(values, sub_oid) do + case Map.get(values, sub_oid) do + value when is_binary(value) and value != "" -> value + _ -> nil + end + end +end diff --git a/lib/towerops/snmp/profiles/vendors/vendor.ex b/lib/towerops/snmp/profiles/vendors/vendor.ex new file mode 100644 index 00000000..1bd0f82e --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/vendor.ex @@ -0,0 +1,130 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Vendor do + @moduledoc """ + Behaviour for vendor-specific SNMP device handling. + + Each vendor module (Epmp, Airos, Routeros, etc.) implements this behaviour + to provide device-specific discovery logic, mirroring LibreNMS OS PHP classes. + """ + + alias Towerops.Snmp.Client + + @type sensor :: %{ + sensor_type: String.t(), + sensor_index: String.t(), + sensor_oid: String.t(), + sensor_descr: String.t(), + sensor_unit: String.t(), + sensor_divisor: integer(), + last_value: float() | nil + } + + @type sensor_def :: %{ + oid: String.t(), + sensor_type: String.t(), + sensor_descr: String.t(), + sensor_unit: String.t(), + sensor_divisor: integer() + } + + @doc """ + Returns the list of profile names this vendor module handles. + """ + @callback profile_names() :: [String.t()] + + @doc """ + Detect hardware type/model from SNMP data. + Returns a string describing the hardware (e.g., "ePMP AP", "MikroTik hAP ac2"). + """ + @callback detect_hardware(Client.connection_opts()) :: String.t() | nil + + @doc """ + Discover wireless sensors specific to this vendor. + Returns a list of sensor maps ready to be saved to the database. + """ + @callback discover_wireless_sensors(Client.connection_opts()) :: [sensor()] + + @doc """ + Returns the list of wireless OID definitions for debug data collection. + """ + @callback wireless_oid_defs() :: [sensor_def()] + + # Helper functions shared by all vendor modules + + @doc """ + Fetch wireless sensor values from scalar OIDs. + """ + @spec fetch_sensors([sensor_def()], Client.connection_opts()) :: [sensor()] + def fetch_sensors(sensor_defs, client_opts) do + sensor_defs + |> Enum.map(&fetch_sensor_value(&1, client_opts)) + |> Enum.reject(&is_nil/1) + end + + @doc """ + Fetch a single sensor value from a scalar OID. + """ + @spec fetch_sensor_value(sensor_def(), Client.connection_opts()) :: sensor() | nil + def fetch_sensor_value(sensor_def, client_opts) do + case Client.get(client_opts, sensor_def.oid) do + {:ok, value} when is_integer(value) -> + # Use OID suffix to make sensor_index unique + oid_suffix = sensor_def.oid |> String.split(".") |> Enum.take(-2) |> Enum.join("_") + descr_slug = sensor_def.sensor_descr |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "_") + + %{ + sensor_type: sensor_def.sensor_type, + sensor_index: "wireless_#{descr_slug}_#{oid_suffix}", + sensor_oid: sensor_def.oid, + sensor_descr: sensor_def.sensor_descr, + sensor_unit: sensor_def.sensor_unit, + sensor_divisor: sensor_def.sensor_divisor, + last_value: value / 1 + } + + _ -> + nil + end + end + + @doc """ + Walk an SNMP table and create sensors from the results. + Used for per-interface or per-client wireless sensors. + """ + @spec walk_table_sensors(String.t(), sensor_def(), Client.connection_opts()) :: [sensor()] + def walk_table_sensors(base_oid, sensor_def, client_opts) do + case Client.walk(client_opts, base_oid) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + results + |> Enum.with_index(1) + |> Enum.map(fn {{oid, value}, idx} -> + build_table_sensor(sensor_def, oid, value, idx) + end) + |> Enum.reject(&is_nil/1) + + {:ok, results} when is_list(results) and results != [] -> + results + |> Enum.with_index(1) + |> Enum.map(fn {%{oid: oid, value: value}, idx} -> + build_table_sensor(sensor_def, oid, value, idx) + end) + |> Enum.reject(&is_nil/1) + + _ -> + [] + end + end + + defp build_table_sensor(sensor_def, oid, value, idx) when is_integer(value) do + %{ + sensor_type: sensor_def.sensor_type, + sensor_index: "#{sensor_def.sensor_type}_#{idx}", + sensor_oid: oid, + sensor_descr: "#{sensor_def.sensor_descr} #{idx}", + sensor_unit: sensor_def.sensor_unit, + sensor_divisor: sensor_def.sensor_divisor, + last_value: value / 1 + } + end + + defp build_table_sensor(_sensor_def, _oid, _value, _idx), do: nil +end