add honeybadger and snmp overhaul
This commit is contained in:
parent
adec4b134f
commit
1f123bbeb9
24 changed files with 600 additions and 2511 deletions
|
|
@ -17,6 +17,12 @@ config :esbuild,
|
|||
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
|
||||
config :logger, :default_formatter,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
|
|
|
|||
121
lib/mix/tasks/copy_mibs.ex
Normal file
121
lib/mix/tasks/copy_mibs.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -239,6 +239,10 @@ defmodule Towerops.Devices do
|
|||
Deletes device.
|
||||
"""
|
||||
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)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ defmodule Towerops.Snmp.Discovery do
|
|||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.DeviceProfiles
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Devices.Device, as: DeviceSchema
|
||||
alias Towerops.Repo
|
||||
|
|
@ -24,7 +23,6 @@ defmodule Towerops.Snmp.Discovery do
|
|||
alias Towerops.Snmp.NeighborDiscovery
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
alias Towerops.Snmp.Profiles.Cisco
|
||||
alias Towerops.Snmp.Profiles.Dynamic
|
||||
alias Towerops.Snmp.Profiles.Mikrotik
|
||||
alias Towerops.Snmp.Profiles.NetSnmp
|
||||
alias Towerops.Snmp.Profiles.Ubiquiti
|
||||
|
|
@ -76,7 +74,7 @@ defmodule Towerops.Snmp.Discovery do
|
|||
optional(atom()) => term()
|
||||
}
|
||||
|
||||
@type profile :: module() | {:dynamic, DeviceProfiles.DeviceProfile.t()}
|
||||
@type profile :: module()
|
||||
|
||||
@type discovery_summary :: %{
|
||||
success: non_neg_integer(),
|
||||
|
|
@ -209,19 +207,6 @@ defmodule Towerops.Snmp.Discovery do
|
|||
"""
|
||||
@spec select_profile(system_info()) :: profile()
|
||||
def select_profile(system_info) do
|
||||
# First try to match against database profiles
|
||||
case DeviceProfiles.match_profile(system_info) do
|
||||
%DeviceProfiles.DeviceProfile{} = profile ->
|
||||
Logger.debug("Selected database profile: #{profile.os}")
|
||||
{:dynamic, profile}
|
||||
|
||||
nil ->
|
||||
# Fall back to hard-coded profile selection
|
||||
select_hardcoded_profile(system_info)
|
||||
end
|
||||
end
|
||||
|
||||
defp select_hardcoded_profile(system_info) do
|
||||
sys_descr = Map.get(system_info, :sys_descr, "")
|
||||
sys_object_id = Map.get(system_info, :sys_object_id, "")
|
||||
|
||||
|
|
@ -252,16 +237,6 @@ defmodule Towerops.Snmp.Discovery do
|
|||
|
||||
@spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
|
||||
{:ok, device_info()}
|
||||
defp build_device_info(client_opts, system_info, {: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
|
||||
# Let the hard-coded profile identify the device
|
||||
identified_info = profile.identify_device(system_info)
|
||||
|
|
@ -274,18 +249,6 @@ defmodule Towerops.Snmp.Discovery do
|
|||
|
||||
@spec discover_interfaces(Client.connection_opts(), profile()) ::
|
||||
{: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
|
||||
case profile.discover_interfaces(client_opts) do
|
||||
{:ok, interfaces} ->
|
||||
|
|
@ -299,12 +262,6 @@ defmodule Towerops.Snmp.Discovery do
|
|||
end
|
||||
|
||||
@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
|
||||
case profile.discover_sensors(client_opts) do
|
||||
{:ok, sensors} ->
|
||||
|
|
|
|||
|
|
@ -71,9 +71,14 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
|
||||
@impl true
|
||||
def handle_info(:poll_data, state) do
|
||||
_ = perform_poll(state.device_id)
|
||||
case perform_poll(state.device_id) do
|
||||
:stop ->
|
||||
{:stop, :normal, state}
|
||||
|
||||
:ok ->
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:discovery_completed, _device_id}, state) do
|
||||
|
|
@ -91,18 +96,31 @@ defmodule Towerops.Snmp.PollerWorker do
|
|||
|
||||
@impl true
|
||||
def handle_cast(:poll_now, state) do
|
||||
_ = perform_poll(state.device_id)
|
||||
case perform_poll(state.device_id) do
|
||||
:stop ->
|
||||
{:stop, :normal, state}
|
||||
|
||||
:ok ->
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
# Private Functions
|
||||
|
||||
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
|
||||
|
||||
device ->
|
||||
if device.snmp_enabled do
|
||||
poll_equipment_device(device_id, device)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp poll_equipment_device(device_id, device) do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
265
lib/towerops_web/controllers/api/v1/mib_controller.ex
Normal file
265
lib/towerops_web/controllers/api/v1/mib_controller.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -2,6 +2,7 @@ defmodule ToweropsWeb.Router do
|
|||
@moduledoc false
|
||||
|
||||
use ToweropsWeb, :router
|
||||
use Honeybadger.Plug
|
||||
|
||||
import Phoenix.LiveDashboard.Router
|
||||
import ToweropsWeb.UserAuth
|
||||
|
|
@ -79,8 +80,10 @@ defmodule ToweropsWeb.Router do
|
|||
resources "/sites", SitesController, except: [:new, :edit]
|
||||
resources "/devices", DevicesController, except: [:new, :edit]
|
||||
|
||||
# Profile management (requires superuser API token)
|
||||
post "/profiles/import", ProfilesController, :import_bulk
|
||||
# MIB file management (requires superuser API token)
|
||||
get "/mibs", MibController, :index
|
||||
post "/mibs", MibController, :upload
|
||||
delete "/mibs/:vendor", MibController, :delete
|
||||
end
|
||||
|
||||
# WebAuthn API routes
|
||||
|
|
|
|||
1
mix.exs
1
mix.exs
|
|
@ -75,6 +75,7 @@ defmodule Towerops.MixProject do
|
|||
{:ecto_psql_extras, "~> 0.6"},
|
||||
{:exq, "~> 0.19"},
|
||||
{:mox, "~> 1.0", only: :test},
|
||||
{:honeybadger, "~> 0.24"},
|
||||
{:stream_data, "~> 1.1", only: :test},
|
||||
{:styler, "~> 1.10", only: [:dev, :test], runtime: false},
|
||||
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -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"},
|
||||
"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]},
|
||||
"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"},
|
||||
"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"},
|
||||
|
|
@ -55,6 +56,7 @@
|
|||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue