From 928055cbe3450626886c5bf3f121efb1c2ad540b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 22 Jan 2026 10:12:49 -0600 Subject: [PATCH] feat: add FortiAP, D-Link AP, and Moxa AWK vendor modules --- lib/towerops/snmp/profiles/vendors/dlinkap.ex | 73 ++++++++++++ lib/towerops/snmp/profiles/vendors/fortiap.ex | 82 +++++++++++++ .../snmp/profiles/vendors/moxa_awk.ex | 73 ++++++++++++ .../snmp/profiles/vendors/registry.ex | 6 + .../snmp/profiles/vendors/dlinkap_test.exs | 105 +++++++++++++++++ .../snmp/profiles/vendors/fortiap_test.exs | 109 ++++++++++++++++++ .../snmp/profiles/vendors/moxa_awk_test.exs | 108 +++++++++++++++++ .../snmp/profiles/vendors/registry_test.exs | 21 ++++ 8 files changed, 577 insertions(+) create mode 100644 lib/towerops/snmp/profiles/vendors/dlinkap.ex create mode 100644 lib/towerops/snmp/profiles/vendors/fortiap.ex create mode 100644 lib/towerops/snmp/profiles/vendors/moxa_awk.ex create mode 100644 test/towerops/snmp/profiles/vendors/dlinkap_test.exs create mode 100644 test/towerops/snmp/profiles/vendors/fortiap_test.exs create mode 100644 test/towerops/snmp/profiles/vendors/moxa_awk_test.exs diff --git a/lib/towerops/snmp/profiles/vendors/dlinkap.ex b/lib/towerops/snmp/profiles/vendors/dlinkap.ex new file mode 100644 index 00000000..796ee872 --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/dlinkap.ex @@ -0,0 +1,73 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Dlinkap do + @moduledoc """ + D-Link Access Point device-specific SNMP handling. + + Supports D-Link wireless access points. + + Supported devices: + - DAP series (DAP-2660, DAP-2695, etc.) + - DWL series + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + # D-Link Enterprise OID: 1.3.6.1.4.1.171 + @model_oid "1.3.6.1.4.1.171.10.37.1.1.1.0" + + @impl true + def profile_names, do: ["dlinkap"] + + @impl true + def detect_hardware(client_opts) do + case Client.get(client_opts, @model_oid) do + {:ok, model} when is_binary(model) -> model + _ -> 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 + [ + # Total Associated Clients + %{ + oid: "1.3.6.1.4.1.171.10.37.1.2.1.0", + sensor_type: "clients", + sensor_descr: "Associated Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # 2.4GHz Radio Clients + %{ + oid: "1.3.6.1.4.1.171.10.37.1.2.2.0", + sensor_type: "clients", + sensor_descr: "2.4GHz Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # 5GHz Radio Clients + %{ + oid: "1.3.6.1.4.1.171.10.37.1.2.3.0", + sensor_type: "clients", + sensor_descr: "5GHz Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # CPU Utilization + %{ + oid: "1.3.6.1.4.1.171.10.37.1.3.1.0", + sensor_type: "load", + sensor_descr: "CPU Utilization", + sensor_unit: "%", + sensor_divisor: 1 + } + ] + end +end diff --git a/lib/towerops/snmp/profiles/vendors/fortiap.ex b/lib/towerops/snmp/profiles/vendors/fortiap.ex new file mode 100644 index 00000000..ca3996a0 --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/fortiap.ex @@ -0,0 +1,82 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Fortiap do + @moduledoc """ + Fortinet FortiAP device-specific SNMP handling. + + Supports FortiAP wireless access points. + + Supported devices: + - FortiAP series (221E, 231F, 431F, etc.) + - FortiAP-S series + - FortiAP-U series + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + # Fortinet Enterprise OID: 1.3.6.1.4.1.12356 + @model_oid "1.3.6.1.4.1.12356.120.10.1.1.0" + + @impl true + def profile_names, do: ["fortiap"] + + @impl true + def detect_hardware(client_opts) do + case Client.get(client_opts, @model_oid) do + {:ok, model} when is_binary(model) -> model + _ -> 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 + [ + # Total Associated Clients + %{ + oid: "1.3.6.1.4.1.12356.120.10.2.1.0", + sensor_type: "clients", + sensor_descr: "Associated Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # CPU Utilization + %{ + oid: "1.3.6.1.4.1.12356.120.10.3.1.0", + sensor_type: "load", + sensor_descr: "CPU Utilization", + sensor_unit: "%", + sensor_divisor: 1 + }, + # Memory Utilization + %{ + oid: "1.3.6.1.4.1.12356.120.10.3.2.0", + sensor_type: "load", + sensor_descr: "Memory Utilization", + sensor_unit: "%", + sensor_divisor: 1 + }, + # 2.4GHz Radio Clients + %{ + oid: "1.3.6.1.4.1.12356.120.10.2.2.0", + sensor_type: "clients", + sensor_descr: "2.4GHz Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # 5GHz Radio Clients + %{ + oid: "1.3.6.1.4.1.12356.120.10.2.3.0", + sensor_type: "clients", + sensor_descr: "5GHz Clients", + sensor_unit: "", + sensor_divisor: 1 + } + ] + end +end diff --git a/lib/towerops/snmp/profiles/vendors/moxa_awk.ex b/lib/towerops/snmp/profiles/vendors/moxa_awk.ex new file mode 100644 index 00000000..bfc215ae --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/moxa_awk.ex @@ -0,0 +1,73 @@ +defmodule Towerops.Snmp.Profiles.Vendors.MoxaAwk do + @moduledoc """ + Moxa AWK industrial wireless device-specific SNMP handling. + + Supports Moxa industrial wireless access points and clients. + + Supported devices: + - AWK series (AWK-3131A, AWK-4131A, etc.) + - AWK-1137C industrial wireless + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + # Moxa Enterprise OID: 1.3.6.1.4.1.8691 + @model_oid "1.3.6.1.4.1.8691.15.34.1.1.0" + + @impl true + def profile_names, do: ["moxa-awk"] + + @impl true + def detect_hardware(client_opts) do + case Client.get(client_opts, @model_oid) do + {:ok, model} when is_binary(model) -> model + _ -> 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 + [ + # Associated Clients + %{ + oid: "1.3.6.1.4.1.8691.15.34.2.1.0", + sensor_type: "clients", + sensor_descr: "Associated Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # CPU Utilization + %{ + oid: "1.3.6.1.4.1.8691.15.34.3.1.0", + sensor_type: "load", + sensor_descr: "CPU Utilization", + sensor_unit: "%", + sensor_divisor: 1 + }, + # Memory Utilization + %{ + oid: "1.3.6.1.4.1.8691.15.34.3.2.0", + sensor_type: "load", + sensor_descr: "Memory Utilization", + sensor_unit: "%", + sensor_divisor: 1 + }, + # Wireless Signal Strength + %{ + oid: "1.3.6.1.4.1.8691.15.34.2.2.0", + sensor_type: "rssi", + sensor_descr: "Signal Strength", + sensor_unit: "dBm", + sensor_divisor: 1 + } + ] + end +end diff --git a/lib/towerops/snmp/profiles/vendors/registry.ex b/lib/towerops/snmp/profiles/vendors/registry.ex index 1eb131f6..962028c5 100644 --- a/lib/towerops/snmp/profiles/vendors/registry.ex +++ b/lib/towerops/snmp/profiles/vendors/registry.ex @@ -22,13 +22,16 @@ defmodule Towerops.Snmp.Profiles.Vendors.Registry do alias Towerops.Snmp.Profiles.Vendors.Cnpilot alias Towerops.Snmp.Profiles.Vendors.Cnwave60 alias Towerops.Snmp.Profiles.Vendors.Deliberant + alias Towerops.Snmp.Profiles.Vendors.Dlinkap alias Towerops.Snmp.Profiles.Vendors.Dragonwave alias Towerops.Snmp.Profiles.Vendors.Engenius alias Towerops.Snmp.Profiles.Vendors.Epmp alias Towerops.Snmp.Profiles.Vendors.Exalt + alias Towerops.Snmp.Profiles.Vendors.Fortiap alias Towerops.Snmp.Profiles.Vendors.Grandstream alias Towerops.Snmp.Profiles.Vendors.Ligoos alias Towerops.Snmp.Profiles.Vendors.Mimosa + alias Towerops.Snmp.Profiles.Vendors.MoxaAwk alias Towerops.Snmp.Profiles.Vendors.Netonix alias Towerops.Snmp.Profiles.Vendors.Pmp alias Towerops.Snmp.Profiles.Vendors.Proxim @@ -63,13 +66,16 @@ defmodule Towerops.Snmp.Profiles.Vendors.Registry do Cnpilot, Cnwave60, Deliberant, + Dlinkap, Dragonwave, Engenius, Epmp, Exalt, + Fortiap, Grandstream, Ligoos, Mimosa, + MoxaAwk, Netonix, Pmp, Proxim, diff --git a/test/towerops/snmp/profiles/vendors/dlinkap_test.exs b/test/towerops/snmp/profiles/vendors/dlinkap_test.exs new file mode 100644 index 00000000..95a4eb2b --- /dev/null +++ b/test/towerops/snmp/profiles/vendors/dlinkap_test.exs @@ -0,0 +1,105 @@ +defmodule Towerops.Snmp.Profiles.Vendors.DlinkapTest do + use Towerops.DataCase, async: true + + import Mox + + alias Towerops.Snmp.Profiles.Vendors.Dlinkap + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + @client_opts [ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + timeout: 5000 + ] + + describe "profile_names/0" do + test "returns dlinkap profile name" do + assert Dlinkap.profile_names() == ["dlinkap"] + end + end + + describe "detect_hardware/1" do + test "detects hardware from model OID" do + expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.171.10.37.1.1.1.0", _ -> + {:ok, "DAP-2660"} + end) + + assert Dlinkap.detect_hardware(@client_opts) == "DAP-2660" + end + + test "returns nil when OID unavailable" do + expect(SnmpMock, :get, fn _, _, _ -> + {:error, :no_such_object} + end) + + assert Dlinkap.detect_hardware(@client_opts) == nil + end + end + + describe "wireless_oid_defs/0" do + test "returns list of wireless sensor definitions" do + defs = Dlinkap.wireless_oid_defs() + + assert is_list(defs) + assert [_ | _] = defs + end + + test "includes associated clients sensor" do + defs = Dlinkap.wireless_oid_defs() + clients = Enum.find(defs, &(&1.sensor_descr == "Associated Clients")) + + assert clients + assert clients.sensor_type == "clients" + end + + test "includes CPU sensor" do + defs = Dlinkap.wireless_oid_defs() + cpu = Enum.find(defs, &(&1.sensor_descr == "CPU Utilization")) + + assert cpu + assert cpu.sensor_type == "load" + end + + test "includes band-specific client sensors" do + defs = Dlinkap.wireless_oid_defs() + clients_2g = Enum.find(defs, &(&1.sensor_descr == "2.4GHz Clients")) + clients_5g = Enum.find(defs, &(&1.sensor_descr == "5GHz Clients")) + + assert clients_2g + assert clients_5g + end + end + + describe "discover_wireless_sensors/1" do + test "discovers sensors when SNMP responds" do + expect(SnmpMock, :get, 4, fn _, oid, _ -> + cond do + String.contains?(oid, "171.10.37.1.2.1.0") -> {:ok, 85} + String.contains?(oid, "171.10.37.1.2.2.0") -> {:ok, 40} + String.contains?(oid, "171.10.37.1.2.3.0") -> {:ok, 45} + String.contains?(oid, "171.10.37.1.3.1.0") -> {:ok, 30} + true -> {:error, :no_such_object} + end + end) + + sensors = Dlinkap.discover_wireless_sensors(@client_opts) + + assert is_list(sensors) + assert length(sensors) == 4 + end + + test "returns empty list when no sensors respond" do + expect(SnmpMock, :get, 4, fn _, _, _ -> + {:error, :no_such_object} + end) + + sensors = Dlinkap.discover_wireless_sensors(@client_opts) + + assert sensors == [] + end + end +end diff --git a/test/towerops/snmp/profiles/vendors/fortiap_test.exs b/test/towerops/snmp/profiles/vendors/fortiap_test.exs new file mode 100644 index 00000000..9e955261 --- /dev/null +++ b/test/towerops/snmp/profiles/vendors/fortiap_test.exs @@ -0,0 +1,109 @@ +defmodule Towerops.Snmp.Profiles.Vendors.FortiapTest do + use Towerops.DataCase, async: true + + import Mox + + alias Towerops.Snmp.Profiles.Vendors.Fortiap + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + @client_opts [ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + timeout: 5000 + ] + + describe "profile_names/0" do + test "returns fortiap profile name" do + assert Fortiap.profile_names() == ["fortiap"] + end + end + + describe "detect_hardware/1" do + test "detects hardware from model OID" do + expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.12356.120.10.1.1.0", _ -> + {:ok, "FAP-431F"} + end) + + assert Fortiap.detect_hardware(@client_opts) == "FAP-431F" + end + + test "returns nil when OID unavailable" do + expect(SnmpMock, :get, fn _, _, _ -> + {:error, :no_such_object} + end) + + assert Fortiap.detect_hardware(@client_opts) == nil + end + end + + describe "wireless_oid_defs/0" do + test "returns list of wireless sensor definitions" do + defs = Fortiap.wireless_oid_defs() + + assert is_list(defs) + assert [_ | _] = defs + end + + test "includes associated clients sensor" do + defs = Fortiap.wireless_oid_defs() + clients = Enum.find(defs, &(&1.sensor_descr == "Associated Clients")) + + assert clients + assert clients.sensor_type == "clients" + end + + test "includes CPU and memory sensors" do + defs = Fortiap.wireless_oid_defs() + cpu = Enum.find(defs, &(&1.sensor_descr == "CPU Utilization")) + mem = Enum.find(defs, &(&1.sensor_descr == "Memory Utilization")) + + assert cpu + assert cpu.sensor_type == "load" + assert mem + assert mem.sensor_type == "load" + end + + test "includes band-specific client sensors" do + defs = Fortiap.wireless_oid_defs() + clients_2g = Enum.find(defs, &(&1.sensor_descr == "2.4GHz Clients")) + clients_5g = Enum.find(defs, &(&1.sensor_descr == "5GHz Clients")) + + assert clients_2g + assert clients_5g + end + end + + describe "discover_wireless_sensors/1" do + test "discovers sensors when SNMP responds" do + expect(SnmpMock, :get, 5, fn _, oid, _ -> + cond do + String.contains?(oid, "12356.120.10.2.1.0") -> {:ok, 120} + String.contains?(oid, "12356.120.10.3.1.0") -> {:ok, 25} + String.contains?(oid, "12356.120.10.3.2.0") -> {:ok, 55} + String.contains?(oid, "12356.120.10.2.2.0") -> {:ok, 50} + String.contains?(oid, "12356.120.10.2.3.0") -> {:ok, 70} + true -> {:error, :no_such_object} + end + end) + + sensors = Fortiap.discover_wireless_sensors(@client_opts) + + assert is_list(sensors) + assert length(sensors) == 5 + end + + test "returns empty list when no sensors respond" do + expect(SnmpMock, :get, 5, fn _, _, _ -> + {:error, :no_such_object} + end) + + sensors = Fortiap.discover_wireless_sensors(@client_opts) + + assert sensors == [] + end + end +end diff --git a/test/towerops/snmp/profiles/vendors/moxa_awk_test.exs b/test/towerops/snmp/profiles/vendors/moxa_awk_test.exs new file mode 100644 index 00000000..c3b7095b --- /dev/null +++ b/test/towerops/snmp/profiles/vendors/moxa_awk_test.exs @@ -0,0 +1,108 @@ +defmodule Towerops.Snmp.Profiles.Vendors.MoxaAwkTest do + use Towerops.DataCase, async: true + + import Mox + + alias Towerops.Snmp.Profiles.Vendors.MoxaAwk + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + @client_opts [ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + timeout: 5000 + ] + + describe "profile_names/0" do + test "returns moxa-awk profile name" do + assert MoxaAwk.profile_names() == ["moxa-awk"] + end + end + + describe "detect_hardware/1" do + test "detects hardware from model OID" do + expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.8691.15.34.1.1.0", _ -> + {:ok, "AWK-3131A"} + end) + + assert MoxaAwk.detect_hardware(@client_opts) == "AWK-3131A" + end + + test "returns nil when OID unavailable" do + expect(SnmpMock, :get, fn _, _, _ -> + {:error, :no_such_object} + end) + + assert MoxaAwk.detect_hardware(@client_opts) == nil + end + end + + describe "wireless_oid_defs/0" do + test "returns list of wireless sensor definitions" do + defs = MoxaAwk.wireless_oid_defs() + + assert is_list(defs) + assert [_ | _] = defs + end + + test "includes associated clients sensor" do + defs = MoxaAwk.wireless_oid_defs() + clients = Enum.find(defs, &(&1.sensor_descr == "Associated Clients")) + + assert clients + assert clients.sensor_type == "clients" + end + + test "includes CPU and memory sensors" do + defs = MoxaAwk.wireless_oid_defs() + cpu = Enum.find(defs, &(&1.sensor_descr == "CPU Utilization")) + mem = Enum.find(defs, &(&1.sensor_descr == "Memory Utilization")) + + assert cpu + assert cpu.sensor_type == "load" + assert mem + assert mem.sensor_type == "load" + end + + test "includes signal strength sensor" do + defs = MoxaAwk.wireless_oid_defs() + signal = Enum.find(defs, &(&1.sensor_descr == "Signal Strength")) + + assert signal + assert signal.sensor_type == "rssi" + assert signal.sensor_unit == "dBm" + end + end + + describe "discover_wireless_sensors/1" do + test "discovers sensors when SNMP responds" do + expect(SnmpMock, :get, 4, fn _, oid, _ -> + cond do + String.contains?(oid, "8691.15.34.2.1.0") -> {:ok, 12} + String.contains?(oid, "8691.15.34.3.1.0") -> {:ok, 25} + String.contains?(oid, "8691.15.34.3.2.0") -> {:ok, 60} + String.contains?(oid, "8691.15.34.2.2.0") -> {:ok, -65} + true -> {:error, :no_such_object} + end + end) + + sensors = MoxaAwk.discover_wireless_sensors(@client_opts) + + assert is_list(sensors) + assert length(sensors) == 4 + end + + test "returns empty list when no sensors respond" do + expect(SnmpMock, :get, 4, fn _, _, _ -> + {:error, :no_such_object} + end) + + sensors = MoxaAwk.discover_wireless_sensors(@client_opts) + + assert sensors == [] + end + end +end diff --git a/test/towerops/snmp/profiles/vendors/registry_test.exs b/test/towerops/snmp/profiles/vendors/registry_test.exs index 34cdfdc7..604e1834 100644 --- a/test/towerops/snmp/profiles/vendors/registry_test.exs +++ b/test/towerops/snmp/profiles/vendors/registry_test.exs @@ -9,11 +9,14 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do alias Towerops.Snmp.Profiles.Vendors.Alvarion alias Towerops.Snmp.Profiles.Vendors.Cnpilot alias Towerops.Snmp.Profiles.Vendors.Deliberant + alias Towerops.Snmp.Profiles.Vendors.Dlinkap alias Towerops.Snmp.Profiles.Vendors.Dragonwave alias Towerops.Snmp.Profiles.Vendors.Epmp alias Towerops.Snmp.Profiles.Vendors.Exalt + alias Towerops.Snmp.Profiles.Vendors.Fortiap alias Towerops.Snmp.Profiles.Vendors.Ligoos alias Towerops.Snmp.Profiles.Vendors.Mimosa + alias Towerops.Snmp.Profiles.Vendors.MoxaAwk alias Towerops.Snmp.Profiles.Vendors.Pmp alias Towerops.Snmp.Profiles.Vendors.Proxim alias Towerops.Snmp.Profiles.Vendors.Racom @@ -68,6 +71,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do assert Stellar in vendors assert Xirrus in vendors assert ZyxelWlc in vendors + assert Dlinkap in vendors + assert Fortiap in vendors + assert MoxaAwk in vendors end end @@ -101,6 +107,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do assert "stellar" in names assert "xirrus_aos" in names assert "zyxelwlc" in names + assert "dlinkap" in names + assert "fortiap" in names + assert "moxa-awk" in names end end @@ -201,6 +210,18 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do assert Registry.get_vendor("zyxelwlc") == ZyxelWlc end + test "returns Dlinkap for dlinkap profile" do + assert Registry.get_vendor("dlinkap") == Dlinkap + end + + test "returns Fortiap for fortiap profile" do + assert Registry.get_vendor("fortiap") == Fortiap + end + + test "returns MoxaAwk for moxa-awk profile" do + assert Registry.get_vendor("moxa-awk") == MoxaAwk + end + test "returns nil for unknown profile" do assert Registry.get_vendor("unknown_vendor") == nil end