add honeybadger and snmp overhaul

This commit is contained in:
Graham McIntire 2026-01-18 16:16:08 -06:00
parent adec4b134f
commit 1f123bbeb9
No known key found for this signature in database
24 changed files with 600 additions and 2511 deletions

View file

@ -17,6 +17,12 @@ config :esbuild,
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
] ]
config :honeybadger,
api_key: "hbp_xe5xMnpLZ2XJsXQJujoEkgCmiqCfwa0uYA3Y",
environment_name: config_env(),
insights_enabled: true,
use_logger: true
# Configure Elixir's Logger # Configure Elixir's Logger
config :logger, :default_formatter, config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",

121
lib/mix/tasks/copy_mibs.ex Normal file
View file

@ -0,0 +1,121 @@
defmodule Mix.Tasks.CopyMibs do
@shortdoc "Copies MIB files from source directory to app MIB directory"
@moduledoc """
Copies MIB files from a source directory to the application's MIB directory.
## Usage
# Copy all MIB files
mix copy_mibs --source-path ~/mibs
# Copy specific vendor MIB directories
mix copy_mibs --source-path ~/mibs --vendors mikrotik,cisco,ubiquiti
"""
use Mix.Task
require Logger
@mib_dir Application.compile_env(:towerops, :mib_dir, "/app/mibs")
def run(args) do
{opts, _} =
OptionParser.parse!(args,
strict: [
source_path: :string,
vendors: :string,
target_dir: :string
]
)
source_path = opts[:source_path] || raise "Missing --source-path argument"
target_dir = opts[:target_dir] || @mib_dir
vendors = parse_vendors(opts[:vendors])
source_mibs = source_path
if !File.exists?(source_mibs) do
raise "LibreNMS MIB directory not found: #{source_mibs}"
end
# Ensure target directory exists
File.mkdir_p!(target_dir)
case vendors do
:all ->
copy_all_mibs(source_mibs, target_dir)
vendor_list ->
copy_vendor_mibs(source_mibs, target_dir, vendor_list)
end
end
defp parse_vendors(nil), do: :all
defp parse_vendors(""), do: :all
defp parse_vendors(vendors_string) do
vendors_string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp copy_all_mibs(source_dir, target_dir) do
Logger.info("Copying all MIB files from #{source_dir} to #{target_dir}...")
# Use rsync for efficient copying
case System.cmd("rsync", ["-av", "--exclude=.git", "#{source_dir}/", target_dir]) do
{output, 0} ->
Logger.info("Successfully copied MIB files")
Logger.debug(output)
:ok
{error, exit_code} ->
Logger.error("Failed to copy MIB files (exit code #{exit_code}): #{error}")
{:error, :copy_failed}
end
rescue
_e in ErlangError ->
# rsync not found, fall back to File.cp_r
Logger.warning("rsync not found, using File.cp_r (slower)")
copy_mibs_recursive(source_dir, target_dir)
end
defp copy_vendor_mibs(source_dir, target_dir, vendors) do
Logger.info("Copying MIB files for vendors: #{Enum.join(vendors, ", ")}")
results =
Enum.map(vendors, fn vendor ->
source_path = Path.join(source_dir, vendor)
target_path = Path.join(target_dir, vendor)
if File.exists?(source_path) do
Logger.info("Copying #{vendor} MIBs...")
File.mkdir_p!(target_path)
File.cp_r!(source_path, target_path)
{:ok, vendor}
else
Logger.warning("Vendor MIB directory not found: #{vendor}")
{:error, vendor}
end
end)
successes = Enum.count(results, &match?({:ok, _}, &1))
failures = Enum.count(results, &match?({:error, _}, &1))
Logger.info("Copied #{successes} vendor MIB directories (#{failures} not found)")
end
defp copy_mibs_recursive(source_dir, target_dir) do
case File.cp_r(source_dir, target_dir) do
{:ok, _files} ->
Logger.info("Successfully copied MIB files")
:ok
{:error, reason, file} ->
Logger.error("Failed to copy #{file}: #{inspect(reason)}")
{:error, :copy_failed}
end
end
end

View file

@ -1,179 +0,0 @@
defmodule Mix.Tasks.ExportProfiles do
@moduledoc """
Exports device SNMP profiles to JSON format for uploading to production.
## Usage
# Export all profiles to JSON
mix export_profiles --librenms-path ~/dev/librenms --output profiles.json
# Export specific profiles
mix export_profiles --librenms-path ~/dev/librenms --profiles epmp,airos-af --output profiles.json
## Options
--librenms-path PATH - Path to external installation (required)
--output PATH - Output JSON file path (default: profiles.json)
--profiles LIST - Export only specific profiles (comma-separated, optional)
## Output Format
The exported JSON file contains all profile data in a format that can be uploaded
to the production server via API:
POST /api/v1/admin/profiles/import
Content-Type: application/json
Authorization: Bearer YOUR_TOKEN
"""
use Mix.Task
@requirements ["app.start"]
@impl Mix.Task
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [
librenms_path: :string,
output: :string,
profiles: :string
]
)
librenms_path = opts[:librenms_path] || guess_librenms_path()
output_file = opts[:output] || "profiles.json"
profile_filter = parse_profile_filter(opts[:profiles])
if librenms_path do
Mix.shell().info("Exporting profiles from: #{librenms_path}")
export_profiles(librenms_path, output_file, profile_filter)
else
Mix.shell().error("Please specify --librenms-path option")
print_usage()
end
end
defp export_profiles(librenms_path, output_file, profile_filter) do
detection_dir = Path.join(librenms_path, "resources/definitions/os_detection")
discovery_dir = Path.join(librenms_path, "resources/definitions/os_discovery")
detection_files = Path.wildcard(Path.join(detection_dir, "*.yaml"))
# Filter profiles if specified
detection_files =
if profile_filter do
Enum.filter(detection_files, fn file ->
os_name = Path.basename(file, ".yaml")
os_name in profile_filter
end)
else
detection_files
end
Mix.shell().info("Found #{length(detection_files)} profiles to export")
Mix.shell().info("Reading YAML files...")
# Read and parse all profiles
profiles =
detection_files
|> Enum.map(fn detection_file ->
os_name = Path.basename(detection_file, ".yaml")
discovery_file = Path.join(discovery_dir, "#{os_name}.yaml")
Mix.shell().info(" Reading: #{os_name}")
detection_data = read_yaml_file(detection_file)
discovery_data =
if File.exists?(discovery_file) do
read_yaml_file(discovery_file)
end
%{
os: os_name,
detection: detection_data,
discovery: discovery_data
}
end)
|> Enum.reject(&is_nil(&1.detection))
Mix.shell().info("\nWriting to: #{output_file}")
# Write JSON file
json_data = Jason.encode!(profiles, pretty: true)
File.write!(output_file, json_data)
file_size = format_bytes(File.stat!(output_file).size)
Mix.shell().info("✓ Exported #{length(profiles)} profiles (#{file_size})")
Mix.shell().info("\nUpload to production (requires superuser API token):")
Mix.shell().info("""
curl -X POST https://towerops.net/api/v1/profiles/import \\
-H "Authorization: Bearer YOUR_SUPERUSER_API_TOKEN" \\
-H "Content-Type: application/json" \\
-d @#{output_file}
Note: You must create an API token using a superuser account.
Only tokens created by superusers can import device profiles.
""")
end
defp read_yaml_file(file_path) do
case YamlElixir.read_from_file(file_path) do
{:ok, data} ->
data
{:error, reason} ->
Mix.shell().error(" Failed to read #{file_path}: #{inspect(reason)}")
nil
end
end
defp parse_profile_filter(nil), do: nil
defp parse_profile_filter(profiles_string) do
profiles_string
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
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 format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_bytes(bytes), do: "#{Float.round(bytes / (1024 * 1024), 1)} MB"
defp print_usage do
Mix.shell().info("""
Usage:
mix export_profiles [options]
Options:
--librenms-path PATH Path to external installation (required)
--output PATH Output JSON file (default: profiles.json)
--profiles LIST Export specific profiles (comma-separated)
Examples:
# Export all profiles
mix export_profiles --librenms-path ~/dev/librenms --output profiles.json
# Export specific profiles
mix export_profiles --librenms-path ~/dev/librenms --profiles epmp,unifi,airos-af
""")
end
end

View file

@ -1,229 +0,0 @@
defmodule Mix.Tasks.ImportProfiles do
@moduledoc """
Imports device SNMP profiles from external 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 external 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 external
--librenms-path PATH - Path to external 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("external 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...")
{:ok, stats} = Importer.import_all_from_directory(detection_dir, discovery_dir)
Mix.shell().info("\nImport complete!")
Mix.shell().info("Successfully imported: #{stats.success} profiles")
Mix.shell().info("Failed to import: #{stats.failed} profiles")
else
Mix.shell().error("external 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 external
--librenms-path PATH Path to external 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

@ -1,290 +0,0 @@
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 """
Upserts a device profile by OS name.
If a profile with the given OS exists, it updates it and deletes all associated
definitions (detection rules, sensors, etc.) to allow re-import from scratch.
Otherwise, creates a new profile.
"""
def upsert_profile(attrs \\ %{}) do
os = attrs[:os] || attrs["os"]
case get_profile(os) do
nil ->
# Create new profile
create_profile(attrs)
existing_profile ->
# Delete all associated definitions to get a clean slate
delete_profile_associations(existing_profile.id)
# Update the profile
update_profile(existing_profile, attrs)
end
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 delete_profile_associations(profile_id) do
# Delete in order to respect foreign key constraints
Repo.delete_all(
from s in SensorState,
join: sd in SensorDefinition,
on: s.sensor_definition_id == sd.id,
where: sd.device_profile_id == ^profile_id
)
Repo.delete_all(from s in SensorDefinition, where: s.device_profile_id == ^profile_id)
Repo.delete_all(from p in ProcessorDefinition, where: p.device_profile_id == ^profile_id)
Repo.delete_all(from m in MemPoolDefinition, where: m.device_profile_id == ^profile_id)
Repo.delete_all(from o in OSDefinition, where: o.device_profile_id == ^profile_id)
Repo.delete_all(from d in DetectionRule, where: d.device_profile_id == ^profile_id)
end
defp list_profiles_with_detection_rules do
# Load all enabled profiles with detection rules
profiles =
Repo.all(
from p in DeviceProfile,
where: p.enabled == true,
preload: [:detection_rules]
)
# Sort profiles by:
# 1. Priority (ascending - lower number = higher priority)
# 2. Pattern specificity (descending - longer pattern = more specific)
# This ensures more specific patterns like RouterOS (.1.3.6.1.4.1.14988.1)
# are checked before generic patterns like Supermicro (.1.3.6.1.4.1.)
Enum.sort_by(profiles, fn profile ->
max_pattern_length = get_max_pattern_length(profile.detection_rules)
{profile.priority, -max_pattern_length}
end)
end
defp get_max_pattern_length(rules) do
rules
|> Enum.filter(&(&1.rule_type == "sysObjectID"))
|> Enum.map(fn rule -> String.length(rule.pattern || "") end)
|> Enum.max(fn -> 0 end)
end
defp match_detection_rules?(rules, system_info) do
# Group rules by rule_group (each group represents a detection block from YAML)
# OR logic between groups, AND logic within groups
rules
|> Enum.group_by(& &1.rule_group)
|> Enum.any?(fn {_group_id, group_rules} ->
# Within a group, ALL field types must match (AND logic)
# But we need to handle multiple values for the same field type (OR within field)
group_rules
|> Enum.group_by(& &1.rule_type)
|> Enum.all?(fn {_rule_type, type_rules} ->
# Within the same field type, ANY rule can match (OR logic)
Enum.any?(type_rules, &match_rule?(&1, system_info))
end)
end)
end
defp match_rule?(%DetectionRule{rule_type: "sysObjectID"} = rule, system_info) do
sys_object_id = Map.get(system_info, :sys_object_id, "")
# Normalize OID - prepend dot if not present for pattern matching
# Device profiles use ".1.3.6.1..." format, SNMP returns "1.3.6.1..." format
normalized_oid = if String.starts_with?(sys_object_id, "."), do: sys_object_id, else: "." <> sys_object_id
cond do
rule.pattern && rule.pattern != "" ->
String.starts_with?(normalized_oid, rule.pattern)
rule.value && rule.value != "" ->
normalized_value = if String.starts_with?(rule.value, "."), do: rule.value, else: "." <> rule.value
normalized_oid == normalized_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

@ -1,47 +0,0 @@
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
field :rule_group, :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,
:rule_group,
:device_profile_id
])
|> validate_required([:rule_type, :device_profile_id])
|> foreign_key_constraint(:device_profile_id)
end
end

View file

@ -1,60 +0,0 @@
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

@ -1,478 +0,0 @@
defmodule Towerops.DeviceProfiles.Importer do
@moduledoc """
Imports external 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 external 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 a device profile from parsed data structures (instead of files).
## Parameters
- detection_data: Parsed YAML map from os_detection
- discovery_data: Parsed YAML map from os_discovery (optional)
## Returns
- {:ok, profile} on success
- {:error, reason} on failure
"""
def import_profile_from_data(detection_data, discovery_data \\ nil) when is_map(detection_data) do
Repo.transaction(fn ->
with {:ok, profile} <- create_profile_from_detection(detection_data),
{:ok, profile} <- maybe_import_discovery_data(profile, discovery_data) 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 failures != [] 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
}
# Upsert profile - updates if exists, creates if new
# Deletes all associated definitions before update for clean re-import
case DeviceProfiles.upsert_profile(attrs) do
{:ok, profile} ->
# Create detection rules (associations were deleted in upsert)
:ok = create_detection_rules(profile.id, data["discovery"] || [])
{:ok, profile}
{:error, _} = error ->
error
end
end
defp create_detection_rules(profile_id, rules) when is_list(rules) do
# Each rule in the list is a separate detection block
# Uses OR logic between blocks, AND logic within blocks
rules
|> Enum.with_index()
|> Enum.each(fn {rule, group_index} ->
parse_detection_rule(profile_id, rule, group_index)
end)
:ok
end
defp parse_detection_rule(profile_id, rule, group_index) do
# Handle sysObjectID rules
if Map.has_key?(rule, "sysObjectID") do
create_sys_object_id_rules(profile_id, rule["sysObjectID"], group_index)
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"],
rule_group: group_index
})
end
# Handle sysDescr_regex rules
if Map.has_key?(rule, "sysDescr_regex") do
create_sys_descr_regex_rules(profile_id, rule["sysDescr_regex"], group_index)
end
{:ok, :processed}
end
defp create_sys_object_id_rules(profile_id, oids, group_index) when is_list(oids) do
Enum.each(oids, fn oid ->
DeviceProfiles.create_detection_rule(%{
device_profile_id: profile_id,
rule_type: "sysObjectID",
pattern: oid,
rule_group: group_index
})
end)
{:ok, :created}
end
defp create_sys_object_id_rules(profile_id, oid, group_index) when is_binary(oid) do
DeviceProfiles.create_detection_rule(%{
device_profile_id: profile_id,
rule_type: "sysObjectID",
pattern: oid,
rule_group: group_index
})
end
defp create_sys_descr_regex_rules(profile_id, patterns, group_index) 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,
rule_group: group_index
})
end)
{:ok, :created}
end
defp create_sys_descr_regex_rules(profile_id, pattern, group_index) 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,
rule_group: group_index
})
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 maybe_import_discovery_data(profile, nil), do: {:ok, profile}
defp maybe_import_discovery_data(profile, discovery_data) when is_map(discovery_data) do
import_discovery_definitions(profile, discovery_data)
end
defp import_discovery_definitions(profile, data) do
# Import MIB if specified (truncate to 255 chars if needed)
if data["mib"] do
mib =
if String.length(data["mib"]) > 255 do
String.slice(data["mib"], 0, 255)
else
data["mib"]
end
DeviceProfiles.update_profile(profile, %{mib: 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 ->
# Skip sensors without a valid OID (required for SNMP polling)
# Check for both nil and empty string
has_valid_oid = present?(sensor["oid"]) || present?(sensor["num_oid"])
if has_valid_oid do
# Calculate divisor with validation
raw_divisor = sensor["divisor"] || calculate_divisor(sensor["precision"])
divisor = validate_divisor(raw_divisor)
attrs = %{
device_profile_id: profile_id,
sensor_class: sensor_class,
oid: nilify_blank(sensor["oid"]),
num_oid: nilify_blank(sensor["num_oid"]),
index: to_string(sensor["index"] || ""),
descr: sensor["descr"],
divisor: divisor,
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)
}
import_sensor_with_states(attrs, sensor["states"])
end
end)
end
defp import_sensor_with_states(attrs, states) do
case DeviceProfiles.create_sensor_definition(attrs) do
{:ok, sensor_def} ->
# Import sensor states if present
if states do
import_sensor_states(sensor_def.id, states)
end
{:error, reason} ->
Logger.warning("Failed to import sensor: #{inspect(reason)}")
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
# Precision in device profiles is the divisor value itself, not an exponent
# For example: precision: 10 means divide by 10, precision: 1024 means divide by 1024
precision
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
case Float.parse(value) do
{float, _remainder} -> float
:error -> nil
end
end
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
# Check if a value is present (not nil and not empty string)
defp present?(nil), do: false
defp present?(""), do: false
defp present?(_), do: true
# Validate divisor to ensure it fits in PostgreSQL integer range
# PostgreSQL integer: -2147483648 to 2147483647
@max_integer 2_147_483_647
defp validate_divisor(divisor) when is_integer(divisor) do
cond do
divisor > @max_integer ->
Logger.warning("Divisor #{divisor} exceeds PostgreSQL integer limit, capping at #{@max_integer}")
@max_integer
divisor < -@max_integer ->
Logger.warning("Divisor #{divisor} below PostgreSQL integer limit, capping at -#{@max_integer}")
-@max_integer
true ->
divisor
end
end
defp validate_divisor(divisor) when is_float(divisor) do
validate_divisor(trunc(divisor))
end
defp validate_divisor(nil), do: 1
defp validate_divisor(_), do: 1
# Convert OID values, preserving all data types since the field is now JSONB
# Handles:
# - Empty strings -> nil
# - Arrays (data corruption) -> extract first element if it's a string
# - Everything else -> pass through
defp nilify_blank(nil), do: nil
defp nilify_blank(""), do: nil
defp nilify_blank([single_oid | _rest]) when is_binary(single_oid) do
Logger.warning("OID stored as array (data corruption), extracting: #{single_oid}")
single_oid
end
defp nilify_blank([_non_string | _rest] = value) do
Logger.warning("OID array contains non-string values, skipping: #{inspect(value)}")
nil
end
defp nilify_blank(value), do: value
end

View file

@ -1,52 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,48 +0,0 @@
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

@ -1,76 +0,0 @@
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
alias Towerops.EctoTypes.JsonAny
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_sensor_definitions" do
field :sensor_class, :string
field :oid, JsonAny
field :num_oid, JsonAny
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, JsonAny
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

@ -1,35 +0,0 @@
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

@ -239,6 +239,10 @@ defmodule Towerops.Devices do
Deletes device. Deletes device.
""" """
def delete_device(%DeviceSchema{} = device) do def delete_device(%DeviceSchema{} = device) do
# Stop monitoring workers before deleting
Towerops.Monitoring.Supervisor.stop_monitor(device.id)
Towerops.Monitoring.Supervisor.stop_snmp_poller(device.id)
Repo.delete(device) Repo.delete(device)
end end

View file

@ -14,7 +14,6 @@ defmodule Towerops.Snmp.Discovery do
import Ecto.Query import Ecto.Query
alias Towerops.DeviceProfiles
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Repo alias Towerops.Repo
@ -24,7 +23,6 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Snmp.NeighborDiscovery alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.Profiles.Base alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Cisco alias Towerops.Snmp.Profiles.Cisco
alias Towerops.Snmp.Profiles.Dynamic
alias Towerops.Snmp.Profiles.Mikrotik alias Towerops.Snmp.Profiles.Mikrotik
alias Towerops.Snmp.Profiles.NetSnmp alias Towerops.Snmp.Profiles.NetSnmp
alias Towerops.Snmp.Profiles.Ubiquiti alias Towerops.Snmp.Profiles.Ubiquiti
@ -76,7 +74,7 @@ defmodule Towerops.Snmp.Discovery do
optional(atom()) => term() optional(atom()) => term()
} }
@type profile :: module() | {:dynamic, DeviceProfiles.DeviceProfile.t()} @type profile :: module()
@type discovery_summary :: %{ @type discovery_summary :: %{
success: non_neg_integer(), success: non_neg_integer(),
@ -209,19 +207,6 @@ defmodule Towerops.Snmp.Discovery do
""" """
@spec select_profile(system_info()) :: profile() @spec select_profile(system_info()) :: profile()
def select_profile(system_info) do 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_descr = Map.get(system_info, :sys_descr, "")
sys_object_id = Map.get(system_info, :sys_object_id, "") sys_object_id = Map.get(system_info, :sys_object_id, "")
@ -252,16 +237,6 @@ defmodule Towerops.Snmp.Discovery do
@spec build_device_info(Client.connection_opts(), system_info(), profile()) :: @spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
{:ok, device_info()} {:ok, device_info()}
defp build_device_info(client_opts, system_info, {:dynamic, profile}) do
# Use dynamic profile to identify device
identified_info = Dynamic.identify_device(profile, client_opts, system_info)
{:ok, identified_info}
rescue
error ->
Logger.error("Failed to identify device with dynamic profile: #{inspect(error)}")
{:ok, system_info}
end
defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do
# Let the hard-coded profile identify the device # Let the hard-coded profile identify the device
identified_info = profile.identify_device(system_info) identified_info = profile.identify_device(system_info)
@ -274,18 +249,6 @@ defmodule Towerops.Snmp.Discovery do
@spec discover_interfaces(Client.connection_opts(), profile()) :: @spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]} {:ok, [interface_data()]}
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 defp discover_interfaces(client_opts, profile) when is_atom(profile) do
case profile.discover_interfaces(client_opts) do case profile.discover_interfaces(client_opts) do
{:ok, interfaces} -> {:ok, interfaces} ->
@ -299,12 +262,6 @@ defmodule Towerops.Snmp.Discovery do
end end
@spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]} @spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]}
defp discover_sensors(client_opts, {:dynamic, profile}) do
{:ok, sensors} = Dynamic.discover_sensors(profile, client_opts)
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
end
defp discover_sensors(client_opts, profile) when is_atom(profile) do defp discover_sensors(client_opts, profile) when is_atom(profile) do
case profile.discover_sensors(client_opts) do case profile.discover_sensors(client_opts) do
{:ok, sensors} -> {:ok, sensors} ->

View file

@ -71,8 +71,13 @@ defmodule Towerops.Snmp.PollerWorker do
@impl true @impl true
def handle_info(:poll_data, state) do def handle_info(:poll_data, state) do
_ = perform_poll(state.device_id) case perform_poll(state.device_id) do
{:noreply, state} :stop ->
{:stop, :normal, state}
:ok ->
{:noreply, state}
end
end end
@impl true @impl true
@ -91,17 +96,30 @@ defmodule Towerops.Snmp.PollerWorker do
@impl true @impl true
def handle_cast(:poll_now, state) do def handle_cast(:poll_now, state) do
_ = perform_poll(state.device_id) case perform_poll(state.device_id) do
{:noreply, state} :stop ->
{:stop, :normal, state}
:ok ->
{:noreply, state}
end
end end
# Private Functions # Private Functions
defp perform_poll(device_id) do defp perform_poll(device_id) do
device = Devices.get_device!(device_id) case Devices.get_device(device_id) do
nil ->
Logger.warning("Device #{device_id} not found, stopping poller")
# Device was deleted, return stop signal
:stop
if device.snmp_enabled do device ->
poll_equipment_device(device_id, device) if device.snmp_enabled do
poll_equipment_device(device_id, device)
end
:ok
end end
end end

View file

@ -1,758 +0,0 @@
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 imported OID definitions.
"""
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.
Delegates to hardcoded vendor profiles when available for accurate device identification.
Falls back to database OS definitions with MIB name translation.
"""
@spec identify_device(DeviceProfile.t(), Client.connection_opts(), Discovery.system_info()) ::
Discovery.device_info()
def identify_device(profile, client_opts, system_info) do
# Load profile with OS definitions
profile = DeviceProfiles.get_profile_with_associations(profile.os)
# Prefer hardcoded vendor profiles when available for accurate identification
# They use numeric OIDs and vendor-specific logic
case delegate_to_vendor_profile(profile, client_opts, system_info) do
{:ok, device_info} ->
Logger.debug("Using vendor-specific profile for device identification")
device_info
:no_vendor_profile ->
# Fall back to database-driven identification with MIB translation
Logger.debug("Using database profile with MIB translation for device identification")
os_data = poll_os_definitions(profile, client_opts)
%{
manufacturer: extract_manufacturer(profile),
model: profile.text || profile.os,
firmware_version: os_data[:version] || os_data[:firmware_version] || profile.os,
serial_number: os_data[:serial]
}
end
end
@doc """
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 and processor_definitions.
"""
@spec discover_sensors(DeviceProfile.t(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]}
def discover_sensors(profile, client_opts) do
# Load profile with sensor and processor definitions
profile = DeviceProfiles.get_profile_with_associations(profile.os)
sensor_count = if profile, do: length(profile.sensor_definitions), else: 0
processor_count = if profile, do: length(profile.processor_definitions), else: 0
if profile && (sensor_count > 0 || processor_count > 0) do
Logger.debug(
"Discovering sensors for #{profile.os} (#{sensor_count} sensor defs, #{processor_count} processor defs)"
)
# Discover regular sensors
sensors =
Enum.flat_map(profile.sensor_definitions, fn sensor_def ->
discover_sensor_from_definition(client_opts, sensor_def)
end)
# Discover processors as sensors
cpu_sensors =
Enum.flat_map(profile.processor_definitions, fn proc_def ->
discover_processor_as_sensor(client_opts, proc_def)
end)
{:ok, sensors ++ cpu_sensors}
else
Logger.debug("No sensor or processor definitions found for profile #{profile.os}")
{:ok, []}
end
end
# Private functions
# Delegate device identification to hardcoded vendor profiles when available
# This allows us to use vendor-specific logic with numeric OIDs for proper identification
defp delegate_to_vendor_profile(profile, _client_opts, system_info) do
case profile.group do
"mikrotik" ->
# Use hardcoded MikroTik profile for device identification
alias Towerops.Snmp.Profiles.Mikrotik
device_info = Mikrotik.identify_device(system_info)
{:ok, device_info}
_ ->
:no_vendor_profile
end
end
defp poll_os_definitions(profile, client_opts) do
alias Towerops.Snmp.MibTranslator
# Get all OS definitions for this profile
os_defs = profile.os_definitions || []
# Poll each OS definition and build a map of field => value
Map.new(os_defs, fn os_def ->
value =
if os_def.oid do
# Translate MIB name to numeric OID if needed
case MibTranslator.translate(os_def.oid) do
{:ok, numeric_oid} ->
# Poll the numeric OID
case Client.get(client_opts, numeric_oid) do
{:ok, result} -> result
{:error, _} -> nil
end
{:error, _} ->
Logger.debug("Failed to translate MIB name: #{os_def.oid}")
nil
end
end
# Convert field name to atom for the map key
field_atom =
case os_def.field do
"version" -> :version
"serial" -> :serial
"hardware" -> :hardware
"features" -> :features
field -> String.to_atom(field)
end
{field_atom, value}
end)
end
defp extract_manufacturer(%DeviceProfile{group: group}) when is_binary(group) do
# 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_processor_as_sensor(client_opts, proc_def) do
# Try numeric OID first, fall back to named OID
oid = proc_def.num_oid || proc_def.oid
if oid do
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
if String.contains?(oid, "::") do
Logger.debug("Skipping symbolic MIB name in processor: #{oid}")
[]
else
# Remove template variables from OID
walk_oid = clean_oid_template(oid)
walk_processor_oid(client_opts, walk_oid, proc_def)
end
else
Logger.warning("Processor definition missing OID: #{inspect(proc_def)}")
[]
end
end
defp walk_processor_oid(client_opts, walk_oid, proc_def) do
case Client.walk(client_opts, walk_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
# Convert walk results (map) to CPU sensor data
Enum.map(results, fn {result_oid, value} ->
build_processor_sensor_data(proc_def, %{oid: result_oid, value: value})
end)
{:ok, _empty} ->
Logger.debug("No data found for processor OID: #{walk_oid}")
[]
{:error, reason} ->
Logger.warning("Failed to walk processor OID #{walk_oid}: #{inspect(reason)}")
[]
end
end
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
# Handle case where OID is mistakenly stored as array (data corruption)
oid_string =
case oid do
[single_oid | _] when is_binary(single_oid) -> single_oid
oid when is_binary(oid) -> oid
_ -> nil
end
if oid_string do
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
if String.contains?(oid_string, "::") do
Logger.debug("Skipping symbolic MIB name: #{oid_string}")
[]
else
# Remove template variables like ".{{ $index }}" from OID
# For walks, we need the base OID without the index placeholder
walk_oid = clean_oid_template(oid_string)
# Check if descr references another OID column (e.g., MIKROTIK-MIB::mtxrGaugeName)
descr_map = maybe_walk_descr_oid(client_opts, walk_oid, sensor_def.descr)
walk_sensor_oid(client_opts, walk_oid, sensor_def, descr_map)
end
else
Logger.warning("Sensor definition has invalid OID format: #{inspect(oid)}")
[]
end
else
Logger.warning("Sensor definition missing OID: #{inspect(sensor_def)}")
[]
end
end
defp walk_sensor_oid(client_opts, walk_oid, sensor_def, descr_map) do
case Client.walk(client_opts, walk_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
# Get skip_values filtering rules from sensor definition options
skip_values = get_skip_values(sensor_def)
# Walk filter OID if skip_values are defined
{filter_map, descr_map_with_fallback} =
if skip_values == [] do
{nil, descr_map}
else
apply_skip_values_filter(client_opts, walk_oid, skip_values, descr_map)
end
# Convert walk results (map) to sensor data, applying filters if present
results
|> Enum.map(fn {result_oid, value} ->
{result_oid, value, extract_index_from_full_oid(result_oid)}
end)
|> Enum.filter(fn {_oid, _value, index} ->
should_include_sensor?(sensor_def, index, filter_map, skip_values)
end)
|> Enum.map(fn {result_oid, value, _index} ->
build_sensor_data(sensor_def, %{oid: result_oid, value: value}, descr_map_with_fallback)
end)
{:ok, _empty} ->
Logger.debug("No data found for sensor OID: #{walk_oid}")
[]
{:error, reason} ->
Logger.warning("Failed to walk sensor OID #{walk_oid}: #{inspect(reason)}")
[]
end
end
# Check if this is a MikroTik mtxrGaugeTable sensor
# Extract skip_values filtering rules from sensor definition options
defp get_skip_values(%{options: %{"skip_values" => skip_values}}) when is_list(skip_values), do: skip_values
defp get_skip_values(_sensor_def), do: []
# Apply skip_values filter by walking the referenced OID
# Returns {filter_map, descr_map_with_fallback}
defp apply_skip_values_filter(client_opts, value_oid, [skip_rule | _], descr_map) do
# Currently only handle first skip_values rule (LibreNMS profiles only have one)
filter_oid_name = skip_rule["oid"]
# Derive the filter OID from the value OID and the filter column reference
filter_oid = derive_filter_oid(value_oid, filter_oid_name)
Logger.info("Walking filter column #{filter_oid_name}: #{filter_oid}")
case Client.walk(client_opts, filter_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
filter_map =
Map.new(results, fn {oid, value} ->
index = oid |> String.split(".") |> List.last()
{index, value}
end)
Logger.info("Found #{map_size(filter_map)} filter entries")
# Firmware bug workaround: if filter column returns strings and description column
# failed, use filter column strings as descriptions
descr_fallback =
if map_size(descr_map) == 0 and Enum.any?(filter_map, fn {_k, v} -> is_binary(v) end) do
Logger.info("Using filter column strings as sensor descriptions (firmware workaround)")
filter_map
else
descr_map
end
{filter_map, descr_fallback}
{:error, reason} ->
Logger.info("Filter column not available (#{inspect(reason)}), will include all sensors")
{nil, descr_map}
{:ok, _empty} ->
Logger.info("Filter column returned no data, will include all sensors")
{nil, descr_map}
end
end
defp apply_skip_values_filter(_client_opts, _value_oid, [], descr_map), do: {nil, descr_map}
# Determine if a sensor should be included based on skip_values filter
defp should_include_sensor?(_sensor_def, _index, nil, _skip_values), do: true
defp should_include_sensor?(sensor_def, index, filter_map, skip_values) when is_map(filter_map) do
case Map.get(filter_map, index) do
nil ->
# No filter value for this index, include it
true
filter_value ->
# Apply skip_values logic: if any rule evaluates to true, SKIP the sensor
# Also handle firmware bug where filter returns strings - match by sensor class
should_skip =
Enum.any?(skip_values, fn rule ->
evaluate_skip_rule(rule, filter_value, sensor_def.sensor_class)
end)
not should_skip
end
end
# Evaluate a single skip_values rule
# Returns true if the sensor should be SKIPPED (filtered out)
defp evaluate_skip_rule(%{"op" => "!=", "value" => expected_value}, actual_value, sensor_class) do
# Skip if actual != expected (i.e., keep only if actual == expected)
# Handle both numeric values and string values (firmware bug workaround)
if is_binary(actual_value) do
# Firmware returns strings like "cpu-temperature" instead of unit codes
# Match by sensor class instead
not matches_sensor_class_in_string?(sensor_class, actual_value)
else
actual_value != expected_value
end
end
defp evaluate_skip_rule(%{"op" => "==", "value" => expected_value}, actual_value, _sensor_class) do
# Skip if actual == expected
actual_value == expected_value
end
defp evaluate_skip_rule(_rule, _actual_value, _sensor_class), do: false
# Firmware bug workaround: match sensor class in string value
defp matches_sensor_class_in_string?("temperature", str), do: String.contains?(str, "temperature")
defp matches_sensor_class_in_string?("voltage", str), do: String.contains?(str, "voltage")
defp matches_sensor_class_in_string?("current", str), do: String.contains?(str, "current")
defp matches_sensor_class_in_string?("power", str), do: String.contains?(str, "power")
defp matches_sensor_class_in_string?("fanspeed", str), do: String.contains?(str, "fan")
defp matches_sensor_class_in_string?("state", str),
do: String.contains?(str, "state") or String.contains?(str, "status")
defp matches_sensor_class_in_string?(_, _), do: false
# Derive the filter OID from the value OID and filter column name
# Handles both numeric OIDs and symbolic MIB names
defp derive_filter_oid(value_oid, filter_name) when is_binary(filter_name) do
cond do
# If filter_name contains MIB reference, try to derive column OID
String.contains?(filter_name, "::") ->
derive_oid_from_mib_name(value_oid, filter_name)
# If it's already a numeric OID, use it directly
String.match?(filter_name, ~r/^[\d\.]+$/) ->
filter_name
# Unknown format, return value OID as fallback
true ->
Logger.warning("Unknown filter OID format: #{filter_name}, using value OID as fallback")
value_oid
end
end
# Derive OID from MIB column name by analyzing the table structure
# For SNMP tables following .table.entry.column.index pattern
defp derive_oid_from_mib_name(value_oid, mib_name) do
# Extract the column name from MIB reference
column_suffix = mib_name |> String.split("::") |> List.last()
# Known column mappings for common MIB tables
# This could be extended or moved to database if needed
column_map = %{
# MikroTik mtxrGaugeTable: .100.1.{column}
"mtxrGaugeName" => 1,
"mtxrGaugeUnit" => 2,
"mtxrGaugeValue" => 3,
# MikroTik mtxrOpticalTable: .19.1.1.{column}
"mtxrOpticalName" => 2,
"mtxrOpticalRxPower" => 10,
"mtxrOpticalTxPower" => 9,
# MikroTik mtxrPOETable: .15.1.1.{column}
"mtxrPOEName" => 1,
"mtxrPOEVoltage" => 4,
"mtxrPOECurrent" => 5
}
case Map.get(column_map, column_suffix) do
nil ->
Logger.debug("No column mapping for #{column_suffix}, using value OID")
value_oid
target_column ->
# Replace the column number in the OID path
# Pattern: find .table.entry.{column}.{index} and replace column
replace_column_in_oid(value_oid, target_column)
end
end
# Generic column replacement in SNMP table OID
# Finds the last column number before the index and replaces it
defp replace_column_in_oid(oid, new_column) do
# Match standard SNMP table pattern: ...table.entry.column[.index]
# Replace the column number (second-to-last or last component)
case oid |> String.split(".") |> Enum.reverse() do
[index | [_old_column | rest]] when index != "" ->
# Has index: ...column.index -> ...new_column.index
Enum.join(Enum.reverse(rest, [to_string(new_column), index]), ".")
[_old_column | rest] ->
# No index: ...column -> ...new_column
Enum.join(Enum.reverse(rest, [to_string(new_column)]), ".")
_ ->
# Can't parse, return original
oid
end
end
# Extract numeric index from full OID
defp extract_index_from_full_oid(oid) do
oid |> String.split(".") |> List.last()
end
# Walk description OID if descr contains a symbolic MIB reference
defp maybe_walk_descr_oid(client_opts, value_oid, descr) when is_binary(descr) do
cond do
# Handle templates like "{{ MIKROTIK-MIB::mtxrOpticalName }} Tx Bias"
String.contains?(descr, "{{") and String.contains?(descr, "::") ->
# Extract symbolic name from template
case Regex.run(~r/\{\{\s*([A-Z0-9\-]+::[a-zA-Z0-9]+)\s*\}\}/, descr) do
[_full_match, symbolic_name] ->
walk_and_resolve_descriptions(client_opts, value_oid, symbolic_name, :template)
_ ->
%{}
end
# Handle simple symbolic names like "MIKROTIK-MIB::mtxrGaugeName"
String.contains?(descr, "::") and is_simple_symbolic_name?(descr) ->
walk_and_resolve_descriptions(client_opts, value_oid, descr, :simple)
true ->
%{}
end
end
defp maybe_walk_descr_oid(_client_opts, _value_oid, _descr), do: %{}
defp walk_and_resolve_descriptions(client_opts, value_oid, symbolic_name, type) do
descr_oid = derive_descr_oid(value_oid, symbolic_name)
Logger.info("Resolving symbolic name #{symbolic_name} (#{type}): walking #{descr_oid}")
case Client.walk(client_opts, descr_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
# Build map of index -> description
descr_map =
Map.new(results, fn {oid, value} ->
index = oid |> String.split(".") |> List.last()
{index, to_string(value)}
end)
Logger.info("Resolved #{map_size(descr_map)} descriptions for #{symbolic_name}: #{inspect(descr_map)}")
descr_map
{:ok, _results} ->
Logger.warning("Empty results for description OID: #{descr_oid} for #{symbolic_name}")
%{}
{:error, reason} ->
Logger.warning("Failed to walk description OID: #{descr_oid} for #{symbolic_name}: #{inspect(reason)}")
%{}
end
end
# Check if description is a simple symbolic name (not a template)
# Simple: "MIKROTIK-MIB::mtxrGaugeName"
# Template: "{{ PowerNet-MIB::pduOutletMeteredStatusName }}"
# Complex template: "GE{{ ADVANTECH-EKI-PRONEER-MIB::poePortStatusIndex }} POE"
defp is_simple_symbolic_name?(descr) do
String.contains?(descr, "::") and not String.contains?(descr, "{{")
end
@doc false
# Derive description OID from value OID
# Most SNMP tables follow the pattern: .table_base.entry.column.index
# Description/name is typically in column 1 or 2
# Public for testing purposes
def derive_descr_oid(value_oid, descr) do
# Vendor-specific mappings where we know the exact column
vendor_specific_oid =
cond do
# MikroTik mtxrGaugeName is in column 1 of mtxrGaugeTable
# Value OID: .1.3.6.1.4.1.14988.1.1.3.100.1.3 or .1.3.6.1.4.1.14988.1.1.3.100.1.3.{index}
# Name OID: .1.3.6.1.4.1.14988.1.1.3.100.1.1 or .1.3.6.1.4.1.14988.1.1.3.100.1.1.{index}
String.contains?(descr, "mtxrGaugeName") ->
String.replace(value_oid, ~r/\.100\.1\.\d+($|\.)/, ".100.1.1\\1")
# MikroTik mtxrOpticalName is in column 2 of mtxrOpticalTable
# Value OID: .1.3.6.1.4.1.14988.1.1.19.1.1.{column} or .1.3.6.1.4.1.14988.1.1.19.1.1.{column}.{index}
# Name OID: .1.3.6.1.4.1.14988.1.1.19.1.1.2 or .1.3.6.1.4.1.14988.1.1.19.1.1.2.{index}
String.contains?(descr, "mtxrOpticalName") ->
String.replace(value_oid, ~r/\.19\.1\.1\.\d+($|\.)/, ".19.1.1.2\\1")
# MikroTik mtxrPOEName is in column 1 of mtxrPOETable
# Value OID: .1.3.6.1.4.1.14988.1.1.3.15.1.1.{column} or .1.3.6.1.4.1.14988.1.1.3.15.1.1.{column}.{index}
# Name OID: .1.3.6.1.4.1.14988.1.1.3.15.1.1.1 or .1.3.6.1.4.1.14988.1.1.3.15.1.1.1.{index}
String.contains?(descr, "mtxrPOEName") ->
String.replace(value_oid, ~r/\.15\.1\.1\.\d+($|\.)/, ".15.1.1.1\\1")
true ->
nil
end
# If we have a vendor-specific mapping, use it
# Otherwise try generic approach
vendor_specific_oid || derive_generic_descr_oid(value_oid)
end
# Generic SNMP table description OID derivation
# Attempts to find the description by trying common column positions
defp derive_generic_descr_oid(value_oid) do
# Parse OID to find table structure
# Format: .base.table.entry.column.index
# Example: .1.3.6.1.4.1.49136.100.1.3.5 -> try .1.3.6.1.4.1.49136.100.1.1.5
parts = value_oid |> String.split(".") |> Enum.reverse()
# Pattern match on list to get index and column
case parts do
[index, column | _rest]
when is_binary(column) and is_binary(index) and column != "" and index != "" ->
# Replace the column number with 1, but only the occurrence before the index
# This avoids replacing earlier occurrences (e.g., in 1.3.6.1)
String.replace(
value_oid,
~r/\.#{Regex.escape(column)}\.(#{Regex.escape(index)})$/,
".1.\\1"
)
_ ->
# Fallback: try replacing last number before index with 1
String.replace(value_oid, ~r/\.\d+\.(\d+)$/, ".1.\\1")
end
end
# Remove template variables from OID
# Examples:
# ".1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}" -> ".1.3.6.1.4.1.17713.21.2.1.36"
# "sysUptime.{{ $index }}" -> "sysUptime"
defp clean_oid_template(oid) do
oid
|> String.replace(~r/\.?\{\{[^}]+\}\}/, "")
|> String.trim_trailing(".")
end
defp build_sensor_data(sensor_def, %{oid: oid, value: value}, descr_map) do
# Build sensor index from OID (extract last component)
oid_index = extract_index_from_oid(oid, sensor_def.index)
# Extract the numeric index for description lookup
numeric_index = oid |> String.split(".") |> List.last()
# Get actual description from map if available, otherwise use configured descr
actual_descr =
case Map.get(descr_map, numeric_index) do
nil ->
sensor_def.descr || "#{sensor_def.sensor_class} #{oid_index}"
resolved_name ->
# Check if descr is a template (contains {{ }})
if String.contains?(sensor_def.descr, "{{") do
# Replace template variable with resolved name
# E.g., "{{ MIKROTIK-MIB::mtxrOpticalName }} Tx Bias" -> "sfp-sfpplus1 Tx Bias"
String.replace(sensor_def.descr, ~r/\{\{[^}]+\}\}/, resolved_name)
else
# For simple symbolic names, use the resolved name directly
resolved_name
end
end
# Make index unique by combining sensor class with OID index
# This prevents conflicts when multiple sensors have the same OID index (e.g., ".0")
sensor_index = build_unique_sensor_index(sensor_def.sensor_class, actual_descr, oid_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: actual_descr,
sensor_unit: determine_unit(sensor_def.sensor_class),
sensor_divisor: sensor_def.divisor || 1,
last_value: last_value,
status: "ok"
}
end
defp build_processor_sensor_data(proc_def, %{oid: oid, value: value}) do
# Build sensor index from OID (extract last component)
sensor_index = extract_index_from_oid(oid, proc_def.index)
# Apply precision (divisor) to value if it's numeric
last_value = apply_processor_precision(value, proc_def.precision)
# Determine status based on CPU load percentage
status = determine_processor_status(last_value)
%{
sensor_type: "percent",
sensor_index: "cpu#{sensor_index}",
sensor_oid: oid,
sensor_descr: proc_def.descr || "CPU #{sensor_index}",
sensor_unit: "%",
sensor_divisor: proc_def.precision || 1,
last_value: last_value,
status: status
}
end
defp apply_processor_precision(value, precision) do
cond do
is_number(value) && precision && precision > 1 ->
value / precision
is_number(value) ->
value * 1.0
true ->
nil
end
end
defp determine_processor_status(last_value) do
cond do
is_nil(last_value) -> "unknown"
last_value < 70 -> "ok"
last_value < 90 -> "warning"
true -> "critical"
end
end
defp extract_index_from_oid(oid, configured_index) when is_binary(configured_index) do
# Extract last component of OID
oid_index =
oid
|> String.split(".")
|> List.last()
|> to_string()
cond do
configured_index == "" ->
# No template, just use OID index
oid_index
String.contains?(configured_index, "{{") ->
# Replace template variable with actual OID index
# E.g., "mtxrGaugeCurrent.{{ $index }}" -> "mtxrGaugeCurrent.17"
String.replace(configured_index, ~r/\{\{\s*\$index\s*\}\}/, oid_index)
true ->
# Use pre-configured static index
configured_index
end
end
defp extract_index_from_oid(oid, _) do
# Extract last component of OID as index
oid
|> String.split(".")
|> List.last()
|> to_string()
end
defp build_unique_sensor_index(sensor_class, descr, oid_index) do
cond do
# If oid_index already has a dot (e.g., "mtxrGaugeCurrent.17"), use it as-is
String.contains?(oid_index, ".") ->
oid_index
# For scalar sensors (index 0), use a descriptive name from the description
oid_index == "0" && descr ->
descr
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")
# For simple numeric indices, prepend sensor class
true ->
"#{sensor_class}_#{oid_index}"
end
end
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

@ -1,75 +0,0 @@
defmodule Towerops.Workers.ProfileImportWorker do
@moduledoc """
Background worker for importing device SNMP profiles.
Enqueued when:
- Superuser uploads profiles via API endpoint POST /api/v1/admin/profiles/import
Queue: maintenance
"""
alias Towerops.DeviceProfiles
alias Towerops.DeviceProfiles.Importer
require Logger
@doc """
Imports device profiles from JSON data.
## Parameters
- profiles: List of profile maps with detection and discovery data
- job_id: Unique identifier for tracking the import job
## Returns
- :ok on completion (logs individual profile successes/failures)
"""
def perform(profiles, job_id) when is_list(profiles) do
Logger.info("Starting profile import job #{job_id} with #{length(profiles)} profiles")
results =
Enum.map(profiles, fn profile_data ->
os_name = profile_data["os"]
case import_profile(profile_data) do
{:ok, profile} ->
Logger.info("[#{job_id}] Successfully imported profile: #{profile.os}")
{:ok, profile}
{:error, reason} ->
Logger.error("[#{job_id}] Failed to import profile #{os_name}: #{inspect(reason)}")
{:error, os_name, reason}
end
end)
{successes, failures} = Enum.split_with(results, &match?({:ok, _}, &1))
Logger.info("[#{job_id}] Import complete: #{length(successes)} succeeded, #{length(failures)} failed")
:ok
end
# Private functions
defp import_profile(%{"os" => os_name, "detection" => detection_data} = data) do
# Delete existing profile first (force replace)
if profile = DeviceProfiles.get_profile(os_name) do
DeviceProfiles.delete_profile(profile)
end
# Import directly from data structures
case Importer.import_profile_from_data(
detection_data,
data["discovery"]
) do
{:ok, profile} ->
{:ok, profile}
{:error, reason} ->
{:error, reason}
end
end
defp import_profile(_invalid_data) do
{:error, "Missing required fields: os and detection"}
end
end

View file

@ -0,0 +1,265 @@
defmodule ToweropsWeb.Api.V1.MibController do
@moduledoc """
API controller for managing SNMP MIB files.
Allows uploading MIB files to the persistent storage for use with MIB name translation.
All endpoints require superuser API token authentication.
"""
use ToweropsWeb, :controller
require Logger
@mib_dir Application.compile_env(:towerops, :mib_dir, "/app/mibs")
@doc """
Upload a MIB file or archive (.tar.gz, .zip) containing MIB files.
Requires superuser API token.
## Request
POST /api/v1/mibs
Content-Type: multipart/form-data
file: <mib_file>
vendor: <vendor_name> (optional)
## Response
201 Created
{
"status": "ok",
"message": "Successfully uploaded MIB file",
"vendor": "mikrotik",
"files_count": 15
}
"""
def upload(conn, params) do
with :ok <- require_superuser(conn) do
case params["file"] do
%Plug.Upload{} = upload ->
vendor = params["vendor"] || "custom"
handle_upload(conn, upload, vendor)
_ ->
conn
|> put_status(:bad_request)
|> json(%{error: "Missing file parameter"})
end
end
end
@doc """
List all available MIB vendors.
Requires superuser API token.
## Response
200 OK
{
"vendors": ["mikrotik", "cisco", "ubiquiti", ...],
"total_files": 1234
}
"""
def index(conn, _params) do
with :ok <- require_superuser(conn) do
case list_mib_vendors() do
{:ok, vendors, total_files} ->
json(conn, %{
vendors: vendors,
total_files: total_files
})
{:error, reason} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: inspect(reason)})
end
end
end
@doc """
Delete MIB files for a specific vendor.
Requires superuser API token.
## Request
DELETE /api/v1/mibs/:vendor
## Response
200 OK
{
"status": "ok",
"message": "Deleted MIB files for vendor: mikrotik"
}
"""
def delete(conn, %{"vendor" => vendor}) do
with :ok <- require_superuser(conn) do
vendor_dir = Path.join(@mib_dir, vendor)
if File.exists?(vendor_dir) do
case File.rm_rf(vendor_dir) do
{:ok, _files} ->
Logger.info("Deleted MIB files for vendor: #{vendor}")
json(conn, %{
status: "ok",
message: "Deleted MIB files for vendor: #{vendor}"
})
{:error, reason, file} ->
Logger.error("Failed to delete MIB files for #{vendor}: #{file} - #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to delete MIB files"})
end
else
conn
|> put_status(:not_found)
|> json(%{error: "Vendor not found: #{vendor}"})
end
end
end
# Private functions
defp require_superuser(conn) do
user = conn.assigns[:current_user]
if !user || !user.is_superuser do
Logger.warning("MIB management attempted by non-superuser: #{inspect(user && user.email)}")
conn
|> put_status(:forbidden)
|> json(%{error: "Superuser access required. Only API tokens created by superusers can manage MIB files."})
|> halt()
else
:ok
end
end
defp handle_upload(conn, upload, vendor) do
vendor_dir = Path.join(@mib_dir, vendor)
File.mkdir_p!(vendor_dir)
cond do
String.ends_with?(upload.filename, ".tar.gz") or String.ends_with?(upload.filename, ".tgz") ->
extract_tarball(conn, upload, vendor_dir, vendor)
String.ends_with?(upload.filename, ".zip") ->
extract_zip(conn, upload, vendor_dir, vendor)
true ->
copy_single_file(conn, upload, vendor_dir, vendor)
end
end
defp extract_tarball(conn, upload, vendor_dir, vendor) do
case System.cmd("tar", ["-xzf", upload.path, "-C", vendor_dir]) do
{_output, 0} ->
files_count = count_files(vendor_dir)
Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}")
conn
|> put_status(:created)
|> json(%{
status: "ok",
message: "Successfully extracted MIB archive",
vendor: vendor,
files_count: files_count
})
{error, exit_code} ->
Logger.error("Failed to extract tarball: #{error}")
conn
|> put_status(:bad_request)
|> json(%{error: "Failed to extract archive (exit code: #{exit_code})"})
end
end
defp extract_zip(conn, upload, vendor_dir, vendor) do
case System.cmd("unzip", ["-o", upload.path, "-d", vendor_dir]) do
{_output, 0} ->
files_count = count_files(vendor_dir)
Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}")
conn
|> put_status(:created)
|> json(%{
status: "ok",
message: "Successfully extracted MIB archive",
vendor: vendor,
files_count: files_count
})
{error, exit_code} ->
Logger.error("Failed to extract zip: #{error}")
conn
|> put_status(:bad_request)
|> json(%{error: "Failed to extract archive (exit code: #{exit_code})"})
end
end
defp copy_single_file(conn, upload, vendor_dir, vendor) do
target_path = Path.join(vendor_dir, upload.filename)
case File.cp(upload.path, target_path) do
:ok ->
Logger.info("Uploaded MIB file: #{upload.filename} for vendor: #{vendor}")
conn
|> put_status(:created)
|> json(%{
status: "ok",
message: "Successfully uploaded MIB file",
vendor: vendor,
filename: upload.filename
})
{:error, reason} ->
Logger.error("Failed to copy MIB file: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to upload file"})
end
end
defp list_mib_vendors do
if File.exists?(@mib_dir) do
vendors =
@mib_dir
|> File.ls!()
|> Enum.filter(fn name ->
path = Path.join(@mib_dir, name)
File.dir?(path) and name != "lost+found"
end)
|> Enum.sort()
total_files = count_files(@mib_dir)
{:ok, vendors, total_files}
else
{:ok, [], 0}
end
rescue
e ->
Logger.error("Failed to list MIB vendors: #{inspect(e)}")
{:error, e}
end
defp count_files(dir) do
dir
|> Path.join("**/*")
|> Path.wildcard()
|> Enum.count(&File.regular?/1)
end
end

View file

@ -1,94 +0,0 @@
defmodule ToweropsWeb.Api.V1.ProfilesController do
@moduledoc """
API controller for managing device SNMP profiles.
All endpoints require API token authentication.
"""
use ToweropsWeb, :controller
require Logger
@doc """
POST /api/v1/profiles/import
Imports device profiles from JSON data exported via `mix export_profiles`.
**Requires superuser API token** - Only API tokens created by superuser accounts can import profiles.
Request body:
[
{
"os": "epmp",
"detection": { ... }, # YAML detection data
"discovery": { ... } # YAML discovery data (optional)
},
...
]
Response:
{
"status": "queued",
"job_id": "uuid",
"profile_count": 15,
"message": "Import job queued successfully..."
}
Example:
curl -X POST https://towerops.net/api/v1/profiles/import \\
-H "Authorization: Bearer YOUR_SUPERUSER_TOKEN" \\
-H "Content-Type: application/json" \\
-d @profiles.json
"""
def import_bulk(conn, %{"_json" => profiles}) when is_list(profiles) do
# Check if user is a superuser
user = conn.assigns[:current_user]
if !user || !user.is_superuser do
Logger.warning("Profile import attempted by non-superuser: #{inspect(user && user.email)}")
conn
|> put_status(:forbidden)
|> json(%{error: "Superuser access required. Only API tokens created by superusers can import profiles."})
else
Logger.info("Queueing import for #{length(profiles)} device profiles via API (user: #{user.email})")
do_import_bulk(conn, profiles)
end
end
def import_bulk(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid request format. Expected array of profile objects."})
end
# Private functions
defp do_import_bulk(conn, profiles) do
# Generate unique job ID for tracking
job_id = Ecto.UUID.generate()
# Enqueue background job
case Exq.enqueue(Exq, "maintenance", Towerops.Workers.ProfileImportWorker, [
profiles,
job_id
]) do
{:ok, _job_jid} ->
Logger.info("Profile import job #{job_id} queued successfully")
json(conn, %{
status: "queued",
job_id: job_id,
profile_count: length(profiles),
message: "Import job queued successfully. #{length(profiles)} profiles will be imported in the background."
})
{:error, reason} ->
Logger.error("Failed to queue profile import: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to queue import job", reason: inspect(reason)})
end
end
end

View file

@ -2,6 +2,7 @@ defmodule ToweropsWeb.Router do
@moduledoc false @moduledoc false
use ToweropsWeb, :router use ToweropsWeb, :router
use Honeybadger.Plug
import Phoenix.LiveDashboard.Router import Phoenix.LiveDashboard.Router
import ToweropsWeb.UserAuth import ToweropsWeb.UserAuth
@ -79,8 +80,10 @@ defmodule ToweropsWeb.Router do
resources "/sites", SitesController, except: [:new, :edit] resources "/sites", SitesController, except: [:new, :edit]
resources "/devices", DevicesController, except: [:new, :edit] resources "/devices", DevicesController, except: [:new, :edit]
# Profile management (requires superuser API token) # MIB file management (requires superuser API token)
post "/profiles/import", ProfilesController, :import_bulk get "/mibs", MibController, :index
post "/mibs", MibController, :upload
delete "/mibs/:vendor", MibController, :delete
end end
# WebAuthn API routes # WebAuthn API routes

View file

@ -75,6 +75,7 @@ defmodule Towerops.MixProject do
{:ecto_psql_extras, "~> 0.6"}, {:ecto_psql_extras, "~> 0.6"},
{:exq, "~> 0.19"}, {:exq, "~> 0.19"},
{:mox, "~> 1.0", only: :test}, {:mox, "~> 1.0", only: :test},
{:honeybadger, "~> 0.24"},
{:stream_data, "~> 1.1", only: :test}, {:stream_data, "~> 1.1", only: :test},
{:styler, "~> 1.10", only: [:dev, :test], runtime: false}, {:styler, "~> 1.10", only: [:dev, :test], runtime: false},
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},

View file

@ -26,6 +26,7 @@
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"honeybadger": {:hex, :honeybadger, "0.24.1", "13ffe56b4d148649c8fbb0e091fefecc5d8e8eb7ade684b6900085a947d741d5", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:process_tree, "~> 0.2.1", [hex: :process_tree, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c97d5a82c42298b9935dbc0a7e3c14372c8f55257f603828258ef9f7e0da892"},
"horde": {:hex, :horde, "0.10.0", "31c6a633057c3ec4e73064d7b11ba409c9f3c518aa185377d76bee441b76ceb0", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "0b51c435cb698cac9bf9c17391dce3ebb1376ae6154c81f077fc61db771b9432"}, "horde": {:hex, :horde, "0.10.0", "31c6a633057c3ec4e73064d7b11ba409c9f3c518aa185377d76bee441b76ceb0", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "0b51c435cb698cac9bf9c17391dce3ebb1376ae6154c81f077fc61db771b9432"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
@ -55,6 +56,7 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"process_tree": {:hex, :process_tree, "0.2.1", "4ebcaa96c64a7833467909f49fee28a8e62eed04975613f4c81b4b99424f7e8a", [:mix], [], "hexpm", "68eee6bf0514351aeeda7037f1a6003c0e25de48fe6b7d15a1b0aebb4b35e713"},
"protobuf": {:hex, :protobuf, "0.16.0", "d1878725105d49162977cf3408ccc3eac4f3532e26e5a9e250f2c624175d10f6", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "f0d0d3edd8768130f24cc2cfc41320637d32c80110e80d13f160fa699102c828"}, "protobuf": {:hex, :protobuf, "0.16.0", "d1878725105d49162977cf3408ccc3eac4f3532e26e5a9e250f2c624175d10f6", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "f0d0d3edd8768130f24cc2cfc41320637d32c80110e80d13f160fa699102c828"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"}, "redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"},

View file

@ -0,0 +1,170 @@
defmodule Towerops.Repo.Migrations.DropDeviceProfilesTables do
use Ecto.Migration
def up do
# Drop child tables first (due to foreign key constraints)
drop table(:profile_sensor_states)
drop table(:profile_sensor_definitions)
drop table(:profile_detection_rules)
drop table(:profile_processor_definitions)
drop table(:profile_mempool_definitions)
drop table(:profile_os_definitions)
# Drop parent table last
drop table(:device_profiles)
end
def down do
# Recreate parent table first
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])
# Recreate child tables
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])
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])
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])
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])
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])
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