From 1f123bbeb9217926fb73726e7dd9f12be7698c05 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 18 Jan 2026 16:16:08 -0600 Subject: [PATCH] add honeybadger and snmp overhaul --- config/config.exs | 6 + lib/mix/tasks/copy_mibs.ex | 121 +++ lib/mix/tasks/export_profiles.ex | 179 ----- lib/mix/tasks/import_profiles.ex | 229 ------ lib/towerops/device_profiles.ex | 290 ------- .../device_profiles/detection_rule.ex | 47 -- .../device_profiles/device_profile.ex | 60 -- lib/towerops/device_profiles/importer.ex | 478 ----------- .../device_profiles/mempool_definition.ex | 52 -- lib/towerops/device_profiles/os_definition.ex | 37 - .../device_profiles/processor_definition.ex | 48 -- .../device_profiles/sensor_definition.ex | 76 -- lib/towerops/device_profiles/sensor_state.ex | 35 - lib/towerops/devices.ex | 4 + lib/towerops/snmp/discovery.ex | 45 +- lib/towerops/snmp/poller_worker.ex | 32 +- lib/towerops/snmp/profiles/dynamic.ex | 758 ------------------ lib/towerops/workers/profile_import_worker.ex | 75 -- .../controllers/api/v1/mib_controller.ex | 265 ++++++ .../controllers/api/v1/profiles_controller.ex | 94 --- lib/towerops_web/router.ex | 7 +- mix.exs | 1 + mix.lock | 2 + ...0118220947_drop_device_profiles_tables.exs | 170 ++++ 24 files changed, 600 insertions(+), 2511 deletions(-) create mode 100644 lib/mix/tasks/copy_mibs.ex delete mode 100644 lib/mix/tasks/export_profiles.ex delete mode 100644 lib/mix/tasks/import_profiles.ex delete mode 100644 lib/towerops/device_profiles.ex delete mode 100644 lib/towerops/device_profiles/detection_rule.ex delete mode 100644 lib/towerops/device_profiles/device_profile.ex delete mode 100644 lib/towerops/device_profiles/importer.ex delete mode 100644 lib/towerops/device_profiles/mempool_definition.ex delete mode 100644 lib/towerops/device_profiles/os_definition.ex delete mode 100644 lib/towerops/device_profiles/processor_definition.ex delete mode 100644 lib/towerops/device_profiles/sensor_definition.ex delete mode 100644 lib/towerops/device_profiles/sensor_state.ex delete mode 100644 lib/towerops/snmp/profiles/dynamic.ex delete mode 100644 lib/towerops/workers/profile_import_worker.ex create mode 100644 lib/towerops_web/controllers/api/v1/mib_controller.ex delete mode 100644 lib/towerops_web/controllers/api/v1/profiles_controller.ex create mode 100644 priv/repo/migrations/20260118220947_drop_device_profiles_tables.exs diff --git a/config/config.exs b/config/config.exs index 8be0b847..e6638966 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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", diff --git a/lib/mix/tasks/copy_mibs.ex b/lib/mix/tasks/copy_mibs.ex new file mode 100644 index 00000000..cb82a62b --- /dev/null +++ b/lib/mix/tasks/copy_mibs.ex @@ -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 diff --git a/lib/mix/tasks/export_profiles.ex b/lib/mix/tasks/export_profiles.ex deleted file mode 100644 index 6c8c0409..00000000 --- a/lib/mix/tasks/export_profiles.ex +++ /dev/null @@ -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 diff --git a/lib/mix/tasks/import_profiles.ex b/lib/mix/tasks/import_profiles.ex deleted file mode 100644 index af63969e..00000000 --- a/lib/mix/tasks/import_profiles.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles.ex b/lib/towerops/device_profiles.ex deleted file mode 100644 index 9afe6226..00000000 --- a/lib/towerops/device_profiles.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/detection_rule.ex b/lib/towerops/device_profiles/detection_rule.ex deleted file mode 100644 index 0d5c6765..00000000 --- a/lib/towerops/device_profiles/detection_rule.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/device_profile.ex b/lib/towerops/device_profiles/device_profile.ex deleted file mode 100644 index d96c0275..00000000 --- a/lib/towerops/device_profiles/device_profile.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/importer.ex b/lib/towerops/device_profiles/importer.ex deleted file mode 100644 index c95e3acc..00000000 --- a/lib/towerops/device_profiles/importer.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/mempool_definition.ex b/lib/towerops/device_profiles/mempool_definition.ex deleted file mode 100644 index f8a4da3c..00000000 --- a/lib/towerops/device_profiles/mempool_definition.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/os_definition.ex b/lib/towerops/device_profiles/os_definition.ex deleted file mode 100644 index d22a6005..00000000 --- a/lib/towerops/device_profiles/os_definition.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/processor_definition.ex b/lib/towerops/device_profiles/processor_definition.ex deleted file mode 100644 index 56bdda74..00000000 --- a/lib/towerops/device_profiles/processor_definition.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/sensor_definition.ex b/lib/towerops/device_profiles/sensor_definition.ex deleted file mode 100644 index 7ddb442e..00000000 --- a/lib/towerops/device_profiles/sensor_definition.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/device_profiles/sensor_state.ex b/lib/towerops/device_profiles/sensor_state.ex deleted file mode 100644 index a5f9d168..00000000 --- a/lib/towerops/device_profiles/sensor_state.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index de83ccd4..484f7a3d 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -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 diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 165ae270..1efb9372 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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} -> diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index 5d455863..9879eaeb 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -71,8 +71,13 @@ defmodule Towerops.Snmp.PollerWorker do @impl true def handle_info(:poll_data, state) do - _ = perform_poll(state.device_id) - {:noreply, state} + case perform_poll(state.device_id) do + :stop -> + {:stop, :normal, state} + + :ok -> + {:noreply, state} + end end @impl true @@ -91,17 +96,30 @@ defmodule Towerops.Snmp.PollerWorker do @impl true def handle_cast(:poll_now, state) do - _ = perform_poll(state.device_id) - {:noreply, state} + 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 - if device.snmp_enabled do - poll_equipment_device(device_id, device) + device -> + if device.snmp_enabled do + poll_equipment_device(device_id, device) + end + + :ok end end diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex deleted file mode 100644 index 6ea6b463..00000000 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/workers/profile_import_worker.ex b/lib/towerops/workers/profile_import_worker.ex deleted file mode 100644 index 00cd4f75..00000000 --- a/lib/towerops/workers/profile_import_worker.ex +++ /dev/null @@ -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 diff --git a/lib/towerops_web/controllers/api/v1/mib_controller.ex b/lib/towerops_web/controllers/api/v1/mib_controller.ex new file mode 100644 index 00000000..3288b5a4 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/mib_controller.ex @@ -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: + vendor: (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 diff --git a/lib/towerops_web/controllers/api/v1/profiles_controller.ex b/lib/towerops_web/controllers/api/v1/profiles_controller.ex deleted file mode 100644 index 9132adb8..00000000 --- a/lib/towerops_web/controllers/api/v1/profiles_controller.ex +++ /dev/null @@ -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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 180665b7..dd05206e 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/mix.exs b/mix.exs index e73ed50a..51e164eb 100644 --- a/mix.exs +++ b/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}, diff --git a/mix.lock b/mix.lock index 1f091546..b4ea237b 100644 --- a/mix.lock +++ b/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"}, diff --git a/priv/repo/migrations/20260118220947_drop_device_profiles_tables.exs b/priv/repo/migrations/20260118220947_drop_device_profiles_tables.exs new file mode 100644 index 00000000..d4aa3bda --- /dev/null +++ b/priv/repo/migrations/20260118220947_drop_device_profiles_tables.exs @@ -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