feat: add database-driven SNMP device profiles

Implement dynamic device profile system using LibreNMS YAML definitions:

- Create 7 database tables for device profiles, detection rules, sensors, processors, memory pools
- Add DeviceProfiles context module with profile matching logic
- Add YAML importer to parse LibreNMS os_detection and os_discovery files
- Add mix task for importing profiles from LibreNMS repository
- Create Dynamic profile module to interpret database definitions at runtime
- Update discovery.ex to check database profiles before hard-coded modules
- Fix Redis PubSub configuration to support unnamed nodes for Mix tasks

Imported 5 Ubiquiti/Cambium profiles: epmp, airos-af, airos-af-ltu, airos-af60, unifi

The system now supports 671+ device profiles from LibreNMS without requiring
code changes for each device type.
This commit is contained in:
Graham McIntire 2026-01-17 18:13:53 -06:00
parent 3c46c805a0
commit 007adcc489
No known key found for this signature in database
14 changed files with 1563 additions and 6 deletions

View file

@ -0,0 +1,234 @@
defmodule Mix.Tasks.ImportProfiles do
@moduledoc """
Imports device SNMP profiles from LibreNMS YAML files.
## Usage
# Import a single profile
mix import_profiles --profile epmp --detection ~/dev/librenms/resources/definitions/os_detection/epmp.yaml --discovery ~/dev/librenms/resources/definitions/os_discovery/epmp.yaml
# Import specific profiles
mix import_profiles --profiles epmp,airos-af,unifi
# Import all profiles from LibreNMS directory
mix import_profiles --all --librenms-path ~/dev/librenms
## Options
--profile NAME - Import a single profile by name
--profiles LIST - Import multiple profiles (comma-separated)
--all - Import all profiles from LibreNMS
--librenms-path PATH - Path to LibreNMS installation
--detection PATH - Path to detection YAML file
--discovery PATH - Path to discovery YAML file (optional)
--force - Delete existing profile before importing
"""
use Mix.Task
alias Towerops.DeviceProfiles
alias Towerops.DeviceProfiles.Importer
alias Towerops.Repo
@requirements ["app.start"]
@impl Mix.Task
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [
profile: :string,
profiles: :string,
all: :boolean,
librenms_path: :string,
detection: :string,
discovery: :string,
force: :boolean
]
)
cond do
opts[:all] ->
import_all_profiles(opts)
opts[:profiles] ->
import_multiple_profiles(opts)
opts[:profile] || opts[:detection] ->
import_single_profile(opts)
true ->
print_usage()
end
end
defp import_single_profile(opts) do
detection_file = opts[:detection]
discovery_file = opts[:discovery]
force = opts[:force] || false
if detection_file && File.exists?(detection_file) do
os_name = Path.basename(detection_file, ".yaml")
if force do
delete_existing_profile(os_name)
end
Mix.shell().info("Importing profile: #{os_name}")
case Importer.import_profile(detection_file, discovery_file) do
{:ok, profile} ->
Mix.shell().info("Successfully imported profile: #{profile.os}")
print_profile_stats(profile)
{:error, reason} ->
Mix.shell().error("Failed to import profile: #{inspect(reason)}")
end
else
Mix.shell().error("Detection file not found: #{detection_file}")
end
end
defp import_multiple_profiles(opts) do
profiles = String.split(opts[:profiles], ",", trim: true)
librenms_path = opts[:librenms_path] || guess_librenms_path()
force = opts[:force] || false
if librenms_path do
detection_dir = Path.join(librenms_path, "resources/definitions/os_detection")
discovery_dir = Path.join(librenms_path, "resources/definitions/os_discovery")
Enum.each(profiles, fn profile_name ->
import_profile_by_name(profile_name, detection_dir, discovery_dir, force)
end)
else
Mix.shell().error("LibreNMS path not found. Use --librenms-path option.")
end
end
defp import_all_profiles(opts) do
librenms_path = opts[:librenms_path] || guess_librenms_path()
force = opts[:force] || false
if librenms_path do
detection_dir = Path.join(librenms_path, "resources/definitions/os_detection")
discovery_dir = Path.join(librenms_path, "resources/definitions/os_discovery")
if force do
Mix.shell().info("Deleting all existing profiles...")
delete_all_profiles()
end
Mix.shell().info("Importing all profiles from: #{librenms_path}")
Mix.shell().info("This may take several minutes...")
case Importer.import_all_from_directory(detection_dir, discovery_dir) do
{:ok, stats} ->
Mix.shell().info("\nImport complete!")
Mix.shell().info("Successfully imported: #{stats.success} profiles")
Mix.shell().info("Failed to import: #{stats.failed} profiles")
{:error, reason} ->
Mix.shell().error("Failed to import profiles: #{inspect(reason)}")
end
else
Mix.shell().error("LibreNMS path not found. Use --librenms-path option.")
end
end
defp import_profile_by_name(profile_name, detection_dir, discovery_dir, force) do
detection_file = Path.join(detection_dir, "#{profile_name}.yaml")
discovery_file = Path.join(discovery_dir, "#{profile_name}.yaml")
discovery_file = if File.exists?(discovery_file), do: discovery_file
if File.exists?(detection_file) do
if force do
delete_existing_profile(profile_name)
end
Mix.shell().info("Importing profile: #{profile_name}")
case Importer.import_profile(detection_file, discovery_file) do
{:ok, profile} ->
Mix.shell().info("✓ Successfully imported: #{profile.os}")
{:error, reason} ->
Mix.shell().error("✗ Failed to import #{profile_name}: #{inspect(reason)}")
end
else
Mix.shell().error("Detection file not found for: #{profile_name}")
end
end
defp delete_existing_profile(os_name) do
if profile = DeviceProfiles.get_profile(os_name) do
Mix.shell().info("Deleting existing profile: #{os_name}")
DeviceProfiles.delete_profile(profile)
end
end
defp delete_all_profiles do
profiles = DeviceProfiles.list_profiles()
Enum.each(profiles, fn profile ->
DeviceProfiles.delete_profile(profile)
end)
Mix.shell().info("Deleted #{length(profiles)} existing profiles")
end
defp guess_librenms_path do
home = System.get_env("HOME")
possible_paths = ["~/dev/librenms", "#{home}/dev/librenms", "/opt/librenms"]
possible_paths
|> Enum.find(fn path ->
expanded = Path.expand(path)
File.dir?(expanded)
end)
|> case do
nil -> nil
path -> Path.expand(path)
end
end
defp print_profile_stats(profile) do
profile = Repo.preload(profile, [:detection_rules, :sensor_definitions, :processor_definitions])
Mix.shell().info("")
Mix.shell().info(" OS: #{profile.os}")
Mix.shell().info(" Text: #{profile.text}")
Mix.shell().info(" Type: #{profile.type}")
Mix.shell().info(" Detection rules: #{length(profile.detection_rules)}")
Mix.shell().info(" Sensor definitions: #{length(profile.sensor_definitions)}")
Mix.shell().info(" Processor definitions: #{length(profile.processor_definitions)}")
Mix.shell().info("")
end
defp print_usage do
Mix.shell().info("""
Usage:
mix import_profiles [options]
Options:
--profile NAME Import a single profile by name
--profiles LIST Import multiple profiles (comma-separated)
--all Import all profiles from LibreNMS
--librenms-path PATH Path to LibreNMS installation
--detection PATH Path to detection YAML file
--discovery PATH Path to discovery YAML file (optional)
--force Delete existing profile before importing
Examples:
# Import specific profiles
mix import_profiles --profiles epmp,airos-af,unifi --librenms-path ~/dev/librenms
# Import a single profile with explicit paths
mix import_profiles --detection ~/dev/librenms/resources/definitions/os_detection/epmp.yaml --discovery ~/dev/librenms/resources/definitions/os_discovery/epmp.yaml
# Import all profiles (takes several minutes)
mix import_profiles --all --librenms-path ~/dev/librenms
""")
end
end

View file

@ -26,7 +26,7 @@ defmodule Towerops.Application do
ToweropsWeb.Telemetry,
Towerops.Repo,
{DNSCluster, query: Application.get_env(:towerops, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Towerops.PubSub, adapter: Phoenix.PubSub.Redis, redis_config: redis_pubsub_config()},
pubsub_spec(),
# Start event logger (subscribes to PubSub)
Towerops.Devices.EventLogger,
# Start monitoring supervisor
@ -78,6 +78,27 @@ defmodule Towerops.Application do
]
end
# Returns PubSub spec - uses Redis in production/clustered environments,
# falls back to default PG2 adapter for Mix tasks and development
defp pubsub_spec do
redis_config = Application.get_env(:towerops, :redis, [])
node_name = node()
# Use Redis adapter only when:
# 1. Redis config exists with host
# 2. Node has a name (not nonode@nohost)
use_redis? =
Keyword.has_key?(redis_config, :host) &&
node_name != :nonode@nohost
if use_redis? do
{Phoenix.PubSub, name: Towerops.PubSub, adapter: Phoenix.PubSub.Redis, redis_config: redis_pubsub_config()}
else
# Default PG2 adapter for Mix tasks and development
{Phoenix.PubSub, name: Towerops.PubSub}
end
end
defp redis_pubsub_config do
redis_config = Application.get_env(:towerops, :redis, [])

View file

