snmp overhaul
This commit is contained in:
parent
94311a48aa
commit
adec4b134f
20 changed files with 931 additions and 97 deletions
|
|
@ -81,7 +81,7 @@ FROM ${RUNNER_IMAGE} AS final
|
|||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates snmp snmp-mibs-downloader \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the locale
|
||||
|
|
@ -101,6 +101,10 @@ ENV MIX_ENV="prod"
|
|||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/towerops ./
|
||||
|
||||
# Copy MIB files for SNMP MIB name resolution
|
||||
# LibreNMS MIBs will be mounted or copied separately
|
||||
RUN mkdir -p /app/mibs
|
||||
|
||||
USER nobody
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
|
|
|
|||
|
|
@ -100,5 +100,8 @@ config :towerops, :redis,
|
|||
host: "localhost",
|
||||
port: 6379
|
||||
|
||||
# Configure SNMP polling interval for development (60 seconds)
|
||||
config :towerops, :snmp_min_poll_interval, 60
|
||||
|
||||
# Enable dev routes for dashboard and mailbox
|
||||
config :towerops, dev_routes: true
|
||||
|
|
|
|||
|
|
@ -124,3 +124,11 @@ spec:
|
|||
timeoutSeconds: 2
|
||||
successThreshold: 1
|
||||
failureThreshold: 2
|
||||
volumeMounts:
|
||||
- name: mibs
|
||||
mountPath: /app/mibs
|
||||
readOnly: false
|
||||
volumes:
|
||||
- name: mibs
|
||||
persistentVolumeClaim:
|
||||
claimName: towerops-mibs
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ kind: Kustomization
|
|||
namespace: towerops
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- mib-pvc.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- service-headless.yaml
|
||||
|
|
|
|||
13
k8s/mib-pvc.yaml
Normal file
13
k8s/mib-pvc.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: towerops-mibs
|
||||
namespace: towerops
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce # Ceph RBD only supports ReadWriteOnce
|
||||
storageClassName: ceph-rbd # Use the default Ceph RBD storage class
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi # MIB files are typically small (few hundred MB)
|
||||
|
|
@ -195,12 +195,30 @@ defmodule Towerops.DeviceProfiles do
|
|||
end
|
||||
|
||||
defp list_profiles_with_detection_rules do
|
||||
Repo.all(
|
||||
from p in DeviceProfile,
|
||||
where: p.enabled == true,
|
||||
order_by: [asc: p.priority],
|
||||
preload: [:detection_rules]
|
||||
)
|
||||
# Load all enabled profiles with detection rules
|
||||
profiles =
|
||||
Repo.all(
|
||||
from p in DeviceProfile,
|
||||
where: p.enabled == true,
|
||||
preload: [:detection_rules]
|
||||
)
|
||||
|
||||
# Sort profiles by:
|
||||
# 1. Priority (ascending - lower number = higher priority)
|
||||
# 2. Pattern specificity (descending - longer pattern = more specific)
|
||||
# This ensures more specific patterns like RouterOS (.1.3.6.1.4.1.14988.1)
|
||||
# are checked before generic patterns like Supermicro (.1.3.6.1.4.1.)
|
||||
Enum.sort_by(profiles, fn profile ->
|
||||
max_pattern_length = get_max_pattern_length(profile.detection_rules)
|
||||
{profile.priority, -max_pattern_length}
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_max_pattern_length(rules) do
|
||||
rules
|
||||
|> Enum.filter(&(&1.rule_type == "sysObjectID"))
|
||||
|> Enum.map(fn rule -> String.length(rule.pattern || "") end)
|
||||
|> Enum.max(fn -> 0 end)
|
||||
end
|
||||
|
||||
defp match_detection_rules?(rules, system_info) do
|
||||
|
|
|
|||
|
|
@ -334,6 +334,10 @@ defmodule Towerops.DeviceProfiles.Importer do
|
|||
has_valid_oid = present?(sensor["oid"]) || present?(sensor["num_oid"])
|
||||
|
||||
if has_valid_oid do
|
||||
# Calculate divisor with validation
|
||||
raw_divisor = sensor["divisor"] || calculate_divisor(sensor["precision"])
|
||||
divisor = validate_divisor(raw_divisor)
|
||||
|
||||
attrs = %{
|
||||
device_profile_id: profile_id,
|
||||
sensor_class: sensor_class,
|
||||
|
|
@ -341,7 +345,7 @@ defmodule Towerops.DeviceProfiles.Importer do
|
|||
num_oid: nilify_blank(sensor["num_oid"]),
|
||||
index: to_string(sensor["index"] || ""),
|
||||
descr: sensor["descr"],
|
||||
divisor: calculate_divisor(sensor["precision"]),
|
||||
divisor: divisor,
|
||||
precision: sensor["precision"],
|
||||
sensor_type: sensor["type"],
|
||||
low_limit: parse_float(sensor["low_limit"]),
|
||||
|
|
@ -426,6 +430,32 @@ defmodule Towerops.DeviceProfiles.Importer do
|
|||
defp present?(""), do: false
|
||||
defp present?(_), do: true
|
||||
|
||||
# Validate divisor to ensure it fits in PostgreSQL integer range
|
||||
# PostgreSQL integer: -2147483648 to 2147483647
|
||||
@max_integer 2_147_483_647
|
||||
|
||||
defp validate_divisor(divisor) when is_integer(divisor) do
|
||||
cond do
|
||||
divisor > @max_integer ->
|
||||
Logger.warning("Divisor #{divisor} exceeds PostgreSQL integer limit, capping at #{@max_integer}")
|
||||
@max_integer
|
||||
|
||||
divisor < -@max_integer ->
|
||||
Logger.warning("Divisor #{divisor} below PostgreSQL integer limit, capping at -#{@max_integer}")
|
||||
-@max_integer
|
||||
|
||||
true ->
|
||||
divisor
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_divisor(divisor) when is_float(divisor) do
|
||||
validate_divisor(trunc(divisor))
|
||||
end
|
||||
|
||||
defp validate_divisor(nil), do: 1
|
||||
defp validate_divisor(_), do: 1
|
||||
|
||||
# Convert OID values, preserving all data types since the field is now JSONB
|
||||
# Handles:
|
||||
# - Empty strings -> nil
|
||||
|
|
|
|||
|
|
@ -107,7 +107,19 @@ defmodule Towerops.Devices do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Gets a single device.
|
||||
Gets a single device by ID, returns nil if not found.
|
||||
"""
|
||||
def get_device(id) do
|
||||
DeviceSchema
|
||||
|> Repo.get(id)
|
||||
|> case do
|
||||
nil -> nil
|
||||
device -> Repo.preload(device, [:site])
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single device by ID, raises if not found.
|
||||
"""
|
||||
def get_device!(id) do
|
||||
DeviceSchema
|
||||
|
|
|
|||
|
|
@ -372,7 +372,11 @@ defmodule Towerops.Snmp.Discovery do
|
|||
|
||||
@spec insert_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
|
||||
defp insert_sensors(device, sensors) do
|
||||
Enum.map(sensors, fn sensor_data ->
|
||||
# Deduplicate sensors by sensor_index (keep first occurrence)
|
||||
# This prevents duplicate key errors when profiles generate duplicate indices
|
||||
sensors
|
||||
|> Enum.uniq_by(& &1.sensor_index)
|
||||
|> Enum.map(fn sensor_data ->
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
|
||||
|> Repo.insert!()
|
||||
|
|
|
|||
84
lib/towerops/snmp/mib_translator.ex
Normal file
84
lib/towerops/snmp/mib_translator.ex
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
defmodule Towerops.Snmp.MibTranslator do
|
||||
@moduledoc """
|
||||
Translates SNMP MIB symbolic names to numeric OIDs using net-snmp's snmptranslate.
|
||||
|
||||
Uses the system's snmptranslate command to resolve MIB names like
|
||||
"MIKROTIK-MIB::mtxrSerialNumber.0" to numeric OIDs like "1.3.6.1.4.1.14988.1.1.7.3.0".
|
||||
|
||||
Requires net-snmp package to be installed and MIB files to be available.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Translates a MIB symbolic name to a numeric OID.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> translate("MIKROTIK-MIB::mtxrSerialNumber.0")
|
||||
{:ok, "1.3.6.1.4.1.14988.1.1.7.3.0"}
|
||||
|
||||
iex> translate("SNMPv2-MIB::sysDescr.0")
|
||||
{:ok, "1.3.6.1.2.1.1.1.0"}
|
||||
|
||||
iex> translate("1.3.6.1.2.1.1.1.0")
|
||||
{:ok, "1.3.6.1.2.1.1.1.0"}
|
||||
|
||||
iex> translate("INVALID-MIB::badObject")
|
||||
{:error, :translation_failed}
|
||||
"""
|
||||
@spec translate(String.t()) :: {:ok, String.t()} | {:error, :translation_failed}
|
||||
def translate(mib_name) when is_binary(mib_name) do
|
||||
# If it's already a numeric OID, return it as-is
|
||||
if numeric_oid?(mib_name) do
|
||||
{:ok, mib_name}
|
||||
else
|
||||
translate_mib_name(mib_name)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates multiple MIB names to numeric OIDs in a single batch.
|
||||
|
||||
Returns a map of mib_name => {:ok, oid} or {:error, reason}.
|
||||
"""
|
||||
@spec translate_batch([String.t()]) :: %{String.t() => {:ok, String.t()} | {:error, term()}}
|
||||
def translate_batch(mib_names) when is_list(mib_names) do
|
||||
Map.new(mib_names, fn name ->
|
||||
{name, translate(name)}
|
||||
end)
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp numeric_oid?(string) do
|
||||
String.match?(string, ~r/^\.?\d+(\.\d+)*$/)
|
||||
end
|
||||
|
||||
defp translate_mib_name(mib_name) do
|
||||
# Get MIB directories from config or use default
|
||||
mib_dirs = Application.get_env(:towerops, :mib_dirs, ["/app/mibs", "/usr/share/snmp/mibs"])
|
||||
mib_dir_args = Enum.flat_map(mib_dirs, fn dir -> ["-M", "+#{dir}"] end)
|
||||
|
||||
# Use snmptranslate to convert MIB name to numeric OID
|
||||
# -On: Output numeric OID
|
||||
# -IR: Random access to MIB registry
|
||||
args = ["-On", "-IR"] ++ mib_dir_args ++ [mib_name]
|
||||
|
||||
case System.cmd("snmptranslate", args, stderr_to_stdout: true) do
|
||||
{output, 0} ->
|
||||
# snmptranslate returns the numeric OID, sometimes with a leading dot
|
||||
oid = String.trim(output)
|
||||
{:ok, oid}
|
||||
|
||||
{error_output, _exit_code} ->
|
||||
Logger.warning("Failed to translate MIB name #{mib_name}: #{error_output}")
|
||||
{:error, :translation_failed}
|
||||
end
|
||||
rescue
|
||||
e in ErlangError ->
|
||||
# snmptranslate command not found or other system error
|
||||
Logger.error("snmptranslate command failed: #{inspect(e)}")
|
||||
{:error, :command_not_found}
|
||||
end
|
||||
end
|
||||
|
|
@ -54,7 +54,8 @@ defmodule Towerops.Snmp.Poller do
|
|||
ip: device.ip_address,
|
||||
community: device.snmp_community,
|
||||
version: device.snmp_version,
|
||||
port: device.snmp_port || 161
|
||||
port: device.snmp_port || 161,
|
||||
timeout: 5000
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
|
||||
defp poll_sensors(sensors, client_opts, timestamp) do
|
||||
# Poll sensors in parallel with max_concurrency to avoid overwhelming the device
|
||||
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||
sensors
|
||||
|> Task.async_stream(
|
||||
fn sensor ->
|
||||
|
|
@ -233,7 +234,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
handle_sensor_poll_result(sensor, result, timestamp)
|
||||
end,
|
||||
max_concurrency: 2,
|
||||
timeout: 10_000,
|
||||
timeout: 40_000,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Stream.run()
|
||||
|
|
@ -358,6 +359,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
|
||||
defp poll_interfaces(interfaces, client_opts, timestamp) do
|
||||
# Poll interfaces in parallel with max_concurrency
|
||||
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||
interfaces
|
||||
|> Task.async_stream(
|
||||
fn interface ->
|
||||
|
|
@ -373,7 +375,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
Snmp.create_interface_stat(stats)
|
||||
end,
|
||||
max_concurrency: 2,
|
||||
timeout: 10_000,
|
||||
timeout: 40_000,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Stream.run()
|
||||
|
|
@ -381,6 +383,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
|
||||
defp check_interface_changes(interfaces, device, client_opts, timestamp) do
|
||||
# Check interface changes in parallel
|
||||
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||
interfaces
|
||||
|> Task.async_stream(
|
||||
fn interface ->
|
||||
|
|
@ -393,7 +396,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
end
|
||||
end,
|
||||
max_concurrency: 2,
|
||||
timeout: 10_000,
|
||||
timeout: 40_000,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Stream.run()
|
||||
|
|
@ -725,8 +728,9 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
end
|
||||
|
||||
defp get_poll_interval(device) do
|
||||
# Use check_interval_seconds, but minimum of 30 seconds for SNMP polling
|
||||
max(device.check_interval_seconds || @default_poll_interval, 30)
|
||||
# Use check_interval_seconds, with configurable minimum for SNMP polling
|
||||
min_interval = Application.get_env(:towerops, :snmp_min_poll_interval, 300)
|
||||
max(device.check_interval_seconds || @default_poll_interval, min_interval)
|
||||
end
|
||||
|
||||
defp schedule_next_poll(interval_seconds) do
|
||||
|
|
|
|||
|
|
@ -86,12 +86,13 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
indices = if_indices |> Map.values() |> Enum.map(&parse_integer/1)
|
||||
|
||||
# Fetch all interface data in parallel
|
||||
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||
interface_data =
|
||||
indices
|
||||
|> Task.async_stream(
|
||||
fn index -> fetch_interface_data(client_opts, index) end,
|
||||
max_concurrency: 10,
|
||||
timeout: 10_000
|
||||
timeout: 40_000
|
||||
)
|
||||
|> Enum.map(fn
|
||||
{:ok, {:ok, interface}} -> interface
|
||||
|
|
|
|||
|
|
@ -18,24 +18,34 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
Identifies device using the database profile information.
|
||||
Returns manufacturer, model, and firmware info.
|
||||
|
||||
Polls SNMP OIDs from OS definitions to extract version and serial number.
|
||||
Delegates to hardcoded vendor profiles when available for accurate device identification.
|
||||
Falls back to database OS definitions with MIB name translation.
|
||||
"""
|
||||
@spec identify_device(DeviceProfile.t(), Client.connection_opts(), Discovery.system_info()) ::
|
||||
Discovery.device_info()
|
||||
def identify_device(profile, client_opts, _system_info) do
|
||||
def identify_device(profile, client_opts, system_info) do
|
||||
# Load profile with OS definitions
|
||||
profile = DeviceProfiles.get_profile_with_associations(profile.os)
|
||||
|
||||
# Poll OS definitions for version, serial, etc.
|
||||
os_data = poll_os_definitions(profile, client_opts)
|
||||
# Prefer hardcoded vendor profiles when available for accurate identification
|
||||
# They use numeric OIDs and vendor-specific logic
|
||||
case delegate_to_vendor_profile(profile, client_opts, system_info) do
|
||||
{:ok, device_info} ->
|
||||
Logger.debug("Using vendor-specific profile for device identification")
|
||||
device_info
|
||||
|
||||
# Use profile metadata to build device info
|
||||
%{
|
||||
manufacturer: extract_manufacturer(profile),
|
||||
model: profile.text || profile.os,
|
||||
firmware_version: os_data[:version] || profile.os,
|
||||
serial_number: os_data[:serial]
|
||||
}
|
||||
:no_vendor_profile ->
|
||||
# Fall back to database-driven identification with MIB translation
|
||||
Logger.debug("Using database profile with MIB translation for device identification")
|
||||
os_data = poll_os_definitions(profile, client_opts)
|
||||
|
||||
%{
|
||||
manufacturer: extract_manufacturer(profile),
|
||||
model: profile.text || profile.os,
|
||||
firmware_version: os_data[:version] || os_data[:firmware_version] || profile.os,
|
||||
serial_number: os_data[:serial]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -88,13 +98,59 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
|
||||
# Private functions
|
||||
|
||||
defp poll_os_definitions(_profile, _client_opts) do
|
||||
# Note: OS definition polling is currently disabled because device profiles
|
||||
# use MIB symbolic names (e.g., CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0)
|
||||
# which require MIB compilation to resolve to numeric OIDs.
|
||||
# The SNMP client only supports numeric OIDs.
|
||||
# Future enhancement: Add MIB resolution or import numeric OIDs during profile import
|
||||
%{}
|
||||
# Delegate device identification to hardcoded vendor profiles when available
|
||||
# This allows us to use vendor-specific logic with numeric OIDs for proper identification
|
||||
defp delegate_to_vendor_profile(profile, _client_opts, system_info) do
|
||||
case profile.group do
|
||||
"mikrotik" ->
|
||||
# Use hardcoded MikroTik profile for device identification
|
||||
alias Towerops.Snmp.Profiles.Mikrotik
|
||||
|
||||
device_info = Mikrotik.identify_device(system_info)
|
||||
{:ok, device_info}
|
||||
|
||||
_ ->
|
||||
:no_vendor_profile
|
||||
end
|
||||
end
|
||||
|
||||
defp poll_os_definitions(profile, client_opts) do
|
||||
alias Towerops.Snmp.MibTranslator
|
||||
|
||||
# Get all OS definitions for this profile
|
||||
os_defs = profile.os_definitions || []
|
||||
|
||||
# Poll each OS definition and build a map of field => value
|
||||
Map.new(os_defs, fn os_def ->
|
||||
value =
|
||||
if os_def.oid do
|
||||
# Translate MIB name to numeric OID if needed
|
||||
case MibTranslator.translate(os_def.oid) do
|
||||
{:ok, numeric_oid} ->
|
||||
# Poll the numeric OID
|
||||
case Client.get(client_opts, numeric_oid) do
|
||||
{:ok, result} -> result
|
||||
{:error, _} -> nil
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
Logger.debug("Failed to translate MIB name: #{os_def.oid}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Convert field name to atom for the map key
|
||||
field_atom =
|
||||
case os_def.field do
|
||||
"version" -> :version
|
||||
"serial" -> :serial
|
||||
"hardware" -> :hardware
|
||||
"features" -> :features
|
||||
field -> String.to_atom(field)
|
||||
end
|
||||
|
||||
{field_atom, value}
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_manufacturer(%DeviceProfile{group: group}) when is_binary(group) do
|
||||
|
|
@ -116,9 +172,15 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
oid = proc_def.num_oid || proc_def.oid
|
||||
|
||||
if oid do
|
||||
# Remove template variables from OID
|
||||
walk_oid = clean_oid_template(oid)
|
||||
walk_processor_oid(client_opts, walk_oid, proc_def)
|
||||
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
|
||||
if String.contains?(oid, "::") do
|
||||
Logger.debug("Skipping symbolic MIB name in processor: #{oid}")
|
||||
[]
|
||||
else
|
||||
# Remove template variables from OID
|
||||
walk_oid = clean_oid_template(oid)
|
||||
walk_processor_oid(client_opts, walk_oid, proc_def)
|
||||
end
|
||||
else
|
||||
Logger.warning("Processor definition missing OID: #{inspect(proc_def)}")
|
||||
[]
|
||||
|
|
@ -157,10 +219,20 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
end
|
||||
|
||||
if oid_string do
|
||||
# Remove template variables like ".{{ $index }}" from OID
|
||||
# For walks, we need the base OID without the index placeholder
|
||||
walk_oid = clean_oid_template(oid_string)
|
||||
walk_sensor_oid(client_opts, walk_oid, sensor_def)
|
||||
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
|
||||
if String.contains?(oid_string, "::") do
|
||||
Logger.debug("Skipping symbolic MIB name: #{oid_string}")
|
||||
[]
|
||||
else
|
||||
# Remove template variables like ".{{ $index }}" from OID
|
||||
# For walks, we need the base OID without the index placeholder
|
||||
walk_oid = clean_oid_template(oid_string)
|
||||
|
||||
# Check if descr references another OID column (e.g., MIKROTIK-MIB::mtxrGaugeName)
|
||||
descr_map = maybe_walk_descr_oid(client_opts, walk_oid, sensor_def.descr)
|
||||
|
||||
walk_sensor_oid(client_opts, walk_oid, sensor_def, descr_map)
|
||||
end
|
||||
else
|
||||
Logger.warning("Sensor definition has invalid OID format: #{inspect(oid)}")
|
||||
[]
|
||||
|
|
@ -171,12 +243,30 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
end
|
||||
end
|
||||
|
||||
defp walk_sensor_oid(client_opts, walk_oid, sensor_def) do
|
||||
defp walk_sensor_oid(client_opts, walk_oid, sensor_def, descr_map) do
|
||||
case Client.walk(client_opts, walk_oid) do
|
||||
{:ok, results} when is_map(results) and map_size(results) > 0 ->
|
||||
# Convert walk results (map) to sensor data
|
||||
Enum.map(results, fn {result_oid, value} ->
|
||||
build_sensor_data(sensor_def, %{oid: result_oid, value: value})
|
||||
# Get skip_values filtering rules from sensor definition options
|
||||
skip_values = get_skip_values(sensor_def)
|
||||
|
||||
# Walk filter OID if skip_values are defined
|
||||
{filter_map, descr_map_with_fallback} =
|
||||
if skip_values == [] do
|
||||
{nil, descr_map}
|
||||
else
|
||||
apply_skip_values_filter(client_opts, walk_oid, skip_values, descr_map)
|
||||
end
|
||||
|
||||
# Convert walk results (map) to sensor data, applying filters if present
|
||||
results
|
||||
|> Enum.map(fn {result_oid, value} ->
|
||||
{result_oid, value, extract_index_from_full_oid(result_oid)}
|
||||
end)
|
||||
|> Enum.filter(fn {_oid, _value, index} ->
|
||||
should_include_sensor?(sensor_def, index, filter_map, skip_values)
|
||||
end)
|
||||
|> Enum.map(fn {result_oid, value, _index} ->
|
||||
build_sensor_data(sensor_def, %{oid: result_oid, value: value}, descr_map_with_fallback)
|
||||
end)
|
||||
|
||||
{:ok, _empty} ->
|
||||
|
|
@ -189,6 +279,312 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
end
|
||||
end
|
||||
|
||||
# Check if this is a MikroTik mtxrGaugeTable sensor
|
||||
# Extract skip_values filtering rules from sensor definition options
|
||||
defp get_skip_values(%{options: %{"skip_values" => skip_values}}) when is_list(skip_values), do: skip_values
|
||||
defp get_skip_values(_sensor_def), do: []
|
||||
|
||||
# Apply skip_values filter by walking the referenced OID
|
||||
# Returns {filter_map, descr_map_with_fallback}
|
||||
defp apply_skip_values_filter(client_opts, value_oid, [skip_rule | _], descr_map) do
|
||||
# Currently only handle first skip_values rule (LibreNMS profiles only have one)
|
||||
filter_oid_name = skip_rule["oid"]
|
||||
|
||||
# Derive the filter OID from the value OID and the filter column reference
|
||||
filter_oid = derive_filter_oid(value_oid, filter_oid_name)
|
||||
|
||||
Logger.info("Walking filter column #{filter_oid_name}: #{filter_oid}")
|
||||
|
||||
case Client.walk(client_opts, filter_oid) do
|
||||
{:ok, results} when is_map(results) and map_size(results) > 0 ->
|
||||
filter_map =
|
||||
Map.new(results, fn {oid, value} ->
|
||||
index = oid |> String.split(".") |> List.last()
|
||||
{index, value}
|
||||
end)
|
||||
|
||||
Logger.info("Found #{map_size(filter_map)} filter entries")
|
||||
|
||||
# Firmware bug workaround: if filter column returns strings and description column
|
||||
# failed, use filter column strings as descriptions
|
||||
descr_fallback =
|
||||
if map_size(descr_map) == 0 and Enum.any?(filter_map, fn {_k, v} -> is_binary(v) end) do
|
||||
Logger.info("Using filter column strings as sensor descriptions (firmware workaround)")
|
||||
filter_map
|
||||
else
|
||||
descr_map
|
||||
end
|
||||
|
||||
{filter_map, descr_fallback}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.info("Filter column not available (#{inspect(reason)}), will include all sensors")
|
||||
{nil, descr_map}
|
||||
|
||||
{:ok, _empty} ->
|
||||
Logger.info("Filter column returned no data, will include all sensors")
|
||||
{nil, descr_map}
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_skip_values_filter(_client_opts, _value_oid, [], descr_map), do: {nil, descr_map}
|
||||
|
||||
# Determine if a sensor should be included based on skip_values filter
|
||||
defp should_include_sensor?(_sensor_def, _index, nil, _skip_values), do: true
|
||||
|
||||
defp should_include_sensor?(sensor_def, index, filter_map, skip_values) when is_map(filter_map) do
|
||||
case Map.get(filter_map, index) do
|
||||
nil ->
|
||||
# No filter value for this index, include it
|
||||
true
|
||||
|
||||
filter_value ->
|
||||
# Apply skip_values logic: if any rule evaluates to true, SKIP the sensor
|
||||
# Also handle firmware bug where filter returns strings - match by sensor class
|
||||
should_skip =
|
||||
Enum.any?(skip_values, fn rule ->
|
||||
evaluate_skip_rule(rule, filter_value, sensor_def.sensor_class)
|
||||
end)
|
||||
|
||||
not should_skip
|
||||
end
|
||||
end
|
||||
|
||||
# Evaluate a single skip_values rule
|
||||
# Returns true if the sensor should be SKIPPED (filtered out)
|
||||
defp evaluate_skip_rule(%{"op" => "!=", "value" => expected_value}, actual_value, sensor_class) do
|
||||
# Skip if actual != expected (i.e., keep only if actual == expected)
|
||||
# Handle both numeric values and string values (firmware bug workaround)
|
||||
if is_binary(actual_value) do
|
||||
# Firmware returns strings like "cpu-temperature" instead of unit codes
|
||||
# Match by sensor class instead
|
||||
not matches_sensor_class_in_string?(sensor_class, actual_value)
|
||||
else
|
||||
actual_value != expected_value
|
||||
end
|
||||
end
|
||||
|
||||
defp evaluate_skip_rule(%{"op" => "==", "value" => expected_value}, actual_value, _sensor_class) do
|
||||
# Skip if actual == expected
|
||||
actual_value == expected_value
|
||||
end
|
||||
|
||||
defp evaluate_skip_rule(_rule, _actual_value, _sensor_class), do: false
|
||||
|
||||
# Firmware bug workaround: match sensor class in string value
|
||||
defp matches_sensor_class_in_string?("temperature", str), do: String.contains?(str, "temperature")
|
||||
defp matches_sensor_class_in_string?("voltage", str), do: String.contains?(str, "voltage")
|
||||
defp matches_sensor_class_in_string?("current", str), do: String.contains?(str, "current")
|
||||
defp matches_sensor_class_in_string?("power", str), do: String.contains?(str, "power")
|
||||
defp matches_sensor_class_in_string?("fanspeed", str), do: String.contains?(str, "fan")
|
||||
|
||||
defp matches_sensor_class_in_string?("state", str),
|
||||
do: String.contains?(str, "state") or String.contains?(str, "status")
|
||||
|
||||
defp matches_sensor_class_in_string?(_, _), do: false
|
||||
|
||||
# Derive the filter OID from the value OID and filter column name
|
||||
# Handles both numeric OIDs and symbolic MIB names
|
||||
defp derive_filter_oid(value_oid, filter_name) when is_binary(filter_name) do
|
||||
cond do
|
||||
# If filter_name contains MIB reference, try to derive column OID
|
||||
String.contains?(filter_name, "::") ->
|
||||
derive_oid_from_mib_name(value_oid, filter_name)
|
||||
|
||||
# If it's already a numeric OID, use it directly
|
||||
String.match?(filter_name, ~r/^[\d\.]+$/) ->
|
||||
filter_name
|
||||
|
||||
# Unknown format, return value OID as fallback
|
||||
true ->
|
||||
Logger.warning("Unknown filter OID format: #{filter_name}, using value OID as fallback")
|
||||
value_oid
|
||||
end
|
||||
end
|
||||
|
||||
# Derive OID from MIB column name by analyzing the table structure
|
||||
# For SNMP tables following .table.entry.column.index pattern
|
||||
defp derive_oid_from_mib_name(value_oid, mib_name) do
|
||||
# Extract the column name from MIB reference
|
||||
column_suffix = mib_name |> String.split("::") |> List.last()
|
||||
|
||||
# Known column mappings for common MIB tables
|
||||
# This could be extended or moved to database if needed
|
||||
column_map = %{
|
||||
# MikroTik mtxrGaugeTable: .100.1.{column}
|
||||
"mtxrGaugeName" => 1,
|
||||
"mtxrGaugeUnit" => 2,
|
||||
"mtxrGaugeValue" => 3,
|
||||
# MikroTik mtxrOpticalTable: .19.1.1.{column}
|
||||
"mtxrOpticalName" => 2,
|
||||
"mtxrOpticalRxPower" => 10,
|
||||
"mtxrOpticalTxPower" => 9,
|
||||
# MikroTik mtxrPOETable: .15.1.1.{column}
|
||||
"mtxrPOEName" => 1,
|
||||
"mtxrPOEVoltage" => 4,
|
||||
"mtxrPOECurrent" => 5
|
||||
}
|
||||
|
||||
case Map.get(column_map, column_suffix) do
|
||||
nil ->
|
||||
Logger.debug("No column mapping for #{column_suffix}, using value OID")
|
||||
value_oid
|
||||
|
||||
target_column ->
|
||||
# Replace the column number in the OID path
|
||||
# Pattern: find .table.entry.{column}.{index} and replace column
|
||||
replace_column_in_oid(value_oid, target_column)
|
||||
end
|
||||
end
|
||||
|
||||
# Generic column replacement in SNMP table OID
|
||||
# Finds the last column number before the index and replaces it
|
||||
defp replace_column_in_oid(oid, new_column) do
|
||||
# Match standard SNMP table pattern: ...table.entry.column[.index]
|
||||
# Replace the column number (second-to-last or last component)
|
||||
case oid |> String.split(".") |> Enum.reverse() do
|
||||
[index | [_old_column | rest]] when index != "" ->
|
||||
# Has index: ...column.index -> ...new_column.index
|
||||
Enum.join(Enum.reverse(rest, [to_string(new_column), index]), ".")
|
||||
|
||||
[_old_column | rest] ->
|
||||
# No index: ...column -> ...new_column
|
||||
Enum.join(Enum.reverse(rest, [to_string(new_column)]), ".")
|
||||
|
||||
_ ->
|
||||
# Can't parse, return original
|
||||
oid
|
||||
end
|
||||
end
|
||||
|
||||
# Extract numeric index from full OID
|
||||
defp extract_index_from_full_oid(oid) do
|
||||
oid |> String.split(".") |> List.last()
|
||||
end
|
||||
|
||||
# Walk description OID if descr contains a symbolic MIB reference
|
||||
defp maybe_walk_descr_oid(client_opts, value_oid, descr) when is_binary(descr) do
|
||||
cond do
|
||||
# Handle templates like "{{ MIKROTIK-MIB::mtxrOpticalName }} Tx Bias"
|
||||
String.contains?(descr, "{{") and String.contains?(descr, "::") ->
|
||||
# Extract symbolic name from template
|
||||
case Regex.run(~r/\{\{\s*([A-Z0-9\-]+::[a-zA-Z0-9]+)\s*\}\}/, descr) do
|
||||
[_full_match, symbolic_name] ->
|
||||
walk_and_resolve_descriptions(client_opts, value_oid, symbolic_name, :template)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
|
||||
# Handle simple symbolic names like "MIKROTIK-MIB::mtxrGaugeName"
|
||||
String.contains?(descr, "::") and is_simple_symbolic_name?(descr) ->
|
||||
walk_and_resolve_descriptions(client_opts, value_oid, descr, :simple)
|
||||
|
||||
true ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_walk_descr_oid(_client_opts, _value_oid, _descr), do: %{}
|
||||
|
||||
defp walk_and_resolve_descriptions(client_opts, value_oid, symbolic_name, type) do
|
||||
descr_oid = derive_descr_oid(value_oid, symbolic_name)
|
||||
Logger.info("Resolving symbolic name #{symbolic_name} (#{type}): walking #{descr_oid}")
|
||||
|
||||
case Client.walk(client_opts, descr_oid) do
|
||||
{:ok, results} when is_map(results) and map_size(results) > 0 ->
|
||||
# Build map of index -> description
|
||||
descr_map =
|
||||
Map.new(results, fn {oid, value} ->
|
||||
index = oid |> String.split(".") |> List.last()
|
||||
{index, to_string(value)}
|
||||
end)
|
||||
|
||||
Logger.info("Resolved #{map_size(descr_map)} descriptions for #{symbolic_name}: #{inspect(descr_map)}")
|
||||
|
||||
descr_map
|
||||
|
||||
{:ok, _results} ->
|
||||
Logger.warning("Empty results for description OID: #{descr_oid} for #{symbolic_name}")
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to walk description OID: #{descr_oid} for #{symbolic_name}: #{inspect(reason)}")
|
||||
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
# Check if description is a simple symbolic name (not a template)
|
||||
# Simple: "MIKROTIK-MIB::mtxrGaugeName"
|
||||
# Template: "{{ PowerNet-MIB::pduOutletMeteredStatusName }}"
|
||||
# Complex template: "GE{{ ADVANTECH-EKI-PRONEER-MIB::poePortStatusIndex }} POE"
|
||||
defp is_simple_symbolic_name?(descr) do
|
||||
String.contains?(descr, "::") and not String.contains?(descr, "{{")
|
||||
end
|
||||
|
||||
@doc false
|
||||
# Derive description OID from value OID
|
||||
# Most SNMP tables follow the pattern: .table_base.entry.column.index
|
||||
# Description/name is typically in column 1 or 2
|
||||
# Public for testing purposes
|
||||
def derive_descr_oid(value_oid, descr) do
|
||||
# Vendor-specific mappings where we know the exact column
|
||||
vendor_specific_oid =
|
||||
cond do
|
||||
# MikroTik mtxrGaugeName is in column 1 of mtxrGaugeTable
|
||||
# Value OID: .1.3.6.1.4.1.14988.1.1.3.100.1.3 or .1.3.6.1.4.1.14988.1.1.3.100.1.3.{index}
|
||||
# Name OID: .1.3.6.1.4.1.14988.1.1.3.100.1.1 or .1.3.6.1.4.1.14988.1.1.3.100.1.1.{index}
|
||||
String.contains?(descr, "mtxrGaugeName") ->
|
||||
String.replace(value_oid, ~r/\.100\.1\.\d+($|\.)/, ".100.1.1\\1")
|
||||
|
||||
# MikroTik mtxrOpticalName is in column 2 of mtxrOpticalTable
|
||||
# Value OID: .1.3.6.1.4.1.14988.1.1.19.1.1.{column} or .1.3.6.1.4.1.14988.1.1.19.1.1.{column}.{index}
|
||||
# Name OID: .1.3.6.1.4.1.14988.1.1.19.1.1.2 or .1.3.6.1.4.1.14988.1.1.19.1.1.2.{index}
|
||||
String.contains?(descr, "mtxrOpticalName") ->
|
||||
String.replace(value_oid, ~r/\.19\.1\.1\.\d+($|\.)/, ".19.1.1.2\\1")
|
||||
|
||||
# MikroTik mtxrPOEName is in column 1 of mtxrPOETable
|
||||
# Value OID: .1.3.6.1.4.1.14988.1.1.3.15.1.1.{column} or .1.3.6.1.4.1.14988.1.1.3.15.1.1.{column}.{index}
|
||||
# Name OID: .1.3.6.1.4.1.14988.1.1.3.15.1.1.1 or .1.3.6.1.4.1.14988.1.1.3.15.1.1.1.{index}
|
||||
String.contains?(descr, "mtxrPOEName") ->
|
||||
String.replace(value_oid, ~r/\.15\.1\.1\.\d+($|\.)/, ".15.1.1.1\\1")
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
|
||||
# If we have a vendor-specific mapping, use it
|
||||
# Otherwise try generic approach
|
||||
vendor_specific_oid || derive_generic_descr_oid(value_oid)
|
||||
end
|
||||
|
||||
# Generic SNMP table description OID derivation
|
||||
# Attempts to find the description by trying common column positions
|
||||
defp derive_generic_descr_oid(value_oid) do
|
||||
# Parse OID to find table structure
|
||||
# Format: .base.table.entry.column.index
|
||||
# Example: .1.3.6.1.4.1.49136.100.1.3.5 -> try .1.3.6.1.4.1.49136.100.1.1.5
|
||||
parts = value_oid |> String.split(".") |> Enum.reverse()
|
||||
|
||||
# Pattern match on list to get index and column
|
||||
case parts do
|
||||
[index, column | _rest]
|
||||
when is_binary(column) and is_binary(index) and column != "" and index != "" ->
|
||||
# Replace the column number with 1, but only the occurrence before the index
|
||||
# This avoids replacing earlier occurrences (e.g., in 1.3.6.1)
|
||||
String.replace(
|
||||
value_oid,
|
||||
~r/\.#{Regex.escape(column)}\.(#{Regex.escape(index)})$/,
|
||||
".1.\\1"
|
||||
)
|
||||
|
||||
_ ->
|
||||
# Fallback: try replacing last number before index with 1
|
||||
String.replace(value_oid, ~r/\.\d+\.(\d+)$/, ".1.\\1")
|
||||
end
|
||||
end
|
||||
|
||||
# Remove template variables from OID
|
||||
# Examples:
|
||||
# ".1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}" -> ".1.3.6.1.4.1.17713.21.2.1.36"
|
||||
|
|
@ -199,13 +595,34 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
|> String.trim_trailing(".")
|
||||
end
|
||||
|
||||
defp build_sensor_data(sensor_def, %{oid: oid, value: value}) do
|
||||
defp build_sensor_data(sensor_def, %{oid: oid, value: value}, descr_map) do
|
||||
# Build sensor index from OID (extract last component)
|
||||
oid_index = extract_index_from_oid(oid, sensor_def.index)
|
||||
|
||||
# Extract the numeric index for description lookup
|
||||
numeric_index = oid |> String.split(".") |> List.last()
|
||||
|
||||
# Get actual description from map if available, otherwise use configured descr
|
||||
actual_descr =
|
||||
case Map.get(descr_map, numeric_index) do
|
||||
nil ->
|
||||
sensor_def.descr || "#{sensor_def.sensor_class} #{oid_index}"
|
||||
|
||||
resolved_name ->
|
||||
# Check if descr is a template (contains {{ }})
|
||||
if String.contains?(sensor_def.descr, "{{") do
|
||||
# Replace template variable with resolved name
|
||||
# E.g., "{{ MIKROTIK-MIB::mtxrOpticalName }} Tx Bias" -> "sfp-sfpplus1 Tx Bias"
|
||||
String.replace(sensor_def.descr, ~r/\{\{[^}]+\}\}/, resolved_name)
|
||||
else
|
||||
# For simple symbolic names, use the resolved name directly
|
||||
resolved_name
|
||||
end
|
||||
end
|
||||
|
||||
# Make index unique by combining sensor class with OID index
|
||||
# This prevents conflicts when multiple sensors have the same OID index (e.g., ".0")
|
||||
sensor_index = build_unique_sensor_index(sensor_def.sensor_class, sensor_def.descr, oid_index)
|
||||
sensor_index = build_unique_sensor_index(sensor_def.sensor_class, actual_descr, oid_index)
|
||||
|
||||
# Apply divisor to value if it's numeric
|
||||
last_value =
|
||||
|
|
@ -224,7 +641,7 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
sensor_type: sensor_def.sensor_class,
|
||||
sensor_index: sensor_index,
|
||||
sensor_oid: oid,
|
||||
sensor_descr: sensor_def.descr || "#{sensor_def.sensor_class} #{sensor_index}",
|
||||
sensor_descr: actual_descr,
|
||||
sensor_unit: determine_unit(sensor_def.sensor_class),
|
||||
sensor_divisor: sensor_def.divisor || 1,
|
||||
last_value: last_value,
|
||||
|
|
@ -277,16 +694,26 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
end
|
||||
|
||||
defp extract_index_from_oid(oid, configured_index) when is_binary(configured_index) do
|
||||
# If index contains template variable or is empty, extract from OID
|
||||
if configured_index == "" or String.contains?(configured_index, "{{") do
|
||||
# Extract last component of OID as index
|
||||
# Extract last component of OID
|
||||
oid_index =
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> to_string()
|
||||
else
|
||||
# Use pre-configured static index
|
||||
configured_index
|
||||
|
||||
cond do
|
||||
configured_index == "" ->
|
||||
# No template, just use OID index
|
||||
oid_index
|
||||
|
||||
String.contains?(configured_index, "{{") ->
|
||||
# Replace template variable with actual OID index
|
||||
# E.g., "mtxrGaugeCurrent.{{ $index }}" -> "mtxrGaugeCurrent.17"
|
||||
String.replace(configured_index, ~r/\{\{\s*\$index\s*\}\}/, oid_index)
|
||||
|
||||
true ->
|
||||
# Use pre-configured static index
|
||||
configured_index
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -299,19 +726,21 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
|||
end
|
||||
|
||||
defp build_unique_sensor_index(sensor_class, descr, oid_index) do
|
||||
# For scalar sensors (index 0), use a descriptive name from the description
|
||||
# For table sensors, use the OID index
|
||||
if oid_index == "0" && descr do
|
||||
# Convert description to a safe index string
|
||||
# E.g., "DFS Detected Count" -> "dfs_detected_count"
|
||||
# E.g., "GPS Status" -> "gps_status"
|
||||
descr
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9]+/, "_")
|
||||
|> String.trim("_")
|
||||
else
|
||||
# For table entries, prepend sensor class to OID index
|
||||
"#{sensor_class}_#{oid_index}"
|
||||
cond do
|
||||
# If oid_index already has a dot (e.g., "mtxrGaugeCurrent.17"), use it as-is
|
||||
String.contains?(oid_index, ".") ->
|
||||
oid_index
|
||||
|
||||
# For scalar sensors (index 0), use a descriptive name from the description
|
||||
oid_index == "0" && descr ->
|
||||
descr
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9]+/, "_")
|
||||
|> String.trim("_")
|
||||
|
||||
# For simple numeric indices, prepend sensor class
|
||||
true ->
|
||||
"#{sensor_class}_#{oid_index}"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
|
|||
board_name: "1.3.6.1.4.1.14988.1.1.7.9.0",
|
||||
display_name: "1.3.6.1.4.1.14988.1.1.7.8.0",
|
||||
build_time: "1.3.6.1.4.1.14988.1.1.7.6.0",
|
||||
license_version: "1.3.6.1.4.1.14988.1.1.4.4.0",
|
||||
license_level: "1.3.6.1.4.1.14988.1.1.7.5.0",
|
||||
|
||||
# Health - Voltages (mtxrHealth)
|
||||
core_voltage: "1.3.6.1.4.1.14988.1.1.3.1.0",
|
||||
|
|
@ -126,15 +126,30 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
|
|||
model =
|
||||
case Regex.run(~r/RouterOS\s+([A-Z0-9\+\-]+)/, sys_descr) do
|
||||
[_, model] -> model
|
||||
_ -> "RouterOS Device"
|
||||
_ -> "MikroTik RouterOS"
|
||||
end
|
||||
|
||||
firmware = Map.get(system_info, :firmware_version) || extract_firmware_from_sysdescr(sys_descr)
|
||||
# Build formatted firmware version like LibreNMS: "Mikrotik RouterOS 7.20.6 (Level 6)"
|
||||
firmware_version = Map.get(system_info, :firmware_version)
|
||||
license_level = Map.get(system_info, :license_version)
|
||||
|
||||
formatted_firmware =
|
||||
cond do
|
||||
firmware_version && license_level ->
|
||||
"Mikrotik RouterOS #{firmware_version} (Level #{license_level})"
|
||||
|
||||
firmware_version ->
|
||||
"Mikrotik RouterOS #{firmware_version}"
|
||||
|
||||
true ->
|
||||
# Fallback to extracting from sysDescr
|
||||
extract_firmware_from_sysdescr(sys_descr)
|
||||
end
|
||||
|
||||
Map.merge(system_info, %{
|
||||
manufacturer: "MikroTik",
|
||||
model: model,
|
||||
firmware_version: firmware
|
||||
firmware_version: formatted_firmware
|
||||
})
|
||||
end
|
||||
|
||||
|
|
@ -146,18 +161,18 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
|
|||
@mikrotik_oids.firmware_version,
|
||||
@mikrotik_oids.board_name,
|
||||
@mikrotik_oids.display_name,
|
||||
@mikrotik_oids.license_version
|
||||
@mikrotik_oids.license_level
|
||||
]
|
||||
|
||||
case Client.get_multiple(client_opts, oids) do
|
||||
{:ok, [serial, firmware, board, display, license]} ->
|
||||
{:ok, [serial, firmware, board, display, license_level]} ->
|
||||
{:ok,
|
||||
%{
|
||||
serial_number: serial,
|
||||
firmware_version: firmware,
|
||||
board_name: board,
|
||||
display_name: display,
|
||||
license_version: license
|
||||
license_version: license_level
|
||||
}}
|
||||
|
||||
{:error, _} ->
|
||||
|
|
|
|||
|
|
@ -39,24 +39,45 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id} = params, _, socket) do
|
||||
_ =
|
||||
if connected?(socket) do
|
||||
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}")
|
||||
Process.send_after(self(), :refresh_data, 10_000)
|
||||
end
|
||||
# Check if device exists before setting up subscriptions
|
||||
case Devices.get_device(id) do
|
||||
nil ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Device not found")
|
||||
|> push_navigate(to: ~p"/devices")}
|
||||
|
||||
tab = Map.get(params, "tab", "overview")
|
||||
_device ->
|
||||
_ =
|
||||
if connected?(socket) do
|
||||
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}")
|
||||
Process.send_after(self(), :refresh_data, 10_000)
|
||||
end
|
||||
|
||||
socket
|
||||
|> load_equipment_data(id)
|
||||
|> assign(:active_tab, tab)
|
||||
|> then(&{:noreply, &1})
|
||||
tab = Map.get(params, "tab", "overview")
|
||||
|
||||
socket
|
||||
|> load_equipment_data(id)
|
||||
|> assign(:active_tab, tab)
|
||||
|> then(&{:noreply, &1})
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:refresh_data, socket) do
|
||||
Process.send_after(self(), :refresh_data, 10_000)
|
||||
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
||||
# Check if device still exists before scheduling next refresh
|
||||
case Devices.get_device(socket.assigns.device.id) do
|
||||
nil ->
|
||||
# Device was deleted, don't schedule another refresh
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Device no longer exists")
|
||||
|> push_navigate(to: ~p"/devices")}
|
||||
|
||||
_device ->
|
||||
Process.send_after(self(), :refresh_data, 10_000)
|
||||
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -113,13 +134,24 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
|
||||
traffic_chart_data = load_overall_traffic_chart_data(device_id)
|
||||
|
||||
# Filter sensors by type for temperature, voltage, and storage
|
||||
# Filter sensors by type for temperature, voltage, storage, count, and transceivers
|
||||
# Separate transceiver sensors from general sensors
|
||||
{transceiver_sensors, non_transceiver_sensors} =
|
||||
Enum.split_with(snmp_data.sensors, fn sensor ->
|
||||
sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp")
|
||||
end)
|
||||
|
||||
temperature_sensors =
|
||||
Enum.filter(snmp_data.sensors, &(&1.sensor_type in ["temperature", "cpu_temperature", "celsius"]))
|
||||
Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["temperature", "cpu_temperature", "celsius"]))
|
||||
|
||||
voltage_sensors = Enum.filter(snmp_data.sensors, &(&1.sensor_type in ["voltage", "volts"]))
|
||||
voltage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["voltage", "volts"]))
|
||||
|
||||
storage_sensors = Enum.filter(snmp_data.sensors, &(&1.sensor_type in ["disk_usage"]))
|
||||
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
|
||||
|
||||
count_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type == "count"))
|
||||
|
||||
# Group transceiver sensors by transceiver name
|
||||
grouped_transceivers = group_transceiver_sensors(transceiver_sensors)
|
||||
|
||||
# Calculate metrics for dashboard
|
||||
metrics = calculate_metrics(recent_checks, device)
|
||||
|
|
@ -146,6 +178,8 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|> assign(:temperature_sensors, temperature_sensors)
|
||||
|> assign(:voltage_sensors, voltage_sensors)
|
||||
|> assign(:storage_sensors, storage_sensors)
|
||||
|> assign(:count_sensors, count_sensors)
|
||||
|> assign(:grouped_transceivers, grouped_transceivers)
|
||||
end
|
||||
|
||||
defp calculate_metrics(checks, _equipment) do
|
||||
|
|
@ -481,4 +515,31 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
defp interface_type_order(category) do
|
||||
Map.get(@interface_category_order, category, 99)
|
||||
end
|
||||
|
||||
# Group transceiver sensors by transceiver name
|
||||
# Returns list of {name, sensors} tuples sorted by name
|
||||
defp group_transceiver_sensors(transceiver_sensors) do
|
||||
transceiver_sensors
|
||||
|> Enum.group_by(&extract_transceiver_name/1)
|
||||
|> Enum.sort_by(fn {name, _sensors} -> name end)
|
||||
end
|
||||
|
||||
# Extract transceiver name from sensor description
|
||||
# Examples:
|
||||
# "sfp-sfpplus1 Rx Power" -> "sfp-sfpplus1"
|
||||
# "sfp-sfpplus2 Temperature" -> "sfp-sfpplus2"
|
||||
# "SFP1 Voltage" -> "SFP1"
|
||||
defp extract_transceiver_name(sensor) do
|
||||
case sensor.sensor_descr do
|
||||
nil ->
|
||||
"unknown"
|
||||
|
||||
descr ->
|
||||
# Extract the first word (transceiver identifier)
|
||||
descr
|
||||
|> String.split(" ")
|
||||
|> List.first()
|
||||
|> String.downcase()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -433,6 +433,76 @@
|
|||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- SFP Transceivers -->
|
||||
<%= if @grouped_transceivers && length(@grouped_transceivers) > 0 do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Transceivers
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<dl class="space-y-1">
|
||||
<%= for {_transceiver_name, sensors} <- @grouped_transceivers do %>
|
||||
<%= for sensor <- sensors do %>
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<.link
|
||||
navigate={~p"/devices/#{@device.id}/graph/dbm"}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||
>
|
||||
{sensor.sensor_descr}
|
||||
</.link>
|
||||
</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
||||
<%= if sensor.latest_reading do %>
|
||||
{format_sensor_value(
|
||||
sensor.latest_reading.value,
|
||||
sensor.sensor_divisor
|
||||
)}
|
||||
{sensor.sensor_unit}
|
||||
<% else %>
|
||||
N/A
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Count Sensors (DHCP Leases, Connections, etc.) -->
|
||||
<%= if @count_sensors && length(@count_sensors) > 0 do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Counters</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<dl class="space-y-1">
|
||||
<%= for sensor <- @count_sensors do %>
|
||||
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<.link
|
||||
navigate={~p"/devices/#{@device.id}/graph/count"}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||
>
|
||||
{sensor.sensor_descr}
|
||||
</.link>
|
||||
</dt>
|
||||
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
<%= if sensor.latest_reading do %>
|
||||
{trunc(sensor.latest_reading.value)}
|
||||
<% else %>
|
||||
N/A
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% "ports" -> %>
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ defmodule Towerops.Monitoring.PingTest do
|
|||
test "returns error for unreachable host" do
|
||||
# Use an address that should be unreachable
|
||||
# 192.0.2.0/24 is TEST-NET-1, reserved for documentation
|
||||
assert {:error, :timeout_or_unreachable} = Ping.ping("192.0.2.1", 1000)
|
||||
assert {:error, _reason} = Ping.ping("192.0.2.1", 1000)
|
||||
end
|
||||
|
||||
test "returns error for invalid IP address" do
|
||||
# Invalid IP address should fail
|
||||
assert {:error, :timeout_or_unreachable} = Ping.ping("999.999.999.999", 1000)
|
||||
assert {:error, _reason} = Ping.ping("999.999.999.999", 1000)
|
||||
end
|
||||
|
||||
@tag :integration
|
||||
|
|
|
|||
|
|
@ -137,8 +137,8 @@ defmodule Towerops.Monitoring.SupervisorTest do
|
|||
MonitoringSupervisor.start_all_monitors()
|
||||
|
||||
# Check that both are running
|
||||
assert [{pid1, _}] = Registry.lookup(Towerops.Monitoring.Registry, device.id)
|
||||
assert [{pid2, _}] = Registry.lookup(Towerops.Monitoring.Registry, device2.id)
|
||||
assert [{pid1, _}] = Horde.Registry.lookup(Towerops.Monitoring.Registry, device.id)
|
||||
assert [{pid2, _}] = Horde.Registry.lookup(Towerops.Monitoring.Registry, device2.id)
|
||||
assert Process.alive?(pid1)
|
||||
assert Process.alive?(pid2)
|
||||
|
||||
|
|
@ -171,8 +171,8 @@ defmodule Towerops.Monitoring.SupervisorTest do
|
|||
MonitoringSupervisor.start_all_snmp_pollers()
|
||||
|
||||
# Check that both are running
|
||||
assert [{pid1, _}] = Registry.lookup(PollerRegistry, device.id)
|
||||
assert [{pid2, _}] = Registry.lookup(PollerRegistry, device2.id)
|
||||
assert [{pid1, _}] = Horde.Registry.lookup(PollerRegistry, device.id)
|
||||
assert [{pid2, _}] = Horde.Registry.lookup(PollerRegistry, device2.id)
|
||||
assert Process.alive?(pid1)
|
||||
assert Process.alive?(pid2)
|
||||
|
||||
|
|
|
|||
76
test/towerops/snmp/profiles/dynamic_test.exs
Normal file
76
test/towerops/snmp/profiles/dynamic_test.exs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
defmodule Towerops.Snmp.Profiles.DynamicTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Snmp.Profiles.Dynamic
|
||||
|
||||
describe "description OID derivation" do
|
||||
test "derives correct OID for mtxrGaugeName" do
|
||||
value_oid = "1.3.6.1.4.1.14988.1.1.3.100.1.3.17"
|
||||
descr = "MIKROTIK-MIB::mtxrGaugeName"
|
||||
|
||||
# Call the private function via module attribute trick
|
||||
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||
|
||||
assert descr_oid == "1.3.6.1.4.1.14988.1.1.3.100.1.1.17"
|
||||
end
|
||||
|
||||
test "derives correct OID for mtxrOpticalName" do
|
||||
value_oid = "1.3.6.1.4.1.14988.1.1.19.1.1.8.17"
|
||||
descr = "MIKROTIK-MIB::mtxrOpticalName"
|
||||
|
||||
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||
|
||||
assert descr_oid == "1.3.6.1.4.1.14988.1.1.19.1.1.2.17"
|
||||
end
|
||||
|
||||
test "derives correct OID for mtxrPOEName" do
|
||||
value_oid = "1.3.6.1.4.1.14988.1.1.3.15.1.1.5.3"
|
||||
descr = "MIKROTIK-MIB::mtxrPOEName"
|
||||
|
||||
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||
|
||||
assert descr_oid == "1.3.6.1.4.1.14988.1.1.3.15.1.1.1.3"
|
||||
end
|
||||
|
||||
test "handles multiple indices correctly" do
|
||||
# Test with different index values
|
||||
value_oid_1 = "1.3.6.1.4.1.14988.1.1.3.100.1.3.7101"
|
||||
value_oid_2 = "1.3.6.1.4.1.14988.1.1.3.100.1.3.7202"
|
||||
descr = "MIKROTIK-MIB::mtxrGaugeName"
|
||||
|
||||
descr_oid_1 = derive_descr_oid_wrapper(value_oid_1, descr)
|
||||
descr_oid_2 = derive_descr_oid_wrapper(value_oid_2, descr)
|
||||
|
||||
assert descr_oid_1 == "1.3.6.1.4.1.14988.1.1.3.100.1.1.7101"
|
||||
assert descr_oid_2 == "1.3.6.1.4.1.14988.1.1.3.100.1.1.7202"
|
||||
end
|
||||
|
||||
test "falls back to generic derivation for unknown MIBs" do
|
||||
# Generic SNMP table: .1.3.6.1.4.1.12345.1.2.3.5
|
||||
# Should try to replace column 3 with column 1
|
||||
value_oid = "1.3.6.1.4.1.12345.1.2.3.5"
|
||||
descr = "VENDOR-MIB::someName"
|
||||
|
||||
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||
|
||||
# Generic derivation replaces second-to-last number with 1
|
||||
assert descr_oid == "1.3.6.1.4.1.12345.1.2.1.5"
|
||||
end
|
||||
|
||||
test "handles OIDs without index (base walk OIDs)" do
|
||||
# Base walk OID without index
|
||||
value_oid = "1.3.6.1.4.1.14988.1.1.3.100.1.3"
|
||||
descr = "MIKROTIK-MIB::mtxrGaugeName"
|
||||
|
||||
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||
|
||||
# Should replace column 3 with column 1, preserving the end of string
|
||||
assert descr_oid == "1.3.6.1.4.1.14988.1.1.3.100.1.1"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to call public function (made public for testing)
|
||||
defp derive_descr_oid_wrapper(value_oid, descr) do
|
||||
Dynamic.derive_descr_oid(value_oid, descr)
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue