fix: correct AirFiber proprietary OID mapping and v1-only device support (#170)

Root cause: AF11X devices only support SNMPv1, IF-MIB eth0 counters
return zero, and air0 32-bit counters wrap too fast for backhaul speeds.

Fixes:
- Use correct UBNT-AirFIBER-MIB OID indices from MIB SEQUENCE definition:
  .7.1 = rxOctetsOK (inbound), .6.1 = txOctetsOK (outbound)
  .10.1 = rxErroredFrames, .11.1 = txErroredFrames
- Detect AirFiber devices by sysDescr instead of SNMP probing (v1 devices
  cannot return Counter64 via standard probe queries)
- Force SNMPv1 for AirFiber proprietary queries
- Apply proprietary counters to all real interfaces (not just eth0)

Verified against live AF11X at 10.250.1.90:
- IF-MIB eth0 returns zero counters
- Proprietary .6.1/.7.1 show real traffic (3.75TB rx / 43TB tx)

Reviewed-on: graham/towerops-web#170
This commit is contained in:
Graham McIntire 2026-03-25 15:02:45 -05:00 committed by graham
parent ce682991fe
commit 3d5c74e7ae
3 changed files with 64 additions and 20 deletions

View file

@ -23,9 +23,32 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airfiber do
end
@impl true
def detect_hardware(_client_opts) do
# Hardware detection handled by YAML profile
nil
def detect_hardware(client_opts) do
# The YAML profile tries IEEE802dot11-MIB::dot11manufacturerProductName.5
# but many AirFiber devices (AF11X, AF24) don't support that OID.
# Fall back to sysName which often contains the model (e.g., "Climax-380 AF24").
# Force v1 since these devices may not support v2c.
v1_opts = Keyword.put(client_opts, :version, "1")
case Client.get(v1_opts, "1.3.6.1.2.1.1.5.0") do
{:ok, sys_name} when is_binary(sys_name) and sys_name != "" ->
"Ubiquiti AirFiber (#{sys_name})"
_ ->
"Ubiquiti AirFiber"
end
end
@doc """
Detects firmware version from the AirFiber status table.
OID 1.3.6.1.4.1.41112.1.3.2.1.40.1 returns the firmware version string (e.g., "v4.1.0").
"""
def detect_version(client_opts) do
v1_opts = Keyword.put(client_opts, :version, "1")
case Client.get(v1_opts, "1.3.6.1.4.1.41112.1.3.2.1.40.1") do
{:ok, version} when is_binary(version) and version != "" -> version
_ -> nil
end
end
@impl true

View file

@ -1042,23 +1042,32 @@ defmodule Towerops.Workers.DevicePollerWorker do
# because their IF-MIB counters are unreliable.
defp detect_airfiber_counter_overrides(nil, _client_opts), do: nil
defp detect_airfiber_counter_overrides(snmp_device, _client_opts) do
defp detect_airfiber_counter_overrides(snmp_device, client_opts) do
sys_oid = snmp_device.sys_object_id || ""
sys_descr = String.downcase(snmp_device.sys_descr || "")
cond do
# Ubiquiti AirFiber — detect by sysObjectID (enterprise 41112) or sysDescr
# These devices need proprietary MIBs because IF-MIB eth0 counters are zero
# and air0 32-bit counters wrap too fast on high-bandwidth links.
# Note: detection is based on device identity, not SNMP probing, because
# some AirFiber devices only support SNMPv1 (can't return Counter64 via probe).
String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") and String.contains?(sys_descr, "airfiber") ->
:airfiber
# Ubiquiti uses two enterprise OIDs:
# - 1.3.6.1.4.1.41112 (new UBNT enterprise OID)
# - 1.3.6.1.4.1.10002 (old UBNT enterprise OID, used by AirFiber AF11/AF24)
is_ubnt = String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or
String.starts_with?(sys_oid, "1.3.6.1.4.1.10002")
String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") and String.contains?(sys_descr, "afltu") ->
:airfiber_ltu
if is_ubnt do
# Probe for the AirFiber statistics table — if it exists, this device
# needs proprietary counters. Use v1 since these devices may not support v2c.
v1_opts = Keyword.put(client_opts, :version, "1")
true -> nil
# Check for LTU first (more specific)
case Client.get(v1_opts, "1.3.6.1.4.1.41112.1.10.1.2.2.0") do
{:ok, val} when val != nil -> :airfiber_ltu
_ ->
# Check for regular AirFiber stats table (index field)
case Client.get(v1_opts, "1.3.6.1.4.1.41112.1.3.3.1.1.1") do
{:ok, val} when val != nil -> :airfiber
_ -> nil
end
end
else
nil
end
end
@ -1078,8 +1087,6 @@ defmodule Towerops.Workers.DevicePollerWorker do
# 8=txPauseFrames, 9=rxPauseFrames, 10=rxErroredFrames, 11=txErroredFrames
#
# These devices may only support SNMPv1 — use v1 for all queries.
# Counter64 values returned via v1 vary by implementation; the Ubiquiti
# firmware handles it despite the RFC saying Counter64 is v2c+ only.
v1_opts = Keyword.put(client_opts, :version, "1")
oids = [

View file

@ -23,8 +23,22 @@ defmodule Towerops.Snmp.Profiles.Vendors.AirfiberTest do
end
describe "detect_hardware/1" do
test "returns nil (hardware detection handled by YAML)" do
assert Airfiber.detect_hardware(@client_opts) == nil
test "returns hardware string with sysName when available" do
SnmpMock
|> expect(:get, fn _target, "1.3.6.1.2.1.1.5.0", _opts ->
{:ok, {:octet_string, "Climax-380 AF24"}}
end)
assert Airfiber.detect_hardware(@client_opts) == "Ubiquiti AirFiber (Climax-380 AF24)"
end
test "returns generic hardware string when sysName unavailable" do
SnmpMock
|> expect(:get, fn _target, "1.3.6.1.2.1.1.5.0", _opts ->
{:error, :timeout}
end)
assert Airfiber.detect_hardware(@client_opts) == "Ubiquiti AirFiber"
end
end