@ -0,0 +1,214 @@
defmodule Towerops.DeviceProfiles do
@moduledoc """
Context for managing device SNMP profiles.
Provides functions for querying profiles, matching devices to profiles based on
detection rules, and retrieving sensor/processor/mempool definitions.
"""
import Ecto.Query
alias Towerops.DeviceProfiles.DetectionRule
alias Towerops.DeviceProfiles.DeviceProfile
alias Towerops.DeviceProfiles.MemPoolDefinition
alias Towerops.DeviceProfiles.OSDefinition
alias Towerops.DeviceProfiles.ProcessorDefinition
alias Towerops.DeviceProfiles.SensorDefinition
alias Towerops.DeviceProfiles.SensorState
alias Towerops.Repo
@doc """
Lists all enabled device profiles.
"""
def list_profiles do
Repo.all(from p in DeviceProfile, where: p.enabled == true, order_by: [asc: p.priority])
end
@doc """
Gets a single device profile by OS name.
"""
def get_profile(os) when is_binary(os) do
Repo.get_by(DeviceProfile, os: os)
end
@doc """
Gets a profile with all associations preloaded.
"""
def get_profile_with_associations(os) when is_binary(os) do
Repo.one(
from p in DeviceProfile,
where: p.os == ^os,
preload: [
:detection_rules,
:os_definitions,
:processor_definitions,
:mempool_definitions,
sensor_definitions: [:states]
]
)
end
@doc """
Matches a device to a profile based on system information.
Takes a map with :sys_descr and :sys_object_id keys and returns the
matching profile or nil.
"""
def match_profile(system_info) do
profiles = list_profiles_with_detection_rules()
Enum.find(profiles, fn profile ->
match_detection_rules?(profile.detection_rules, system_info)
end)
end
@doc """
Lists all sensor definitions for a given profile and sensor class.
"""
def list_sensor_definitions(profile_id, sensor_class) do
Repo.all(
from s in SensorDefinition,
where: s.device_profile_id == ^profile_id and s.sensor_class == ^sensor_class,
preload: [:states]
)
end
@doc """
Creates a new device profile.
"""
def create_profile(attrs \\ %{}) do
%DeviceProfile{}
|> DeviceProfile.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a device profile.
"""
def update_profile(%DeviceProfile{} = profile, attrs) do
profile
|> DeviceProfile.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a device profile and all associated definitions.
"""
def delete_profile(%DeviceProfile{} = profile) do
Repo.delete(profile)
end
@doc """
Creates a detection rule for a profile.
"""
def create_detection_rule(attrs \\ %{}) do
%DetectionRule{}
|> DetectionRule.changeset(attrs)
|> Repo.insert()
end
@doc """
Creates a sensor definition for a profile.
"""
def create_sensor_definition(attrs \\ %{}) do
%SensorDefinition{}
|> SensorDefinition.changeset(attrs)
|> Repo.insert()
end
@doc """
Creates a sensor state for a sensor definition.
"""
def create_sensor_state(attrs \\ %{}) do
%SensorState{}
|> SensorState.changeset(attrs)
|> Repo.insert()
end
@doc """
Creates a processor definition for a profile.
"""
def create_processor_definition(attrs \\ %{}) do
%ProcessorDefinition{}
|> ProcessorDefinition.changeset(attrs)
|> Repo.insert()
end
@doc """
Creates a memory pool definition for a profile.
"""
def create_mempool_definition(attrs \\ %{}) do
%MemPoolDefinition{}
|> MemPoolDefinition.changeset(attrs)
|> Repo.insert()
end
@doc """
Creates an OS definition for a profile.
"""
def create_os_definition(attrs \\ %{}) do
%OSDefinition{}
|> OSDefinition.changeset(attrs)
|> Repo.insert()
end
# Private functions
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]
)
end
defp match_detection_rules?(rules, system_info) do
Enum.any?(rules, &match_rule?(&1, system_info))
end
defp match_rule?(%DetectionRule{rule_type: "sysObjectID"} = rule, system_info) do
sys_object_id = Map.get(system_info, :sys_object_id, "")
cond do
rule.pattern && rule.pattern != "" ->
String.contains?(sys_object_id, rule.pattern)
rule.value && rule.value != "" ->
sys_object_id == rule.value
true ->
false
end
end
defp match_rule?(%DetectionRule{rule_type: "sysDescr"} = rule, system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
cond do
rule.pattern && rule.pattern != "" ->
# Handle regex patterns
case Regex.compile(rule.pattern) do
{:ok, regex} -> Regex.match?(regex, sys_descr)
{:error, _} -> String.contains?(sys_descr, rule.pattern)
end
rule.value && rule.value != "" ->
String.contains?(sys_descr, rule.value)
true ->
false
end
end
defp match_rule?(%DetectionRule{rule_type: "sysDescr_regex"} = rule, system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
case Regex.compile(rule.pattern || "") do
{:ok, regex} -> Regex.match?(regex, sys_descr)
{:error, _} -> false
end
end
defp match_rule?(_rule, _system_info), do: false
end

View file

@ -0,0 +1,37 @@
defmodule Towerops.DeviceProfiles.DetectionRule do
@moduledoc """
Schema for device detection rules.
Defines rules for identifying which device profile to use based on
sysObjectID, sysDescr patterns, and SNMP queries.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.DeviceProfiles.DeviceProfile
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_detection_rules" do
field :rule_type, :string
field :oid, :string
field :operator, :string
field :value, :string
field :pattern, :string
field :priority, :integer, default: 0
belongs_to :device_profile, DeviceProfile
timestamps(type: :utc_datetime)
end
@doc false
def changeset(rule, attrs) do
rule
|> cast(attrs, [:rule_type, :oid, :operator, :value, :pattern, :priority, :device_profile_id])
|> validate_required([:rule_type, :device_profile_id])
|> foreign_key_constraint(:device_profile_id)
end
end

View file

@ -0,0 +1,60 @@
defmodule Towerops.DeviceProfiles.DeviceProfile do
@moduledoc """
Schema for device SNMP profiles.
Stores high-level device type information and coordinates detection rules,
sensor definitions, and other device-specific SNMP configurations.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.DeviceProfiles.DetectionRule
alias Towerops.DeviceProfiles.MemPoolDefinition
alias Towerops.DeviceProfiles.OSDefinition
alias Towerops.DeviceProfiles.ProcessorDefinition
alias Towerops.DeviceProfiles.SensorDefinition
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "device_profiles" do
field :os, :string
field :text, :string
field :type, :string
field :icon, :string
field :group, :string
field :mib, :string
field :snmp_bulk, :boolean, default: true
field :mib_dir, :string
field :enabled, :boolean, default: true
field :priority, :integer, default: 100
has_many :detection_rules, DetectionRule, foreign_key: :device_profile_id
has_many :sensor_definitions, SensorDefinition, foreign_key: :device_profile_id
has_many :processor_definitions, ProcessorDefinition, foreign_key: :device_profile_id
has_many :mempool_definitions, MemPoolDefinition, foreign_key: :device_profile_id
has_many :os_definitions, OSDefinition, foreign_key: :device_profile_id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(profile, attrs) do
profile
|> cast(attrs, [
:os,
:text,
:type,
:icon,
:group,
:mib,
:snmp_bulk,
:mib_dir,
:enabled,
:priority
])
|> validate_required([:os, :text])
|> unique_constraint(:os)
end
end

View file

@ -0,0 +1,359 @@
defmodule Towerops.DeviceProfiles.Importer do
@moduledoc """
Imports LibreNMS device profile definitions from YAML files.
Parses os_detection and os_discovery YAML files and creates database records
for device profiles, detection rules, and sensor definitions.
"""
alias Towerops.DeviceProfiles
alias Towerops.Repo
require Logger
@doc """
Imports a device profile from LibreNMS YAML files.
## Parameters
- detection_file: Path to os_detection YAML file
- discovery_file: Path to os_discovery YAML file (optional)
## Returns
- {:ok, profile} on success
- {:error, reason} on failure
"""
def import_profile(detection_file, discovery_file \\ nil) do
Repo.transaction(fn ->
with {:ok, detection_data} <- parse_yaml_file(detection_file),
{:ok, profile} <- create_profile_from_detection(detection_data),
{:ok, profile} <- maybe_import_discovery(profile, discovery_file) do
profile
else
{:error, reason} ->
Logger.error("Failed to import profile: #{inspect(reason)}")
Repo.rollback(reason)
end
end)
end
@doc """
Imports all profiles from a directory.
"""
def import_all_from_directory(detection_dir, discovery_dir) do
detection_files = Path.wildcard(Path.join(detection_dir, "*.yaml"))
results =
Enum.map(detection_files, fn detection_file ->
os_name = Path.basename(detection_file, ".yaml")
discovery_file = Path.join(discovery_dir, "#{os_name}.yaml")
discovery_file =
if File.exists?(discovery_file), do: discovery_file
case import_profile(detection_file, discovery_file) do
{:ok, profile} ->
Logger.info("Imported profile: #{profile.os}")
{:ok, profile}
{:error, reason} ->
Logger.warning("Failed to import #{os_name}: #{inspect(reason)}")
{:error, os_name, reason}
end
end)
{successes, failures} = Enum.split_with(results, &match?({:ok, _}, &1))
Logger.info("Imported #{length(successes)} profiles successfully")
if length(failures) > 0 do
Logger.warning("Failed to import #{length(failures)} profiles")
end
{:ok, %{success: length(successes), failed: length(failures)}}
end
# Private functions
defp parse_yaml_file(file_path) do
case YamlElixir.read_from_file(file_path) do
{:ok, data} -> {:ok, data}
{:error, reason} -> {:error, "YAML parse error: #{inspect(reason)}"}
end
end
defp create_profile_from_detection(data) do
attrs = %{
os: data["os"],
text: data["text"] || data["os"],
type: data["type"],
icon: data["icon"],
group: data["group"],
snmp_bulk: Map.get(data, "snmp_bulk", true),
mib_dir: data["mib_dir"],
enabled: true,
priority: data["priority"] || 100
}
case DeviceProfiles.create_profile(attrs) do
{:ok, profile} ->
# Create detection rules
case create_detection_rules(profile.id, data["discovery"] || []) do
:ok -> {:ok, profile}
{:error, _} = error -> error
end
{:error, _} = error ->
error
end
end
defp create_detection_rules(profile_id, rules) when is_list(rules) do
Enum.reduce_while(rules, :ok, fn rule, _acc ->
case parse_detection_rule(profile_id, rule) do
{:ok, _} -> {:cont, :ok}
{:error, _} = error -> {:halt, error}
end
end)
end
defp parse_detection_rule(profile_id, rule) do
# Handle sysObjectID rules
if Map.has_key?(rule, "sysObjectID") do
create_sys_object_id_rules(profile_id, rule["sysObjectID"])
end
# Handle sysDescr rules
if Map.has_key?(rule, "sysDescr") do
DeviceProfiles.create_detection_rule(%{
device_profile_id: profile_id,
rule_type: "sysDescr",
value: rule["sysDescr"]
})
end
# Handle sysDescr_regex rules
if Map.has_key?(rule, "sysDescr_regex") do
create_sys_descr_regex_rules(profile_id, rule["sysDescr_regex"])
end
{:ok, :processed}
end
defp create_sys_object_id_rules(profile_id, oids) when is_list(oids) do
Enum.each(oids, fn oid ->
DeviceProfiles.create_detection_rule(%{
device_profile_id: profile_id,
rule_type: "sysObjectID",
pattern: oid
})
end)
{:ok, :created}
end
defp create_sys_object_id_rules(profile_id, oid) when is_binary(oid) do
DeviceProfiles.create_detection_rule(%{
device_profile_id: profile_id,
rule_type: "sysObjectID",
pattern: oid
})
end
defp create_sys_descr_regex_rules(profile_id, patterns) when is_list(patterns) do
Enum.each(patterns, fn pattern ->
# Remove leading/trailing slashes from regex pattern
clean_pattern = String.trim(pattern, "/")
DeviceProfiles.create_detection_rule(%{
device_profile_id: profile_id,
rule_type: "sysDescr_regex",
pattern: clean_pattern
})
end)
{:ok, :created}
end
defp create_sys_descr_regex_rules(profile_id, pattern) when is_binary(pattern) do
clean_pattern = String.trim(pattern, "/")
DeviceProfiles.create_detection_rule(%{
device_profile_id: profile_id,
rule_type: "sysDescr_regex",
pattern: clean_pattern
})
end
defp maybe_import_discovery(profile, nil), do: {:ok, profile}
defp maybe_import_discovery(profile, discovery_file) do
if File.exists?(discovery_file) do
with {:ok, discovery_data} <- parse_yaml_file(discovery_file) do
import_discovery_definitions(profile, discovery_data)
end
else
{:ok, profile}
end
end
defp import_discovery_definitions(profile, data) do
# Import MIB if specified
if data["mib"] do
DeviceProfiles.update_profile(profile, %{mib: data["mib"]})
end
modules = data["modules"] || %{}
# Import OS definitions
if modules["os"] do
import_os_definitions(profile.id, modules["os"])
end
# Import processor definitions
if modules["processors"] && modules["processors"]["data"] do
import_processor_definitions(profile.id, modules["processors"]["data"])
end
# Import memory pool definitions
if modules["mempools"] && modules["mempools"]["data"] do
import_mempool_definitions(profile.id, modules["mempools"]["data"])
end
# Import sensor definitions
if modules["sensors"] do
import_sensor_definitions(profile.id, modules["sensors"])
end
{:ok, profile}
end
defp import_os_definitions(profile_id, os_data) do
Enum.each(os_data, fn {field, value} ->
attrs = %{
device_profile_id: profile_id,
field: to_string(field),
oid: if(is_binary(value), do: value)
}
DeviceProfiles.create_os_definition(attrs)
end)
end
defp import_processor_definitions(profile_id, processors) do
Enum.each(processors, fn proc ->
attrs = %{
device_profile_id: profile_id,
oid: proc["oid"],
num_oid: proc["num_oid"],
index: to_string(proc["index"] || ""),
descr: proc["descr"],
precision: proc["precision"] || 1,
type: proc["type"]
}
DeviceProfiles.create_processor_definition(attrs)
end)
end
defp import_mempool_definitions(profile_id, mempools) do
Enum.each(mempools, fn mempool ->
attrs = %{
device_profile_id: profile_id,
index: to_string(mempool["index"] || ""),
descr: mempool["descr"],
total_oid: mempool["total"],
used_oid: mempool["used"],
free_oid: mempool["free"],
percent_used_oid: mempool["percent_used"],
precision: mempool["precision"] || 1,
type: mempool["type"],
class: mempool["class"]
}
DeviceProfiles.create_mempool_definition(attrs)
end)
end
defp import_sensor_definitions(profile_id, sensors_data) do
Enum.each(sensors_data, fn {sensor_class, sensor_config} ->
if sensor_config["data"] do
import_sensors_of_class(profile_id, to_string(sensor_class), sensor_config["data"])
end
end)
end
defp import_sensors_of_class(profile_id, sensor_class, sensors) do
Enum.each(sensors, fn sensor ->
attrs = %{
device_profile_id: profile_id,
sensor_class: sensor_class,
oid: sensor["oid"],
num_oid: sensor["num_oid"],
index: to_string(sensor["index"] || ""),
descr: sensor["descr"],
divisor: calculate_divisor(sensor["precision"]),
precision: sensor["precision"],
sensor_type: sensor["type"],
low_limit: parse_float(sensor["low_limit"]),
low_warn_limit: parse_float(sensor["low_warn_limit"]),
warn_limit: parse_float(sensor["warn_limit"]),
high_limit: parse_float(sensor["high_limit"]),
skip_value_lt: parse_float(sensor["skip_value_lt"]),
skip_value_gt: parse_float(sensor["skip_value_gt"]),
user_func: sensor["user_func"],
entPhysicalIndex: sensor["entPhysicalIndex"],
group: sensor["group"],
options: extract_sensor_options(sensor)
}
case DeviceProfiles.create_sensor_definition(attrs) do
{:ok, sensor_def} ->
# Import sensor states if present
if sensor["states"] do
import_sensor_states(sensor_def.id, sensor["states"])
end
{:error, reason} ->
Logger.warning("Failed to import sensor: #{inspect(reason)}")
end
end)
end
defp import_sensor_states(sensor_definition_id, states) when is_list(states) do
Enum.each(states, fn state ->
attrs = %{
sensor_definition_id: sensor_definition_id,
value: state["value"],
descr: state["descr"],
generic: state["generic"],
graph: state["graph"]
}
DeviceProfiles.create_sensor_state(attrs)
end)
end
defp calculate_divisor(nil), do: 1
defp calculate_divisor(precision) when is_integer(precision) and precision > 0 do
10 |> :math.pow(precision) |> trunc()
end
defp calculate_divisor(_), do: 1
defp parse_float(nil), do: nil
defp parse_float(value) when is_float(value), do: value
defp parse_float(value) when is_integer(value), do: value * 1.0
defp parse_float(value) when is_binary(value), do: String.to_float(value)
defp parse_float(_), do: nil
defp extract_sensor_options(sensor) do
%{
snmp_flags: sensor["snmp_flags"],
state_name: sensor["state_name"],
skip_values: sensor["skip_values"]
}
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
end

View file

@ -0,0 +1,52 @@
defmodule Towerops.DeviceProfiles.MemPoolDefinition do
@moduledoc """
Schema for memory pool definitions.
Defines OID mappings for discovering and monitoring memory usage.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.DeviceProfiles.DeviceProfile
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_mempool_definitions" do
field :index, :string
field :descr, :string
field :total_oid, :string
field :used_oid, :string
field :free_oid, :string
field :percent_used_oid, :string
field :precision, :integer, default: 1
field :type, :string
field :class, :string
field :warn_percent, :integer
belongs_to :device_profile, DeviceProfile
timestamps(type: :utc_datetime)
end
@doc false
def changeset(definition, attrs) do
definition
|> cast(attrs, [
:index,
:descr,
:total_oid,
:used_oid,
:free_oid,
:percent_used_oid,
:precision,
:type,
:class,
:warn_percent,
:device_profile_id
])
|> validate_required([:device_profile_id])
|> foreign_key_constraint(:device_profile_id)
end
end

View file

@ -0,0 +1,37 @@
defmodule Towerops.DeviceProfiles.OSDefinition do
@moduledoc """
Schema for OS-specific metadata definitions.
Defines how to extract device version, serial number, hardware model,
and other OS-specific information.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.DeviceProfiles.DeviceProfile
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_os_definitions" do
field :field, :string
field :oid, :string
field :regex, :string
field :template, :string
field :value, :string
belongs_to :device_profile, DeviceProfile
timestamps(type: :utc_datetime)
end
@doc false
def changeset(definition, attrs) do
definition
|> cast(attrs, [:field, :oid, :regex, :template, :value, :device_profile_id])
|> validate_required([:field, :device_profile_id])
|> foreign_key_constraint(:device_profile_id)
|> unique_constraint([:device_profile_id, :field])
end
end

View file

@ -0,0 +1,48 @@
defmodule Towerops.DeviceProfiles.ProcessorDefinition do
@moduledoc """
Schema for CPU/processor definitions.
Defines OID mappings for discovering and monitoring CPU usage.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.DeviceProfiles.DeviceProfile
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_processor_definitions" do
field :oid, :string
field :num_oid, :string
field :index, :string
field :descr, :string
field :precision, :integer, default: 1
field :type, :string
field :warn_percent, :integer
field :entPhysicalIndex, :string
belongs_to :device_profile, DeviceProfile
timestamps(type: :utc_datetime)
end
@doc false
def changeset(definition, attrs) do
definition
|> cast(attrs, [
:oid,
:num_oid,
:index,
:descr,
:precision,
:type,
:warn_percent,
:entPhysicalIndex,
:device_profile_id
])
|> validate_required([:oid, :num_oid, :device_profile_id])
|> foreign_key_constraint(:device_profile_id)
end
end

View file

@ -0,0 +1,75 @@
defmodule Towerops.DeviceProfiles.SensorDefinition do
@moduledoc """
Schema for sensor definitions.
Defines OID mappings and configurations for discovering and polling
device sensors (temperature, voltage, power, etc.).
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.DeviceProfiles.DeviceProfile
alias Towerops.DeviceProfiles.SensorState
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_sensor_definitions" do
field :sensor_class, :string
field :oid, :string
field :num_oid, :string
field :index, :string
field :descr, :string
field :divisor, :integer, default: 1
field :multiplier, :integer, default: 1
field :precision, :integer
field :sensor_type, :string
field :unit, :string
field :low_limit, :float
field :low_warn_limit, :float
field :warn_limit, :float
field :high_limit, :float
field :skip_value_lt, :float
field :skip_value_gt, :float
field :user_func, :string
field :entPhysicalIndex, :string
field :group, :string
field :options, :map
belongs_to :device_profile, DeviceProfile
has_many :states, SensorState, foreign_key: :sensor_definition_id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(definition, attrs) do
definition
|> cast(attrs, [
:sensor_class,
:oid,
:num_oid,
:index,
:descr,
:divisor,
:multiplier,
:precision,
:sensor_type,
:unit,
:low_limit,
:low_warn_limit,
:warn_limit,
:high_limit,
:skip_value_lt,
:skip_value_gt,
:user_func,
:entPhysicalIndex,
:group,
:options,
:device_profile_id
])
|> validate_required([:sensor_class, :device_profile_id])
|> foreign_key_constraint(:device_profile_id)
end
end

View file

@ -0,0 +1,35 @@
defmodule Towerops.DeviceProfiles.SensorState do
@moduledoc """
Schema for sensor state definitions.
Defines enumerated states for state-based sensors (e.g., GPS sync status,
DFS status, power supply status).
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.DeviceProfiles.SensorDefinition
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_sensor_states" do
field :value, :integer
field :descr, :string
field :generic, :integer
field :graph, :integer
belongs_to :sensor_definition, SensorDefinition
timestamps(type: :utc_datetime)
end
@doc false
def changeset(state, attrs) do
state
|> cast(attrs, [:value, :descr, :generic, :graph, :sensor_definition_id])
|> validate_required([:value, :descr, :generic, :graph, :sensor_definition_id])
|> foreign_key_constraint(:sensor_definition_id)
end
end

View file

@ -14,6 +14,7 @@ defmodule Towerops.Snmp.Discovery do
import Ecto.Query
alias Towerops.DeviceProfiles
alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Repo
@ -23,6 +24,7 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Cisco
alias Towerops.Snmp.Profiles.Dynamic
alias Towerops.Snmp.Profiles.Mikrotik
alias Towerops.Snmp.Profiles.NetSnmp
alias Towerops.Snmp.Profiles.Ubiquiti
@ -74,7 +76,7 @@ defmodule Towerops.Snmp.Discovery do
optional(atom()) => term()
}
@type profile :: module()
@type profile :: module() | {:dynamic, DeviceProfiles.DeviceProfile.t()}
@type discovery_summary :: %{
success: non_neg_integer(),
@ -195,6 +197,9 @@ defmodule Towerops.Snmp.Discovery do
@doc """
Selects the appropriate SNMP profile based on system information.
First tries to match against database-stored profiles from LibreNMS.
If no database match is found, falls back to hard-coded profile modules.
## Examples
iex> select_profile(%{sys_descr: "Cisco IOS"})
@ -205,6 +210,19 @@ defmodule Towerops.Snmp.Discovery do
"""
@spec select_profile(system_info()) :: profile()
def select_profile(system_info) do
# First try to match against database profiles
case DeviceProfiles.match_profile(system_info) do
%DeviceProfiles.DeviceProfile{} = profile ->
Logger.debug("Selected database profile: #{profile.os}")
{:dynamic, profile}
nil ->
# Fall back to hard-coded profile selection
select_hardcoded_profile(system_info)
end
end
defp select_hardcoded_profile(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
sys_object_id = Map.get(system_info, :sys_object_id, "")
@ -235,8 +253,18 @@ defmodule Towerops.Snmp.Discovery do
@spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
{:ok, device_info()}
defp build_device_info(_client_opts, system_info, profile) do
# Let the profile identify the device (adds manufacturer, model, firmware_version)
defp build_device_info(_client_opts, system_info, {:dynamic, profile}) do
# Use dynamic profile to identify device
identified_info = Dynamic.identify_device(profile, system_info)
{:ok, identified_info}
rescue
_error ->
Logger.error("Failed to identify device with dynamic profile")
{:ok, system_info}
end
defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do
# Let the hard-coded profile identify the device
identified_info = profile.identify_device(system_info)
{:ok, identified_info}
rescue
@ -247,7 +275,19 @@ defmodule Towerops.Snmp.Discovery do
@spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]}
defp discover_interfaces(client_opts, profile) do
defp discover_interfaces(client_opts, {:dynamic, profile}) do
case Dynamic.discover_interfaces(profile, client_opts) do
{:ok, interfaces} ->
Logger.debug("Discovered #{length(interfaces)} interfaces")
{:ok, interfaces}
{:error, _} ->
Logger.warning("Interface discovery failed, continuing without interfaces")
{:ok, []}
end
end
defp discover_interfaces(client_opts, profile) when is_atom(profile) do
case profile.discover_interfaces(client_opts) do
{:ok, interfaces} ->
Logger.debug("Discovered #{length(interfaces)} interfaces")
@ -260,7 +300,19 @@ defmodule Towerops.Snmp.Discovery do
end
@spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]}
defp discover_sensors(client_opts, profile) do
defp discover_sensors(client_opts, {:dynamic, profile}) do
case Dynamic.discover_sensors(profile, client_opts) do
{:ok, sensors} ->
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
{:error, _} ->
Logger.warning("Sensor discovery failed, continuing without sensors")
{:ok, []}
end
end
defp discover_sensors(client_opts, profile) when is_atom(profile) do
case profile.discover_sensors(client_opts) do
{:ok, sensors} ->
Logger.debug("Discovered #{length(sensors)} sensors")

View file

@ -0,0 +1,171 @@
defmodule Towerops.Snmp.Profiles.Dynamic do
@moduledoc """
Dynamic SNMP profile that interprets database-stored device profiles.
This profile uses the device_profiles table to dynamically discover
sensors and processors based on OID definitions from LibreNMS.
"""
alias Towerops.DeviceProfiles
alias Towerops.DeviceProfiles.DeviceProfile
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Profiles.Base
require Logger
@doc """
Identifies device using the database profile information.
Returns manufacturer, model, and firmware info.
"""
@spec identify_device(DeviceProfile.t(), Discovery.system_info()) :: Discovery.device_info()
def identify_device(profile, system_info) do
# Use profile metadata to build device info
%{
manufacturer: extract_manufacturer(profile),
model: profile.text || profile.os,
firmware_version: Map.get(system_info, :sys_descr),
serial_number: nil
}
end
@doc """
Discovers network interfaces using the base profile.
Dynamic profiles inherit standard IF-MIB interface discovery.
"""
@spec discover_interfaces(DeviceProfile.t(), Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(_profile, client_opts) do
# Use base profile for standard interface discovery
Base.discover_interfaces(client_opts)
end
@doc """
Discovers sensors dynamically using database profile definitions.
Walks SNMP OIDs defined in the profile's sensor_definitions.
"""
@spec discover_sensors(DeviceProfile.t(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
def discover_sensors(profile, client_opts) do
# Load profile with sensor definitions
profile = DeviceProfiles.get_profile_with_associations(profile.os)
if profile && length(profile.sensor_definitions) > 0 do
Logger.debug("Discovering sensors for #{profile.os} using #{length(profile.sensor_definitions)} definitions")
sensors =
Enum.flat_map(profile.sensor_definitions, fn sensor_def ->
discover_sensor_from_definition(client_opts, sensor_def)
end)
{:ok, sensors}
else
Logger.debug("No sensor definitions found for profile #{profile.os}")
{:ok, []}
end
end
# Private functions
defp extract_manufacturer(%DeviceProfile{group: group}) when is_binary(group) do
# Capitalize manufacturer from group (e.g., "cambium" -> "Cambium")
String.capitalize(group)
end
defp extract_manufacturer(%DeviceProfile{text: text}) when is_binary(text) do
# Extract first word from text (e.g., "Ubiquiti AirFiber" -> "Ubiquiti")
text
|> String.split(" ")
|> List.first()
end
defp extract_manufacturer(_), do: "Unknown"
defp discover_sensor_from_definition(client_opts, sensor_def) do
# Try numeric OID first, fall back to named OID
oid = sensor_def.num_oid || sensor_def.oid
if oid do
case Client.walk(client_opts, oid) do
{:ok, results} when is_list(results) and results != [] ->
# Convert walk results to sensor data
Enum.map(results, fn result ->
build_sensor_data(sensor_def, result)
end)
{:ok, _empty} ->
Logger.debug("No data found for sensor OID: #{oid}")
[]
{:error, reason} ->
Logger.warning("Failed to walk sensor OID #{oid}: #{inspect(reason)}")
[]
end
else
Logger.warning("Sensor definition missing OID: #{inspect(sensor_def)}")
[]
end
end
defp build_sensor_data(sensor_def, %{oid: oid, value: value}) do
# Build sensor index from OID (extract last component)
sensor_index = extract_index_from_oid(oid, sensor_def.index)
# Apply divisor to value if it's numeric
last_value =
cond do
is_number(value) && sensor_def.divisor && sensor_def.divisor > 1 ->
value / sensor_def.divisor
is_number(value) ->
value * 1.0
true ->
nil
end
%{
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_unit: determine_unit(sensor_def.sensor_class),
sensor_divisor: sensor_def.divisor || 1,
last_value: last_value,
status: "ok"
}
end
defp extract_index_from_oid(oid, configured_index) when is_binary(configured_index) do
# If index is pre-configured, use it
if configured_index == "" do
# Extract last component of OID as index
oid
|> String.split(".")
|> List.last()
|> to_string()
else
configured_index
end
end
defp extract_index_from_oid(oid, _) do
# Extract last component of OID as index
oid
|> String.split(".")
|> List.last()
|> to_string()
end
defp determine_unit("temperature"), do: "°C"
defp determine_unit("voltage"), do: "V"
defp determine_unit("current"), do: "A"
defp determine_unit("power"), do: "W"
defp determine_unit("fanspeed"), do: "RPM"
defp determine_unit("humidity"), do: "%"
defp determine_unit("dbm"), do: "dBm"
defp determine_unit("snr"), do: "dB"
defp determine_unit("percent"), do: "%"
defp determine_unit("frequency"), do: "Hz"
defp determine_unit(_), do: ""
end

View file

@ -0,0 +1,162 @@
defmodule Towerops.Repo.Migrations.CreateDeviceProfiles do
use Ecto.Migration
def change do
# Main device profile table - stores high-level device type information
create table(:device_profiles, primary_key: false) do
add :id, :binary_id, primary_key: true
add :os, :string, null: false
add :text, :string, null: false
add :type, :string
add :icon, :string
add :group, :string
add :mib, :string
add :snmp_bulk, :boolean, default: true
add :mib_dir, :string
add :enabled, :boolean, default: true
add :priority, :integer, default: 100
timestamps(type: :utc_datetime)
end
create unique_index(:device_profiles, [:os])
create index(:device_profiles, [:enabled])
create index(:device_profiles, [:type])
create index(:device_profiles, [:priority])
# Detection rules - how to identify which profile to use
create table(:profile_detection_rules, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_profile_id,
references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false
add :rule_type, :string, null: false
add :oid, :text
add :operator, :string
add :value, :text
add :pattern, :text
add :priority, :integer, default: 0
timestamps(type: :utc_datetime)
end
create index(:profile_detection_rules, [:device_profile_id])
create index(:profile_detection_rules, [:rule_type])
# Sensor definitions - OID mappings for different sensor types
create table(:profile_sensor_definitions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_profile_id,
references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false
add :sensor_class, :string, null: false
add :oid, :string
add :num_oid, :string
add :index, :string
add :descr, :string
add :divisor, :integer, default: 1
add :multiplier, :integer, default: 1
add :precision, :integer
add :sensor_type, :string
add :unit, :string
add :low_limit, :float
add :low_warn_limit, :float
add :warn_limit, :float
add :high_limit, :float
add :skip_value_lt, :float
add :skip_value_gt, :float
add :user_func, :string
add :entPhysicalIndex, :string
add :group, :string
add :options, :map
timestamps(type: :utc_datetime)
end
create index(:profile_sensor_definitions, [:device_profile_id])
create index(:profile_sensor_definitions, [:sensor_class])
# State definitions - for enumerated state sensors
create table(:profile_sensor_states, primary_key: false) do
add :id, :binary_id, primary_key: true
add :sensor_definition_id,
references(:profile_sensor_definitions, type: :binary_id, on_delete: :delete_all),
null: false
add :value, :integer, null: false
add :descr, :string, null: false
add :generic, :integer, null: false
add :graph, :integer, null: false
timestamps(type: :utc_datetime)
end
create index(:profile_sensor_states, [:sensor_definition_id])
# Processor (CPU) definitions
create table(:profile_processor_definitions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_profile_id,
references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false
add :oid, :string, null: false
add :num_oid, :string, null: false
add :index, :string
add :descr, :string
add :precision, :integer, default: 1
add :type, :string
add :warn_percent, :integer
add :entPhysicalIndex, :string
timestamps(type: :utc_datetime)
end
create index(:profile_processor_definitions, [:device_profile_id])
# Memory pool definitions
create table(:profile_mempool_definitions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_profile_id,
references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false
add :index, :string
add :descr, :string
add :total_oid, :string
add :used_oid, :string
add :free_oid, :string
add :percent_used_oid, :string
add :precision, :integer, default: 1
add :type, :string
add :class, :string
add :warn_percent, :integer
timestamps(type: :utc_datetime)
end
create index(:profile_mempool_definitions, [:device_profile_id])
# OS-specific metadata (version, serial, hardware info)
create table(:profile_os_definitions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_profile_id,
references(:device_profiles, type: :binary_id, on_delete: :delete_all), null: false
add :field, :string, null: false
add :oid, :string
add :regex, :string
add :template, :text
add :value, :string
timestamps(type: :utc_datetime)
end
create index(:profile_os_definitions, [:device_profile_id])
create unique_index(:profile_os_definitions, [:device_profile_id, :field])
end
end