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:
parent
3c46c805a0
commit
007adcc489
14 changed files with 1563 additions and 6 deletions
234
lib/mix/tasks/import_profiles.ex
Normal file
234
lib/mix/tasks/import_profiles.ex
Normal 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
|
||||
|
|
@ -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, [])
|
||||
|
||||
|
|
|
|||
214
lib/towerops/device_profiles.ex
Normal file
214
lib/towerops/device_profiles.ex
Normal 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
|
||||
37
lib/towerops/device_profiles/detection_rule.ex
Normal file
37
lib/towerops/device_profiles/detection_rule.ex
Normal 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
|
||||
60
lib/towerops/device_profiles/device_profile.ex
Normal file
60
lib/towerops/device_profiles/device_profile.ex
Normal 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
|
||||
359
lib/towerops/device_profiles/importer.ex
Normal file
359
lib/towerops/device_profiles/importer.ex
Normal 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
|
||||
52
lib/towerops/device_profiles/mempool_definition.ex
Normal file
52
lib/towerops/device_profiles/mempool_definition.ex
Normal 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
|
||||
37
lib/towerops/device_profiles/os_definition.ex
Normal file
37
lib/towerops/device_profiles/os_definition.ex
Normal 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
|
||||
48
lib/towerops/device_profiles/processor_definition.ex
Normal file
48
lib/towerops/device_profiles/processor_definition.ex
Normal 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
|
||||
75
lib/towerops/device_profiles/sensor_definition.ex
Normal file
75
lib/towerops/device_profiles/sensor_definition.ex
Normal 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
|
||||
35
lib/towerops/device_profiles/sensor_state.ex
Normal file
35
lib/towerops/device_profiles/sensor_state.ex
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
171
lib/towerops/snmp/profiles/dynamic.ex
Normal file
171
lib/towerops/snmp/profiles/dynamic.ex
Normal 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
|
||||
162
priv/repo/migrations/20260118000250_create_device_profiles.exs
Normal file
162
priv/repo/migrations/20260118000250_create_device_profiles.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue