diff --git a/lib/towerops/monitoring/check.ex b/lib/towerops/monitoring/check.ex index 00247a05..e629345b 100644 --- a/lib/towerops/monitoring/check.ex +++ b/lib/towerops/monitoring/check.ex @@ -25,6 +25,6 @@ defmodule Towerops.Monitoring.Check do check |> cast(attrs, [:device_id, :status, :response_time_ms, :checked_at]) |> validate_required([:device_id, :status, :checked_at]) - |> foreign_key_constraint(:device_id) + |> foreign_key_constraint(:device_id, match: :suffix) end end diff --git a/lib/towerops/profiles/yaml_profiles.ex b/lib/towerops/profiles/yaml_profiles.ex index d409da39..ed28e0c8 100644 --- a/lib/towerops/profiles/yaml_profiles.ex +++ b/lib/towerops/profiles/yaml_profiles.ex @@ -441,8 +441,17 @@ defmodule Towerops.Profiles.YamlProfiles do default descr when is_binary(descr) -> + # Remove template variables like {{ MIKROTIK-MIB::mtxrOpticalName }} cleaned = descr |> String.replace(~r/\{\{ [^}]+ \}\}/, "") |> String.trim() - if cleaned == "", do: default, else: cleaned + + # If the cleaned descr is a MIB symbolic name (contains "::"), use the default + # MIB names like "MIKROTIK-MIB::mtxrOpticalName" should be resolved to actual values + # by vendor modules, not used as literal descriptions + cond do + cleaned == "" -> default + String.contains?(cleaned, "::") -> default + true -> cleaned + end _ -> default diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 8f7c01e5..7a30bf56 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -14,7 +14,10 @@ defmodule Towerops.Snmp do alias Towerops.Snmp.Discovery alias Towerops.Snmp.Interface alias Towerops.Snmp.InterfaceStat + alias Towerops.Snmp.IpAddress alias Towerops.Snmp.Neighbor + alias Towerops.Snmp.Processor + alias Towerops.Snmp.ProcessorReading alias Towerops.Snmp.Sensor alias Towerops.Snmp.SensorReading alias Towerops.Snmp.StateSensor @@ -96,7 +99,7 @@ defmodule Towerops.Snmp do def get_device_with_associations(device_id) do Device |> where([d], d.device_id == ^device_id) - |> preload([:interfaces, :sensors, :state_sensors]) + |> preload([:interfaces, :sensors, :state_sensors, :processors]) |> Repo.one() end @@ -539,4 +542,95 @@ defmodule Towerops.Snmp do def get_vlan(vlan_id) do Repo.get(Vlan, vlan_id) end + + # IP Address queries + + @doc """ + Lists all IP addresses for a device (across all its interfaces). + """ + def list_ip_addresses(snmp_device_id) do + IpAddress + |> join(:inner, [ip], i in Interface, on: ip.snmp_interface_id == i.id) + |> where([ip, i], i.snmp_device_id == ^snmp_device_id) + |> preload(:snmp_interface) + |> order_by([ip], [ip.ip_type, ip.ip_address]) + |> Repo.all() + end + + @doc """ + Lists IP addresses for a specific interface. + """ + def list_interface_ip_addresses(interface_id) do + IpAddress + |> where([ip], ip.snmp_interface_id == ^interface_id) + |> order_by([ip], [ip.ip_type, ip.ip_address]) + |> Repo.all() + end + + # Processor queries + + @doc """ + Lists all processors for a device. + """ + def list_processors(snmp_device_id) do + Processor + |> where([p], p.snmp_device_id == ^snmp_device_id) + |> order_by([p], p.processor_index) + |> Repo.all() + end + + @doc """ + Gets a specific processor. + """ + def get_processor(processor_id) do + Repo.get(Processor, processor_id) + end + + @doc """ + Updates a processor. + """ + def update_processor(processor, attrs) do + processor + |> Processor.changeset(attrs) + |> Repo.update() + end + + # Processor reading queries + + @doc """ + Gets recent processor readings for a processor. + + ## Options + + - `:limit` - Maximum number of readings to return (default: 100) + - `:since` - Only return readings after this datetime + """ + def get_processor_readings(processor_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + since = Keyword.get(opts, :since) + + query = + ProcessorReading + |> where([r], r.processor_id == ^processor_id) + |> order_by([r], desc: r.checked_at) + |> limit(^limit) + + query = + if since do + where(query, [r], r.checked_at >= ^since) + else + query + end + + Repo.all(query) + end + + @doc """ + Records a new processor reading. + """ + def create_processor_reading(attrs) do + %ProcessorReading{} + |> ProcessorReading.changeset(attrs) + |> Repo.insert() + end end diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex index f48be436..a4743862 100644 --- a/lib/towerops/snmp/device.ex +++ b/lib/towerops/snmp/device.ex @@ -12,6 +12,7 @@ defmodule Towerops.Snmp.Device do alias Ecto.Association.NotLoaded alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Snmp.Interface + alias Towerops.Snmp.Processor alias Towerops.Snmp.Sensor alias Towerops.Snmp.StateSensor @@ -34,6 +35,7 @@ defmodule Towerops.Snmp.Device do belongs_to :device, DeviceSchema has_many :interfaces, Interface, foreign_key: :snmp_device_id + has_many :processors, Processor, foreign_key: :snmp_device_id has_many :sensors, Sensor, foreign_key: :snmp_device_id has_many :state_sensors, StateSensor, foreign_key: :snmp_device_id @@ -57,6 +59,7 @@ defmodule Towerops.Snmp.Device do device_id: Ecto.UUID.t(), device: NotLoaded.t() | DeviceSchema.t(), interfaces: NotLoaded.t() | [Interface.t()], + processors: NotLoaded.t() | [Processor.t()], sensors: NotLoaded.t() | [Sensor.t()], state_sensors: NotLoaded.t() | [StateSensor.t()], inserted_at: DateTime.t(), diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index c7f8ff22..9f00201a 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -22,7 +22,9 @@ defmodule Towerops.Snmp.Discovery do alias Towerops.Snmp.DeferredDiscovery alias Towerops.Snmp.Device alias Towerops.Snmp.Interface + alias Towerops.Snmp.IpAddress alias Towerops.Snmp.NeighborDiscovery + alias Towerops.Snmp.Processor alias Towerops.Snmp.Profiles.Base alias Towerops.Snmp.Profiles.Dynamic alias Towerops.Snmp.Sensor @@ -127,7 +129,11 @@ defmodule Towerops.Snmp.Discovery do {:ok, interfaces} <- discover_interfaces_with_timeout(client_opts, profile, timeouts), {:ok, sensors} <- discover_sensors_with_timeout(client_opts, profile, timeouts), {:ok, vlans} <- discover_vlans_with_timeout(client_opts, profile, timeouts), + {:ok, ip_addresses} <- discover_ip_addresses_with_timeout(client_opts, timeouts), + {:ok, processors} <- discover_processors_with_timeout(client_opts, timeouts), {:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors, vlans), + :ok <- sync_ip_addresses(discovered_device, ip_addresses), + :ok <- sync_processors(discovered_device, processors), {:ok, neighbors} <- discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts), :ok <- save_neighbors(discovered_device.device_id, neighbors) do _ = update_device_discovery_time(device) @@ -196,6 +202,26 @@ defmodule Towerops.Snmp.Discovery do ) end + # IP address discovery with timeout - falls back to empty list on timeout + defp discover_ip_addresses_with_timeout(client_opts, timeouts) do + DeferredDiscovery.slow_check( + client_opts, + fn -> Base.discover_all_ip_addresses(client_opts) end, + timeout: timeouts[:slow], + default: [] + ) + end + + # Processor discovery with timeout - falls back to empty list on timeout + defp discover_processors_with_timeout(client_opts, timeouts) do + DeferredDiscovery.slow_check( + client_opts, + fn -> Base.discover_processors(client_opts) end, + timeout: timeouts[:slow], + default: [] + ) + end + @doc """ Runs discovery for all SNMP-enabled devices in an organization. Returns a summary of successful and failed discoveries. @@ -614,6 +640,128 @@ defmodule Towerops.Snmp.Discovery do end) end + @spec sync_ip_addresses(Device.t(), [map()]) :: :ok + defp sync_ip_addresses(device, discovered_ip_addresses) do + # Build a map of if_index to interface ID from the device's interfaces + if_index_to_interface = + Map.new(device.interfaces, fn iface -> {iface.if_index, iface.id} end) + + # Get existing IP addresses for all interfaces of this device + interface_ids = Map.values(if_index_to_interface) + + existing_ip_addresses = + from(ip in IpAddress, where: ip.snmp_interface_id in ^interface_ids) + |> Repo.all() + |> Map.new(fn ip -> {{ip.snmp_interface_id, ip.ip_address}, ip} end) + + # Track which IP addresses we've seen + discovered_keys = + discovered_ip_addresses + |> Enum.map(fn ip_data -> + interface_id = Map.get(if_index_to_interface, ip_data.if_index) + {interface_id, ip_data.ip_address} + end) + |> Enum.reject(fn {iface_id, _} -> is_nil(iface_id) end) + |> MapSet.new() + + # Delete IP addresses that no longer exist + existing_keys = MapSet.new(Map.keys(existing_ip_addresses)) + removed_keys = MapSet.difference(existing_keys, discovered_keys) + + if MapSet.size(removed_keys) > 0 do + removed_list = MapSet.to_list(removed_keys) + + Enum.each(removed_list, fn {interface_id, ip_address} -> + Repo.delete_all( + from ip in IpAddress, + where: ip.snmp_interface_id == ^interface_id and ip.ip_address == ^ip_address + ) + end) + + Logger.debug("Deleted #{MapSet.size(removed_keys)} removed IP addresses") + end + + # Upsert each discovered IP address + Enum.each(discovered_ip_addresses, fn ip_data -> + upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses) + end) + + Logger.info("Synced #{length(discovered_ip_addresses)} IP addresses for device") + :ok + end + + defp upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses) do + interface_id = Map.get(if_index_to_interface, ip_data.if_index) + + if interface_id do + ip_attrs = Map.put(ip_data, :snmp_interface_id, interface_id) + do_upsert_ip_address(ip_attrs, existing_ip_addresses, interface_id, ip_data.ip_address) + end + end + + defp do_upsert_ip_address(ip_attrs, existing_ip_addresses, interface_id, ip_address) do + case Map.get(existing_ip_addresses, {interface_id, ip_address}) do + nil -> + # New IP address - insert + %IpAddress{} + |> IpAddress.changeset(ip_attrs) + |> Repo.insert!() + + existing -> + # Existing IP address - update + existing + |> IpAddress.changeset(ip_attrs) + |> Repo.update!() + end + end + + @spec sync_processors(Device.t(), [map()]) :: :ok + defp sync_processors(device, discovered_processors) do + # Get existing processors for this device, indexed by processor_index + existing_processors = + from(p in Processor, where: p.snmp_device_id == ^device.id) + |> Repo.all() + |> Map.new(&{&1.processor_index, &1}) + + # Get the set of discovered processor_indices + discovered_indices = MapSet.new(discovered_processors, & &1.processor_index) + + # Delete processors that no longer exist on the device + existing_indices = MapSet.new(Map.keys(existing_processors)) + removed_indices = MapSet.difference(existing_indices, discovered_indices) + + if MapSet.size(removed_indices) > 0 do + removed_list = MapSet.to_list(removed_indices) + + Repo.delete_all( + from p in Processor, + where: p.snmp_device_id == ^device.id and p.processor_index in ^removed_list + ) + + Logger.debug("Deleted #{MapSet.size(removed_indices)} removed processors") + end + + # Upsert each discovered processor + Enum.each(discovered_processors, fn processor_data -> + case Map.get(existing_processors, processor_data.processor_index) do + nil -> + # New processor - insert + %Processor{} + |> Processor.changeset(Map.put(processor_data, :snmp_device_id, device.id)) + |> Repo.insert!() + + existing -> + # Existing processor - update + existing + |> Processor.changeset(processor_data) + |> Repo.update!() + end + end) + + Logger.info("Synced #{length(discovered_processors)} processors for device") + :ok + end + @spec update_device_discovery_time(DeviceSchema.t()) :: {:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()} defp update_device_discovery_time(device) do diff --git a/lib/towerops/snmp/neighbor_discovery.ex b/lib/towerops/snmp/neighbor_discovery.ex index b68b699a..bd46f725 100644 --- a/lib/towerops/snmp/neighbor_discovery.ex +++ b/lib/towerops/snmp/neighbor_discovery.ex @@ -8,6 +8,7 @@ defmodule Towerops.Snmp.NeighborDiscovery do """ alias Towerops.Snmp.Client + alias Towerops.Snmp.Sanitizer require Logger @@ -125,36 +126,19 @@ defmodule Towerops.Snmp.NeighborDiscovery do protocol: protocol, interface_id: interface.id, device_id: interface.device_id, - remote_chassis_id: sanitize_string_field(neighbor_map[:remote_chassis_id]), - remote_system_name: sanitize_string_field(neighbor_map[:remote_system_name]), - remote_system_description: sanitize_string_field(neighbor_map[:remote_system_description]), - remote_platform: sanitize_string_field(neighbor_map[:remote_platform]), - remote_port_id: sanitize_string_field(neighbor_map[:remote_port_id]), - remote_port_description: sanitize_string_field(neighbor_map[:remote_port_description]), - remote_address: sanitize_string_field(neighbor_map[:remote_address]), + remote_chassis_id: Sanitizer.sanitize_string(neighbor_map[:remote_chassis_id]), + remote_system_name: Sanitizer.sanitize_string(neighbor_map[:remote_system_name]), + remote_system_description: Sanitizer.sanitize_string(neighbor_map[:remote_system_description]), + remote_platform: Sanitizer.sanitize_string(neighbor_map[:remote_platform]), + remote_port_id: Sanitizer.sanitize_string(neighbor_map[:remote_port_id]), + remote_port_description: Sanitizer.sanitize_string(neighbor_map[:remote_port_description]), + remote_address: Sanitizer.sanitize_string(neighbor_map[:remote_address]), remote_capabilities: neighbor_map[:remote_capabilities] || [], last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second) } end end - # Ensure all string fields are actually strings, not raw binaries - defp sanitize_string_field(nil), do: nil - - defp sanitize_string_field(value) when is_binary(value) do - if String.valid?(value) and String.printable?(value) do - String.trim(value) - else - # Convert non-printable binary to hex string - value - |> :binary.bin_to_list() - |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) - |> String.downcase() - end - end - - defp sanitize_string_field(value), do: to_string(value) - # CDP Discovery (Cisco proprietary) @cdp_cache_table_oid "1.3.6.1.4.1.9.9.23.1.2.1.1" diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index 6b2b17f7..c7f9f145 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -166,6 +166,9 @@ defmodule Towerops.Snmp.PollerWorker do end), Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> poll_device_neighbors(device, snmp_device, client_opts) + end), + Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> + poll_device_processors(device, snmp_device, client_opts, now) end) ] @@ -263,6 +266,122 @@ defmodule Towerops.Snmp.PollerWorker do Logger.error("Error polling neighbors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") end + defp poll_device_processors(device, snmp_device, client_opts, now) do + processors = snmp_device.processors || [] + + if processors != [] do + poll_processors(processors, client_opts, now) + Logger.debug("Polled processors for #{device.name}") + + # Broadcast processor update event to device-specific topic + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}", + {:processors_updated, device.id} + ) + end + rescue + error -> + Logger.error("Error polling processors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") + end + + defp poll_processors(processors, client_opts, timestamp) do + # Poll processors in parallel with max_concurrency + processors + |> Task.async_stream( + fn processor -> + result = poll_processor_value(processor, client_opts) + handle_processor_poll_result(processor, result, timestamp) + end, + max_concurrency: 2, + timeout: 40_000, + on_timeout: :kill_task + ) + |> Stream.run() + end + + defp poll_processor_value(processor, client_opts) do + # Different OIDs for different processor types + oid = + case processor.processor_type do + "hr_processor" -> + # HOST-RESOURCES-MIB hrProcessorLoad + "1.3.6.1.2.1.25.3.3.1.2.#{processor.processor_index}" + + "cisco_cpu" -> + # CISCO-PROCESS-MIB cpmCPUTotal5minRev + "1.3.6.1.4.1.9.9.109.1.1.1.1.8.#{processor.processor_index}" + + "ucd_cpu" -> + # UCD-SNMP-MIB - need to calculate from user/system/idle + # For simplicity, use ssCpuUser + ssCpuSystem as approximation + poll_ucd_cpu(processor, client_opts) + + _ -> + {:error, :unknown_processor_type} + end + + case oid do + {:ok, value} -> {:ok, value} + {:error, _} = error -> error + oid_string when is_binary(oid_string) -> poll_snmp_load(client_opts, oid_string) + end + end + + defp poll_snmp_load(client_opts, oid) do + case Client.get(client_opts, oid) do + {:ok, value} when is_integer(value) -> + {:ok, value / 1.0} + + {:ok, _non_integer} -> + {:error, :non_integer} + + {:error, reason} -> + {:error, reason} + end + end + + defp poll_ucd_cpu(_processor, client_opts) do + # UCD-SNMP-MIB: ssCpuUser + ssCpuSystem = CPU usage + user_oid = "1.3.6.1.4.1.2021.11.9.0" + system_oid = "1.3.6.1.4.1.2021.11.10.0" + + with {:ok, user} <- Client.get(client_opts, user_oid), + {:ok, system} <- Client.get(client_opts, system_oid), + true <- is_integer(user) and is_integer(system) do + {:ok, (user + system) / 1.0} + else + _ -> {:error, :failed_to_calculate} + end + end + + defp handle_processor_poll_result(processor, {:ok, load_percent}, timestamp) do + # Create a reading + Snmp.create_processor_reading(%{ + processor_id: processor.id, + load_percent: load_percent, + status: "ok", + checked_at: timestamp + }) + + # Update processor with latest value + Snmp.update_processor(processor, %{ + load_percent: load_percent, + last_checked_at: timestamp + }) + end + + defp handle_processor_poll_result(processor, {:error, reason}, timestamp) do + Logger.debug("Failed to poll processor #{processor.description}: #{inspect(reason)}") + + Snmp.create_processor_reading(%{ + processor_id: processor.id, + load_percent: nil, + status: "error", + checked_at: timestamp + }) + end + defp poll_sensors(sensors, client_opts, timestamp) do # Poll sensors in parallel with max_concurrency to avoid overwhelming the device # Timeout must be longer than SNMP timeout (30s) to allow slow devices @@ -796,24 +915,73 @@ defmodule Towerops.Snmp.PollerWorker do defp format_speed(_), do: "Unknown" defp get_interface_stats(client_opts, if_index) do - oids = [ + # Try High-Capacity 64-bit counters first (ifHCInOctets, ifHCOutOctets) + # These are essential for 10G+ interfaces to avoid counter wrapping + # HC counters are from IF-MIB::ifXTable (RFC 2863) + hc_oids = [ + if_in_octets: "1.3.6.1.2.1.31.1.1.1.6.#{if_index}", + if_out_octets: "1.3.6.1.2.1.31.1.1.1.10.#{if_index}" + ] + + # Standard 32-bit counters (fallback) + std_oids = [ if_in_octets: "1.3.6.1.2.1.2.2.1.10.#{if_index}", - if_out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}", + if_out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}" + ] + + # Error/discard counters (always 32-bit, no HC versions) + error_oids = [ if_in_errors: "1.3.6.1.2.1.2.2.1.14.#{if_index}", if_out_errors: "1.3.6.1.2.1.2.2.1.20.#{if_index}", if_in_discards: "1.3.6.1.2.1.2.2.1.13.#{if_index}", if_out_discards: "1.3.6.1.2.1.2.2.1.19.#{if_index}" ] - results = - Enum.map(oids, fn {key, oid} -> + # Try HC counters first for octet counts + octet_results = fetch_octet_counters(client_opts, hc_oids, std_oids) + + # Fetch error/discard counters + error_results = + Enum.map(error_oids, fn {key, oid} -> case Client.get(client_opts, oid) do {:ok, value} -> {key, decode_snmp_value(value)} _ -> {key, nil} end end) - {:ok, Map.new(results)} + {:ok, Map.new(octet_results ++ error_results)} + end + + # Try HC (64-bit) counters first, fall back to standard 32-bit if not available + defp fetch_octet_counters(client_opts, hc_oids, std_oids) do + hc_results = Enum.map(hc_oids, &try_fetch_hc_counter(client_opts, &1)) + + hc_available = Enum.all?(hc_results, fn {_key, value} -> value != :not_available end) + + if hc_available do + Enum.map(hc_results, fn {key, value} -> {key, value} end) + else + Logger.debug("HC counters not available, using standard 32-bit counters") + Enum.map(std_oids, &fetch_standard_counter(client_opts, &1)) + end + end + + defp try_fetch_hc_counter(client_opts, {key, oid}) do + case Client.get(client_opts, oid) do + {:ok, value} -> + decoded = decode_snmp_value(value) + if decoded == nil, do: {key, :not_available}, else: {key, decoded} + + _ -> + {key, :not_available} + end + end + + defp fetch_standard_counter(client_opts, {key, oid}) do + case Client.get(client_opts, oid) do + {:ok, value} -> {key, decode_snmp_value(value)} + _ -> {key, nil} + end end # Decode SNMP values, handling Counter64 and other binary types diff --git a/lib/towerops/snmp/processor.ex b/lib/towerops/snmp/processor.ex new file mode 100644 index 00000000..34e7586e --- /dev/null +++ b/lib/towerops/snmp/processor.ex @@ -0,0 +1,79 @@ +defmodule Towerops.Snmp.Processor do + @moduledoc """ + SNMP processor schema for tracking CPU/processor information. + + Processors are discovered via: + - HOST-RESOURCES-MIB hrProcessorTable + - CISCO-PROCESS-MIB cpmCPUTotalTable + - UCD-SNMP-MIB laTable (for system load averages) + + Processor types: + - "hr_processor" - HOST-RESOURCES-MIB hrProcessorTable entry + - "cisco_cpu" - Cisco CPU from CISCO-PROCESS-MIB + - "ucd_cpu" - UCD-SNMP-MIB CPU statistics + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Snmp.Device + + @valid_processor_types ~w(hr_processor cisco_cpu ucd_cpu) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_processors" do + field :processor_index, :string + field :description, :string + field :processor_type, :string + field :load_percent, :float + field :last_checked_at, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :snmp_device, Device + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + processor_index: String.t(), + description: String.t() | nil, + processor_type: String.t(), + load_percent: float() | nil, + last_checked_at: DateTime.t() | nil, + metadata: map(), + snmp_device_id: Ecto.UUID.t(), + snmp_device: NotLoaded.t() | Device.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @doc false + def changeset(processor, attrs) do + processor + |> cast(attrs, [ + :snmp_device_id, + :processor_index, + :description, + :processor_type, + :load_percent, + :last_checked_at, + :metadata + ]) + |> validate_required([:snmp_device_id, :processor_index, :processor_type]) + |> validate_inclusion(:processor_type, @valid_processor_types) + |> validate_load_percent() + |> unique_constraint([:snmp_device_id, :processor_index]) + |> foreign_key_constraint(:snmp_device_id, match: :suffix) + end + + defp validate_load_percent(changeset) do + case get_field(changeset, :load_percent) do + nil -> changeset + load when load >= 0 and load <= 100 -> changeset + _ -> add_error(changeset, :load_percent, "must be between 0 and 100") + end + end +end diff --git a/lib/towerops/snmp/processor_reading.ex b/lib/towerops/snmp/processor_reading.ex new file mode 100644 index 00000000..e38b83ae --- /dev/null +++ b/lib/towerops/snmp/processor_reading.ex @@ -0,0 +1,44 @@ +defmodule Towerops.Snmp.ProcessorReading do + @moduledoc """ + Time-series processor reading schema. + + Stores individual processor load readings collected during SNMP polling cycles. + Similar to SensorReading but specifically for processor/CPU load data. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.Snmp.Processor + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_processor_readings" do + field :load_percent, :float + field :status, :string + field :checked_at, :utc_datetime + + belongs_to :processor, Processor + + timestamps(type: :utc_datetime, updated_at: false) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + load_percent: float() | nil, + status: String.t(), + checked_at: DateTime.t(), + processor_id: Ecto.UUID.t(), + processor: Ecto.Association.NotLoaded.t() | Processor.t(), + inserted_at: DateTime.t() + } + + @doc false + def changeset(reading, attrs) do + reading + |> cast(attrs, [:processor_id, :load_percent, :status, :checked_at]) + |> validate_required([:processor_id, :status, :checked_at]) + |> validate_inclusion(:status, ["ok", "warning", "critical", "error"]) + |> foreign_key_constraint(:processor_id) + end +end diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index b7a22042..133bb6a5 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -11,6 +11,7 @@ defmodule Towerops.Snmp.Profiles.Base do alias Towerops.Snmp.Client alias Towerops.Snmp.Discovery + alias Towerops.Snmp.Sanitizer require Logger @@ -37,7 +38,12 @@ defmodule Towerops.Snmp.Profiles.Base do # IF-MIB::ifXTable - Extended interface table (64-bit counters, names) if_name: "1.3.6.1.2.1.31.1.1.1.1", - if_alias: "1.3.6.1.2.1.31.1.1.1.18" + if_high_speed: "1.3.6.1.2.1.31.1.1.1.15", + if_alias: "1.3.6.1.2.1.31.1.1.1.18", + + # IF-MIB::ifXTable - High-Capacity 64-bit counters (for 10G+ interfaces) + if_hc_in_octets: "1.3.6.1.2.1.31.1.1.1.6", + if_hc_out_octets: "1.3.6.1.2.1.31.1.1.1.10" } @entity_sensor_oids %{ @@ -79,11 +85,26 @@ defmodule Towerops.Snmp.Profiles.Base do } @ip_mib_oids %{ - # IP-MIB - IPv4 address discovery + # IP-MIB - IPv4 address discovery (ipAddrTable, deprecated but widely supported) ip_ad_ent_if_index: "1.3.6.1.2.1.4.20.1.2", ip_ad_ent_net_mask: "1.3.6.1.2.1.4.20.1.3" } + @ipv6_mib_oids %{ + # IPV6-MIB - IPv6 address discovery (RFC 2465) + # ipv6AddrTable indexed by: ifIndex.ipv6Address (16 octets) + ipv6_addr_pfx_length: "1.3.6.1.2.1.55.1.8.1.2", + ipv6_addr_type: "1.3.6.1.2.1.55.1.8.1.3" + } + + @ip_address_mib_oids %{ + # IP-MIB ipAddressTable (RFC 4293) - unified IPv4/IPv6 table + # Indexed by: addressType.address (1=ipv4, 2=ipv6) + ip_address_if_index: "1.3.6.1.2.1.4.34.1.3", + ip_address_prefix: "1.3.6.1.2.1.4.34.1.5", + ip_address_type: "1.3.6.1.2.1.4.34.1.4" + } + @host_resources_oids %{ # HOST-RESOURCES-MIB - hrStorageTable for memory and storage hr_storage_type: "1.3.6.1.2.1.25.2.3.1.2", @@ -93,6 +114,31 @@ defmodule Towerops.Snmp.Profiles.Base do hr_storage_used: "1.3.6.1.2.1.25.2.3.1.6" } + @hr_processor_oids %{ + # HOST-RESOURCES-MIB - hrProcessorTable for CPU discovery + # hrProcessorFrwID: 1.3.6.1.2.1.25.3.3.1.1 (reference to hrDeviceEntry) + hr_processor_load: "1.3.6.1.2.1.25.3.3.1.2" + } + + @hr_device_oids %{ + # HOST-RESOURCES-MIB - hrDeviceTable for device descriptions + hr_device_descr: "1.3.6.1.2.1.25.3.2.1.3" + } + + @cisco_cpu_oids %{ + # CISCO-PROCESS-MIB - cpmCPUTotalTable + cpm_cpu_total_5sec: "1.3.6.1.4.1.9.9.109.1.1.1.1.3", + cpm_cpu_total_1min: "1.3.6.1.4.1.9.9.109.1.1.1.1.4", + cpm_cpu_total_5min: "1.3.6.1.4.1.9.9.109.1.1.1.1.5" + } + + @ucd_cpu_oids %{ + # UCD-SNMP-MIB - System CPU statistics + ss_cpu_user: "1.3.6.1.4.1.2021.11.9.0", + ss_cpu_system: "1.3.6.1.4.1.2021.11.10.0", + ss_cpu_idle: "1.3.6.1.4.1.2021.11.11.0" + } + # HOST-RESOURCES-MIB storage type OIDs mapping # Memory types (for discover_memory_pools) @memory_type_oids %{ @@ -440,6 +486,231 @@ defmodule Towerops.Snmp.Profiles.Base do {:ok, ip_addresses} end + @doc """ + Discovers IPv6 addresses from IPV6-MIB or IP-MIB ipAddressTable. + Returns a list of IP address maps with ip_address, if_index, prefix_length, and ip_type. + """ + @spec discover_ipv6_addresses(Client.connection_opts()) :: {:ok, [map()]} + def discover_ipv6_addresses(client_opts) do + # Try modern IP-MIB ipAddressTable first (RFC 4293, supports both IPv4/IPv6) + case discover_from_ip_address_table(client_opts) do + {:ok, [_ | _] = addresses} -> + {:ok, addresses} + + _ -> + # Fall back to IPV6-MIB ipv6AddrTable (RFC 2465) + discover_from_ipv6_addr_table(client_opts) + end + end + + # Discover from RFC 4293 ipAddressTable (unified IPv4/IPv6) + defp discover_from_ip_address_table(client_opts) do + case Client.walk(client_opts, @ip_address_mib_oids.ip_address_if_index) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + do_discover_from_ip_address_table(client_opts, results) + + {:ok, _} -> + Logger.debug("No addresses found in ipAddressTable") + {:ok, []} + + {:error, reason} -> + Logger.debug("ipAddressTable walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_from_ip_address_table(client_opts, if_index_results) do + # Fetch prefix lengths + prefix_map = + case Client.walk(client_opts, @ip_address_mib_oids.ip_address_prefix) do + {:ok, results} when is_map(results) -> results + _ -> %{} + end + + # Build address list - only IPv6 (type 2) + addresses = + if_index_results + |> Enum.map(fn {oid, if_index} -> + {ip_type, ip_address} = parse_ip_address_table_oid(oid) + + if ip_type == "ipv6" do + prefix_length = extract_prefix_from_map(prefix_map, oid) + + %{ + ip_address: ip_address, + if_index: if_index, + subnet_mask: nil, + prefix_length: prefix_length, + ip_type: "ipv6", + last_checked_at: DateTime.utc_now() + } + end + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(addresses)} IPv6 addresses from ipAddressTable") + {:ok, addresses} + end + + # Discover from RFC 2465 IPV6-MIB ipv6AddrTable + defp discover_from_ipv6_addr_table(client_opts) do + case Client.walk(client_opts, @ipv6_mib_oids.ipv6_addr_pfx_length) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + do_discover_from_ipv6_addr_table(results) + + {:ok, _} -> + Logger.debug("No IPv6 addresses found in IPV6-MIB") + {:ok, []} + + {:error, reason} -> + Logger.debug("IPV6-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_from_ipv6_addr_table(prefix_results) do + # ipv6AddrTable is indexed by ifIndex.ipv6Address (16 octets) + addresses = + prefix_results + |> Enum.map(fn {oid, prefix_length} -> + {if_index, ipv6_address} = parse_ipv6_addr_table_oid(oid) + + if ipv6_address do + %{ + ip_address: ipv6_address, + if_index: if_index, + subnet_mask: nil, + prefix_length: prefix_length, + ip_type: "ipv6", + last_checked_at: DateTime.utc_now() + } + end + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(addresses)} IPv6 addresses from IPV6-MIB") + {:ok, addresses} + end + + # Parse ipAddressTable OID to extract address type and address + # OID format: base.addressType.addressLength.address... + # addressType: 1=ipv4, 2=ipv6 + defp parse_ip_address_table_oid(oid) when is_binary(oid) do + parts = String.split(oid, ".") + + # Find where the address starts (after the base OID) + # The format is: base.type.length.addr1.addr2... + case find_address_in_oid(parts) do + {:ipv4, octets} when length(octets) == 4 -> + {"ipv4", Enum.join(octets, ".")} + + {:ipv6, octets} when length(octets) == 16 -> + {"ipv6", format_ipv6_address(octets)} + + _ -> + {nil, nil} + end + end + + defp parse_ip_address_table_oid(_), do: {nil, nil} + + defp find_address_in_oid(parts) do + # Look for type indicator (1 or 2) followed by length + # Pattern: ...1.4.a.b.c.d (IPv4) or ...2.16.a.b.c... (IPv6) + parts + |> Enum.chunk_every(2, 1, :discard) + |> Enum.find_value(fn + ["1", "4" | _] -> + idx = Enum.find_index(parts, &(&1 == "1")) + + if idx do + octets = Enum.slice(parts, idx + 2, 4) + {:ipv4, octets} + end + + ["2", "16" | _] -> + idx = Enum.find_index(parts, &(&1 == "2")) + + if idx do + octets = Enum.slice(parts, idx + 2, 16) + {:ipv6, octets} + end + + _ -> + nil + end) + end + + # Parse ipv6AddrTable OID to extract ifIndex and IPv6 address + # OID format: base.ifIndex.ipv6Addr (16 octets) + defp parse_ipv6_addr_table_oid(oid) when is_binary(oid) do + parts = String.split(oid, ".") + + # Last 17 parts: ifIndex + 16 address octets + if length(parts) >= 17 do + suffix = Enum.take(parts, -17) + [if_index_str | addr_octets] = suffix + + if_index = String.to_integer(if_index_str) + ipv6_address = format_ipv6_address(addr_octets) + + {if_index, ipv6_address} + else + {nil, nil} + end + rescue + _ -> {nil, nil} + end + + defp parse_ipv6_addr_table_oid(_), do: {nil, nil} + + # Format IPv6 address from decimal octets to standard notation + defp format_ipv6_address(octets) when is_list(octets) and length(octets) == 16 do + octets + |> Enum.map(&String.to_integer/1) + |> Enum.chunk_every(2) + |> Enum.map_join(":", fn [hi, lo] -> + (hi * 256 + lo) + |> Integer.to_string(16) + |> String.downcase() + end) + rescue + _ -> nil + end + + defp format_ipv6_address(_), do: nil + + defp extract_prefix_from_map(prefix_map, oid) do + # Try to find matching prefix entry + # The OID suffix should match + Enum.find_value(prefix_map, fn {prefix_oid, value} -> + if String.ends_with?(prefix_oid, String.slice(oid, -20..-1)) do + value + end + end) + end + + @doc """ + Discovers all IP addresses (both IPv4 and IPv6) from SNMP. + Combines results from discover_ip_addresses/1 and discover_ipv6_addresses/1. + """ + @spec discover_all_ip_addresses(Client.connection_opts()) :: {:ok, [map()]} + def discover_all_ip_addresses(client_opts) do + ipv4_result = discover_ip_addresses(client_opts) + ipv6_result = discover_ipv6_addresses(client_opts) + + ipv4_addresses = elem(ipv4_result, 1) + ipv6_addresses = elem(ipv6_result, 1) + + all_addresses = ipv4_addresses ++ ipv6_addresses + + Logger.debug( + "Discovered #{length(all_addresses)} total IP addresses (#{length(ipv4_addresses)} IPv4, #{length(ipv6_addresses)} IPv6)" + ) + + {:ok, all_addresses} + end + @doc """ Discovers memory pools from HOST-RESOURCES-MIB hrStorageTable. Returns a list of memory pool maps (RAM, swap/virtual memory). @@ -535,10 +806,11 @@ defmodule Towerops.Snmp.Profiles.Base do allocation_units = Map.get(alloc_map, index, 1) size_units = Map.get(size_map, index, 0) used_units = Map.get(used_map, index, 0) + raw_description = Map.get(descr_map, index) %{ storage_index: index, - description: Map.get(descr_map, index), + description: Sanitizer.sanitize_string(raw_description), storage_type: storage_type, allocation_units: allocation_units, total_bytes: size_units * allocation_units, @@ -693,6 +965,181 @@ defmodule Towerops.Snmp.Profiles.Base do defp to_string_or_nil(value) when is_binary(value), do: value defp to_string_or_nil(value), do: to_string(value) + @doc """ + Discovers processor/CPU information from multiple MIBs. + + Tries in order: + 1. HOST-RESOURCES-MIB hrProcessorTable (standard, most compatible) + 2. CISCO-PROCESS-MIB cpmCPUTotalTable (Cisco-specific) + 3. UCD-SNMP-MIB CPU statistics (Linux/Unix systems) + + Returns a list of processor maps with processor_index, description, processor_type, and load_percent. + """ + @spec discover_processors(Client.connection_opts()) :: {:ok, [map()]} + def discover_processors(client_opts) do + # Try HOST-RESOURCES-MIB first (most widely supported) + case discover_hr_processors(client_opts) do + {:ok, [_ | _] = processors} -> + {:ok, processors} + + _ -> + # Try Cisco-specific OIDs + case discover_cisco_processors(client_opts) do + {:ok, [_ | _] = processors} -> + {:ok, processors} + + _ -> + # Try UCD-SNMP-MIB + discover_ucd_cpu(client_opts) + end + end + end + + # Discover processors from HOST-RESOURCES-MIB hrProcessorTable + defp discover_hr_processors(client_opts) do + case Client.walk(client_opts, @hr_processor_oids.hr_processor_load) do + {:ok, results} when map_size(results) > 0 -> + do_discover_hr_processors(client_opts, results) + + {:ok, _} -> + Logger.debug("No processors found in HOST-RESOURCES-MIB") + {:ok, []} + + {:error, reason} -> + Logger.debug("hrProcessorTable walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_hr_processors(client_opts, load_results) do + # Get device descriptions for processors + descr_map = walk_hr_device_descr(client_opts) + + processors = + load_results + |> Enum.map(fn {oid, load_value} -> + index = extract_processor_index(oid) + raw_description = Map.get(descr_map, index, "CPU #{index}") + description = Sanitizer.sanitize_string(raw_description) + load_percent = parse_integer(load_value) + + %{ + processor_index: "hr_#{index}", + description: description, + processor_type: "hr_processor", + load_percent: load_percent && load_percent * 1.0, + last_checked_at: DateTime.utc_now() + } + end) + |> Enum.reject(fn p -> is_nil(p.processor_index) end) + + Logger.debug("Discovered #{length(processors)} processors from HOST-RESOURCES-MIB") + {:ok, processors} + end + + defp walk_hr_device_descr(client_opts) do + case Client.walk(client_opts, @hr_device_oids.hr_device_descr) do + {:ok, results} -> + Map.new(results, fn {oid, value} -> + index = extract_processor_index(oid) + {index, extract_string_value(value)} + end) + + _ -> + %{} + end + end + + # Discover processors from CISCO-PROCESS-MIB cpmCPUTotalTable + defp discover_cisco_processors(client_opts) do + case Client.walk(client_opts, @cisco_cpu_oids.cpm_cpu_total_5min) do + {:ok, results} when map_size(results) > 0 -> + do_discover_cisco_processors(results) + + {:ok, _} -> + Logger.debug("No Cisco CPU data found") + {:ok, []} + + {:error, reason} -> + Logger.debug("CISCO-PROCESS-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_cisco_processors(cpu_results) do + processors = + Enum.map(cpu_results, fn {oid, load_value} -> + index = extract_processor_index(oid) + load_percent = parse_integer(load_value) + + %{ + processor_index: "cisco_#{index}", + description: "Cisco CPU #{index}", + processor_type: "cisco_cpu", + load_percent: load_percent && load_percent * 1.0, + last_checked_at: DateTime.utc_now() + } + end) + + Logger.debug("Discovered #{length(processors)} Cisco CPUs") + {:ok, processors} + end + + # Discover CPU stats from UCD-SNMP-MIB + defp discover_ucd_cpu(client_opts) do + # Get user, system, and idle percentages + oids = [@ucd_cpu_oids.ss_cpu_user, @ucd_cpu_oids.ss_cpu_system, @ucd_cpu_oids.ss_cpu_idle] + + case Client.get_multiple(client_opts, oids) do + {:ok, values} when is_list(values) and length(values) == 3 -> + [user, system, _idle] = values + user_pct = parse_integer(user) || 0 + system_pct = parse_integer(system) || 0 + total_load = user_pct + system_pct + + processors = [ + %{ + processor_index: "ucd_0", + description: "System CPU", + processor_type: "ucd_cpu", + load_percent: total_load * 1.0, + last_checked_at: DateTime.utc_now(), + metadata: %{ + user_percent: user_pct, + system_percent: system_pct + } + } + ] + + Logger.debug("Discovered UCD-SNMP CPU with #{total_load}% load") + {:ok, processors} + + {:ok, _} -> + Logger.debug("No UCD-SNMP CPU data found") + {:ok, []} + + {:error, reason} -> + Logger.debug("UCD-SNMP-MIB get failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp extract_processor_index(oid) when is_binary(oid) do + oid + |> String.split(".") + |> List.last() + |> String.to_integer() + rescue + _ -> nil + end + + defp extract_processor_index(_), do: nil + + # Extract string value from SNMP result (could be binary, charlist, or other) + defp extract_string_value(value) when is_binary(value), do: value + defp extract_string_value(value) when is_list(value), do: List.to_string(value) + defp extract_string_value(value), do: to_string(value) + @doc """ Identifies device manufacturer and model from sysDescr and sysObjectID. Can be overridden by vendor-specific profiles. @@ -714,12 +1161,12 @@ defmodule Towerops.Snmp.Profiles.Base do defp parse_system_info(raw_info) do %{ - sys_descr: Map.get(raw_info, :sys_descr), + sys_descr: raw_info |> Map.get(:sys_descr) |> Sanitizer.sanitize_string(), sys_object_id: parse_oid(Map.get(raw_info, :sys_object_id)), - sys_name: Map.get(raw_info, :sys_name), + sys_name: raw_info |> Map.get(:sys_name) |> Sanitizer.sanitize_string(), sys_uptime: parse_integer(Map.get(raw_info, :sys_uptime)), - sys_contact: Map.get(raw_info, :sys_contact), - sys_location: Map.get(raw_info, :sys_location) + sys_contact: raw_info |> Map.get(:sys_contact) |> Sanitizer.sanitize_string(), + sys_location: raw_info |> Map.get(:sys_location) |> Sanitizer.sanitize_string() } end @@ -743,26 +1190,54 @@ defmodule Towerops.Snmp.Profiles.Base do with {:ok, [if_descr, if_type, if_speed, if_phys_addr, if_admin, if_oper]} <- Client.get_multiple(client_opts, required_oids) do - # Try to fetch optional IF-MIB extension fields (ifName, ifAlias) + # Try to fetch optional IF-MIB extension fields (ifName, ifAlias, ifHighSpeed) # These are from RFC 2863 and not all devices support them if_name = fetch_optional_field(client_opts, @interface_oids.if_name <> ".#{index}") if_alias = fetch_optional_field(client_opts, @interface_oids.if_alias <> ".#{index}") + if_high_speed = fetch_optional_field(client_opts, @interface_oids.if_high_speed <> ".#{index}") + + # Use ifHighSpeed (Mbps) for 10G+ interfaces where ifSpeed (bps) maxes out at ~4.3Gbps + # ifHighSpeed is in Mbps, convert to bps for consistency + actual_speed = calculate_interface_speed(if_speed, if_high_speed) {:ok, %{ if_index: index, - if_descr: if_descr, - if_name: if_name, - if_alias: if_alias, + if_descr: Sanitizer.sanitize_string(if_descr), + if_name: Sanitizer.sanitize_string(if_name), + if_alias: Sanitizer.sanitize_string(if_alias), if_type: parse_integer(if_type), - if_speed: parse_integer(if_speed), - if_phys_address: format_mac_address(if_phys_addr), + if_speed: actual_speed, + if_phys_address: Sanitizer.sanitize_mac(if_phys_addr) || format_mac_address(if_phys_addr), if_admin_status: parse_if_status(if_admin), if_oper_status: parse_if_status(if_oper) }} end end + # Calculate actual interface speed, preferring ifHighSpeed for 10G+ interfaces + # ifSpeed maxes out at 4,294,967,295 bps (~4.3 Gbps) due to 32-bit gauge + # ifHighSpeed is in Mbps and supports up to 4.3 Tbps + defp calculate_interface_speed(if_speed, if_high_speed) do + speed_bps = parse_integer(if_speed) + high_speed_mbps = parse_integer(if_high_speed) + + cond do + # If ifHighSpeed is available and indicates 10G+ (or ifSpeed maxed out) + high_speed_mbps != nil and high_speed_mbps >= 10_000 -> + # Convert Mbps to bps + high_speed_mbps * 1_000_000 + + # If ifSpeed is at or near max (4.3 Gbps), use ifHighSpeed if available + speed_bps != nil and speed_bps >= 4_000_000_000 and high_speed_mbps != nil -> + high_speed_mbps * 1_000_000 + + # Otherwise use standard ifSpeed + true -> + speed_bps + end + end + # Fetch an optional SNMP field - returns nil if not supported defp fetch_optional_field(client_opts, oid) do case Client.get(client_opts, oid) do @@ -806,13 +1281,13 @@ defmodule Towerops.Snmp.Profiles.Base do end end - # Get best available description from ENTITY-MIB + # Get best available description from ENTITY-MIB (sanitized) defp get_sensor_description(%{name: name}, _index) when is_binary(name) and byte_size(name) > 0 do - name + Sanitizer.sanitize_string(name) end defp get_sensor_description(%{descr: descr}, _index) when is_binary(descr) and byte_size(descr) > 0 do - descr + Sanitizer.sanitize_string(descr) end defp get_sensor_description(_, index), do: "Sensor #{index}" @@ -1152,7 +1627,25 @@ defmodule Towerops.Snmp.Profiles.Base do "#{a}.#{b}.#{c}.#{d}" end - defp format_ip_address(value) when is_binary(value), do: value + # Handle raw 4-byte binary (IPv4 address bytes) - common from SNMP + defp format_ip_address(<>) do + "#{a}.#{b}.#{c}.#{d}" + end + + # Handle already-formatted string, but sanitize non-printable characters + defp format_ip_address(value) when is_binary(value) do + if String.printable?(value) do + value + else + # Try to convert as raw bytes + bytes = :binary.bin_to_list(value) + + if length(bytes) == 4 and Enum.all?(bytes, &(&1 >= 0 and &1 <= 255)) do + Enum.join(bytes, ".") + end + end + end + defp format_ip_address(_), do: nil defp subnet_mask_to_prefix(nil), do: nil diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex index 67263344..61fdaf06 100644 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -292,6 +292,15 @@ defmodule Towerops.Snmp.Profiles.Dynamic do defp build_sensor_descr(sensor_def, idx) do base_descr = sensor_def[:sensor_descr] || sensor_def[:oid_name] || sensor_def[:sensor_type] + # If the description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName"), + # use the sensor_type as fallback since we can't resolve MIB names to actual values here + base_descr = + if is_binary(base_descr) and String.contains?(base_descr, "::") do + sensor_def[:sensor_type] || "sensor" + else + base_descr + end + # If there's only one instance, don't add index if idx == 1 do String.capitalize(to_string(base_descr)) @@ -363,14 +372,24 @@ defmodule Towerops.Snmp.Profiles.Dynamic do defp discover_vendor_wireless_sensors(_profile, _client_opts), do: [] # Deduplicate sensors by OID - when multiple sensors share the same OID, - # prefer the one with a proper description (not containing "MIB::") + # prefer the one with a proper description (not containing MIB symbolic names) defp deduplicate_by_oid(sensors) do sensors |> Enum.group_by(& &1.sensor_oid) |> Enum.map(fn {_oid, group} -> - # Prefer sensor with proper description (no MIB name) - Enum.find(group, fn s -> not String.contains?(s.sensor_descr, "MIB::") end) || + # Prefer sensor with proper description (no MIB symbolic name like "MIKROTIK-MIB::xxx") + # Check case-insensitively since build_sensor_descr capitalizes only first letter + Enum.find(group, fn s -> not has_mib_symbolic_name?(s.sensor_descr) end) || List.last(group) end) end + + # Check if a description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName") + # These should be filtered out in favor of actual sensor names from the device + defp has_mib_symbolic_name?(descr) when is_binary(descr) do + # MIB symbolic names contain "::" pattern (case-insensitive check) + String.contains?(String.downcase(descr), "::") + end + + defp has_mib_symbolic_name?(_), do: false end diff --git a/lib/towerops/snmp/sanitizer.ex b/lib/towerops/snmp/sanitizer.ex new file mode 100644 index 00000000..82f75be4 --- /dev/null +++ b/lib/towerops/snmp/sanitizer.ex @@ -0,0 +1,183 @@ +defmodule Towerops.Snmp.Sanitizer do + @moduledoc """ + Sanitizes SNMP data to ensure it's safe to store in the database. + + SNMP devices may return: + - Non-UTF8 binary data + - Non-printable characters + - Malformed strings + - Raw binary values (MAC addresses, IP addresses) + + This module provides sanitization functions to convert these to safe, + printable strings suitable for PostgreSQL text/varchar columns. + """ + + @doc """ + Sanitizes a string field from SNMP data. + + - Returns nil for nil input + - Returns printable strings as-is (trimmed) + - Converts non-printable binaries to colon-separated hex format + - Converts other values to strings + + ## Examples + + iex> Sanitizer.sanitize_string(nil) + nil + + iex> Sanitizer.sanitize_string("Hello World") + "Hello World" + + iex> Sanitizer.sanitize_string(<<255, 255, 255, 0>>) + "ff:ff:ff:00" + + """ + @spec sanitize_string(any()) :: String.t() | nil + def sanitize_string(nil), do: nil + + def sanitize_string(value) when is_binary(value) do + if String.valid?(value) and String.printable?(value) do + String.trim(value) + else + # Convert non-printable binary to hex string + value + |> :binary.bin_to_list() + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + end + + def sanitize_string(value) when is_list(value) do + # Erlang charlists from SNMP + value + |> List.to_string() + |> sanitize_string() + rescue + _ -> inspect(value) + end + + def sanitize_string(value), do: to_string(value) + + @doc """ + Sanitizes an IPv4 address from various SNMP formats. + + Handles: + - Tuple format: {a, b, c, d} + - Raw 4-byte binary: <> + - String format: "a.b.c.d" + - Longer binary (takes first 4 bytes as IP) + + Returns dotted-decimal string or nil if invalid. + """ + @spec sanitize_ipv4(any()) :: String.t() | nil + def sanitize_ipv4({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do + "#{a}.#{b}.#{c}.#{d}" + end + + def sanitize_ipv4(<>) do + "#{a}.#{b}.#{c}.#{d}" + end + + def sanitize_ipv4(value) when is_binary(value) do + cond do + # Already formatted string - valid IP + String.printable?(value) and String.match?(value, ~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) -> + value + + # Printable string that doesn't match IP format - invalid + String.printable?(value) -> + nil + + # Raw bytes - try to convert (only for non-printable binary data) + byte_size(value) >= 4 -> + <> = value + "#{a}.#{b}.#{c}.#{d}" + + true -> + nil + end + end + + def sanitize_ipv4(_), do: nil + + @doc """ + Sanitizes an IPv6 address from various SNMP formats. + + Handles: + - Raw 16-byte binary + - String format (already formatted) + - Tuple format (8 16-bit values) + + Returns formatted IPv6 string or nil if invalid. + """ + @spec sanitize_ipv6(any()) :: String.t() | nil + def sanitize_ipv6(value) when is_binary(value) and byte_size(value) == 16 do + # Raw 16-byte IPv6 address + bytes = :binary.bin_to_list(value) + + bytes + |> Enum.chunk_every(2) + |> Enum.map_join(":", fn [high, low] -> + (high * 256 + low) + |> Integer.to_string(16) + |> String.downcase() + end) + end + + def sanitize_ipv6(value) when is_binary(value) do + # Already formatted - validate and return + if String.printable?(value) and String.contains?(value, ":") do + String.trim(value) + end + end + + def sanitize_ipv6(_), do: nil + + @doc """ + Sanitizes a MAC address from various SNMP formats. + + Handles: + - Raw 6-byte binary + - Colon-separated hex string + - Dash-separated hex string + + Returns colon-separated lowercase hex format or nil if invalid. + """ + @spec sanitize_mac(any()) :: String.t() | nil + def sanitize_mac(value) when is_binary(value) and byte_size(value) == 6 do + value + |> :binary.bin_to_list() + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + + def sanitize_mac(value) when is_binary(value) do + # Already formatted - validate pattern + cleaned = String.replace(value, "-", ":") + + if String.match?(cleaned, ~r/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/) do + String.downcase(cleaned) + end + end + + def sanitize_mac(_), do: nil + + @doc """ + Sanitizes all string fields in a map recursively. + + Applies sanitize_string/1 to all string values in the map. + """ + @spec sanitize_map(map()) :: map() + def sanitize_map(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(value) -> + {key, sanitize_string(value)} + + {key, value} when is_map(value) -> + {key, sanitize_map(value)} + + {key, value} -> + {key, value} + end) + end +end diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index d33e1dd9..bc12c1f4 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -342,7 +342,7 @@ defmodule ToweropsWeb.Layouts do <.link navigate={@navigate} class={[ - "inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium", + "inline-flex items-center border-b-2 px-1 pt-1 pb-4 text-sm font-medium", if(@active, do: "border-gray-900 text-gray-900 dark:border-white dark:text-white", else: diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index d540e5aa..6e479bf9 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -113,6 +113,11 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, load_equipment_data(socket, socket.assigns.device.id)} end + @impl true + def handle_info({:processors_updated, _device_id}, socket) do + {:noreply, load_equipment_data(socket, socket.assigns.device.id)} + end + @impl true def handle_info({:monitoring_check_updated, _device_id}, socket) do {:noreply, load_equipment_data(socket, socket.assigns.device.id)} @@ -134,8 +139,10 @@ defmodule ToweropsWeb.DeviceLive.Show do events = Devices.list_devices_events(device_id, 100) neighbors = Snmp.list_neighbors_with_equipment(device_id) vlans = load_vlans(snmp_data.device) + ip_addresses = load_ip_addresses(snmp_data.device) + processors = load_processors(snmp_data.device) latency_chart_data = load_latency_chart_data(device_id) - cpu_chart_data = load_sensor_chart_data(device_id, ["cpu_load"]) + processor_chart_data = load_processor_chart_data(processors) memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"]) storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"]) traffic_chart_data = load_overall_traffic_chart_data(device_id) @@ -176,9 +183,12 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:snmp_sensors, snmp_data.sensors) |> assign(:neighbors, neighbors) |> assign(:vlans, vlans) + |> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4"))) + |> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6"))) + |> assign(:processors, processors) |> assign(:events, events) |> assign(:latency_chart_data, latency_chart_data) - |> assign(:cpu_chart_data, cpu_chart_data) + |> assign(:processor_chart_data, processor_chart_data) |> assign(:memory_chart_data, memory_chart_data) |> assign(:storage_chart_data, storage_chart_data) |> assign(:traffic_chart_data, traffic_chart_data) @@ -349,6 +359,55 @@ defmodule ToweropsWeb.DeviceLive.Show do defp load_vlans(nil), do: [] defp load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id) + defp load_ip_addresses(nil), do: [] + defp load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id) + + defp load_processors(nil), do: [] + defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id) + + defp load_processor_chart_data([]), do: nil + + defp load_processor_chart_data(processors) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + datasets = + processors + |> Enum.map(&processor_to_chart_dataset(&1, twenty_four_hours_ago)) + |> Enum.reject(&Enum.empty?(&1.data)) + + if Enum.empty?(datasets) do + nil + else + Jason.encode!(%{datasets: datasets}) + end + end + + defp processor_to_chart_dataset(processor, since) do + readings = + processor.id + |> Snmp.get_processor_readings(since: since, limit: 1000) + |> Enum.reverse() + + label = + if processor.description do + processor.description + else + "CPU #{processor.processor_index}" + end + + %{ + label: label, + data: Enum.map(readings, &processor_reading_to_data_point/1) + } + end + + defp processor_reading_to_data_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.load_percent, do: Float.round(reading.load_percent, 1)) + } + end + defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do case Snmp.get_device_with_associations(device_id) do nil -> nil diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index e05dcc0e..d5c6b229 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -100,6 +100,22 @@ <% end %> + <%= if length(@ipv4_addresses) > 0 || length(@ipv6_addresses) > 0 do %> + <.link + patch={~p"/devices/#{@device.id}?tab=ip_addresses"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if @active_tab == "ip_addresses" do + "border-blue-500 text-blue-600 dark:text-blue-400" + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" + end + ]} + > + IP Addresses + + <% end %> + <.link patch={~p"/devices/#{@device.id}?tab=logs"} class={[ @@ -304,8 +320,17 @@
- - <%= if @cpu_chart_data do %> + + <%= if @processor_chart_data || (@processors && length(@processors) > 0) do %> + <% processor_count = if @processors, do: length(@processors), else: 0 + + avg_load = + if processor_count > 0 do + loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1) + if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil + else + nil + end %>
<.link navigate={~p"/devices/#{@device.id}/graph/processors"} @@ -313,21 +338,61 @@ >

- Processors + <%= if processor_count > 0 do %> + {processor_count} {if processor_count == 1, + do: "Processor", + else: "Processors"} + <% else %> + Processors + <% end %>

<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
-
-
- + + <%= if @processor_chart_data do %> +
+
+ +
-
+ <% end %> + + <%= if avg_load do %> +
+
+ + Average Load + +
+
+
= 50 && avg_load < 80 && "bg-yellow-500", + avg_load >= 80 && "bg-red-500" + ]} + style={"width: #{min(avg_load, 100)}%"} + > +
+
+ + {Float.round(avg_load, 0)}% + +
+
+
+ <% end %>
<% end %> @@ -787,6 +852,123 @@
<% end %> + <% "ip_addresses" -> %> +
+ + <%= if length(@ipv4_addresses) > 0 do %> +
+
+

+ + IPv4 + + Addresses + + ({length(@ipv4_addresses)}) + +

+
+
+ + + + + + + + + + + <%= for ip <- @ipv4_addresses do %> + + + + + + + <% end %> + +
IP AddressSubnetInterfacePrimary
+ {ip.ip_address} + + <%= if ip.prefix_length do %> + /{ip.prefix_length} + <% else %> + {ip.subnet_mask || "-"} + <% end %> + + {(ip.snmp_interface && + (ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"} + + <%= if ip.is_primary do %> + + Primary + + <% else %> + - + <% end %> +
+
+
+ <% end %> + + <%= if length(@ipv6_addresses) > 0 do %> +
+
+

+ + IPv6 + + Addresses + + ({length(@ipv6_addresses)}) + +

+
+
+ + + + + + + + + + <%= for ip <- @ipv6_addresses do %> + + + + + + <% end %> + +
IP AddressPrefixInterface
+ {ip.ip_address} + + /{ip.prefix_length || "-"} + + {(ip.snmp_interface && + (ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"} +
+
+
+ <% end %> + + <%= if length(@ipv4_addresses) == 0 && length(@ipv6_addresses) == 0 do %> +
+
+ <.icon name="hero-globe-alt" class="mx-auto h-12 w-12 text-gray-400" /> +

+ No IP addresses discovered +

+

+ This device doesn't have any IP addresses discovered, or IP discovery hasn't run yet. +

+
+
+ <% end %> +
<% "logs" -> %>
diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index 2a1cd597..df2f4e66 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -98,7 +98,8 @@ defmodule ToweropsWeb.GraphLive.Show do defp get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"] defp get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"] defp get_sensor_types_for_chart("traffic"), do: nil - defp get_sensor_types_for_chart(_), do: [] + # For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.) + defp get_sensor_types_for_chart(sensor_type), do: [sensor_type] defp get_chart_config("latency"), do: {"ICMP Latency", "ms", true} defp get_chart_config("processors"), do: {"Processor Usage", "%", false} @@ -107,7 +108,18 @@ defmodule ToweropsWeb.GraphLive.Show do defp get_chart_config("temperature"), do: {"Temperature", "°C", true} defp get_chart_config("voltage"), do: {"Voltage", "V", true} defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true} - defp get_chart_config(_), do: {"Sensor Data", "", false} + defp get_chart_config("count"), do: {"Count", "", true} + defp get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true} + defp get_chart_config("connections"), do: {"Connections", "", true} + # Humanize unknown sensor types for title + defp get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true} + + defp humanize_sensor_type(sensor_type) do + sensor_type + |> String.replace("_", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end defp load_sensor_chart_data(device_id, sensor_types, range) do case Snmp.get_device_with_associations(device_id) do diff --git a/priv/repo/migrations/20260122214253_create_snmp_processors.exs b/priv/repo/migrations/20260122214253_create_snmp_processors.exs new file mode 100644 index 00000000..5bffad39 --- /dev/null +++ b/priv/repo/migrations/20260122214253_create_snmp_processors.exs @@ -0,0 +1,24 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpProcessors do + use Ecto.Migration + + def change do + create table(:snmp_processors, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all), + null: false + + add :processor_index, :string, null: false + add :description, :string + add :processor_type, :string, null: false + add :load_percent, :float + add :last_checked_at, :utc_datetime + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:snmp_processors, [:snmp_device_id]) + create unique_index(:snmp_processors, [:snmp_device_id, :processor_index]) + end +end diff --git a/priv/repo/migrations/20260122223758_add_processor_readings.exs b/priv/repo/migrations/20260122223758_add_processor_readings.exs new file mode 100644 index 00000000..dee8e663 --- /dev/null +++ b/priv/repo/migrations/20260122223758_add_processor_readings.exs @@ -0,0 +1,22 @@ +defmodule Towerops.Repo.Migrations.AddProcessorReadings do + use Ecto.Migration + + def change do + create table(:snmp_processor_readings, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :processor_id, references(:snmp_processors, type: :binary_id, on_delete: :delete_all), + null: false + + add :load_percent, :float + add :status, :string, null: false, default: "ok" + add :checked_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:snmp_processor_readings, [:processor_id]) + create index(:snmp_processor_readings, [:checked_at]) + create index(:snmp_processor_readings, [:processor_id, :checked_at]) + end +end diff --git a/priv/static/android-icon-144x144.png b/priv/static/android-icon-144x144.png new file mode 100644 index 00000000..8f39b461 Binary files /dev/null and b/priv/static/android-icon-144x144.png differ diff --git a/priv/static/android-icon-192x192.png b/priv/static/android-icon-192x192.png new file mode 100644 index 00000000..12fff576 Binary files /dev/null and b/priv/static/android-icon-192x192.png differ diff --git a/priv/static/android-icon-36x36.png b/priv/static/android-icon-36x36.png new file mode 100644 index 00000000..6de73020 Binary files /dev/null and b/priv/static/android-icon-36x36.png differ diff --git a/priv/static/android-icon-48x48.png b/priv/static/android-icon-48x48.png new file mode 100644 index 00000000..c4f4cae0 Binary files /dev/null and b/priv/static/android-icon-48x48.png differ diff --git a/priv/static/android-icon-72x72.png b/priv/static/android-icon-72x72.png new file mode 100644 index 00000000..2b04a2df Binary files /dev/null and b/priv/static/android-icon-72x72.png differ diff --git a/priv/static/android-icon-96x96.png b/priv/static/android-icon-96x96.png new file mode 100644 index 00000000..9afd32e9 Binary files /dev/null and b/priv/static/android-icon-96x96.png differ diff --git a/priv/static/apple-icon-114x114.png b/priv/static/apple-icon-114x114.png new file mode 100644 index 00000000..4e638b84 Binary files /dev/null and b/priv/static/apple-icon-114x114.png differ diff --git a/priv/static/apple-icon-120x120.png b/priv/static/apple-icon-120x120.png new file mode 100644 index 00000000..505f41a9 Binary files /dev/null and b/priv/static/apple-icon-120x120.png differ diff --git a/priv/static/apple-icon-144x144.png b/priv/static/apple-icon-144x144.png new file mode 100644 index 00000000..8f39b461 Binary files /dev/null and b/priv/static/apple-icon-144x144.png differ diff --git a/priv/static/apple-icon-152x152.png b/priv/static/apple-icon-152x152.png new file mode 100644 index 00000000..35844d8b Binary files /dev/null and b/priv/static/apple-icon-152x152.png differ diff --git a/priv/static/apple-icon-180x180.png b/priv/static/apple-icon-180x180.png new file mode 100644 index 00000000..8b3b1ac6 Binary files /dev/null and b/priv/static/apple-icon-180x180.png differ diff --git a/priv/static/apple-icon-57x57.png b/priv/static/apple-icon-57x57.png new file mode 100644 index 00000000..5e08c630 Binary files /dev/null and b/priv/static/apple-icon-57x57.png differ diff --git a/priv/static/apple-icon-60x60.png b/priv/static/apple-icon-60x60.png new file mode 100644 index 00000000..1d0fe038 Binary files /dev/null and b/priv/static/apple-icon-60x60.png differ diff --git a/priv/static/apple-icon-72x72.png b/priv/static/apple-icon-72x72.png new file mode 100644 index 00000000..2b04a2df Binary files /dev/null and b/priv/static/apple-icon-72x72.png differ diff --git a/priv/static/apple-icon-76x76.png b/priv/static/apple-icon-76x76.png new file mode 100644 index 00000000..d9205d1d Binary files /dev/null and b/priv/static/apple-icon-76x76.png differ diff --git a/priv/static/apple-icon-precomposed.png b/priv/static/apple-icon-precomposed.png new file mode 100644 index 00000000..df396cfa Binary files /dev/null and b/priv/static/apple-icon-precomposed.png differ diff --git a/priv/static/apple-icon.png b/priv/static/apple-icon.png new file mode 100644 index 00000000..df396cfa Binary files /dev/null and b/priv/static/apple-icon.png differ diff --git a/priv/static/favicon-16x16.png b/priv/static/favicon-16x16.png new file mode 100644 index 00000000..c2fd1e58 Binary files /dev/null and b/priv/static/favicon-16x16.png differ diff --git a/priv/static/favicon-32x32.png b/priv/static/favicon-32x32.png new file mode 100644 index 00000000..5be4d7c4 Binary files /dev/null and b/priv/static/favicon-32x32.png differ diff --git a/priv/static/favicon-96x96.png b/priv/static/favicon-96x96.png new file mode 100644 index 00000000..9afd32e9 Binary files /dev/null and b/priv/static/favicon-96x96.png differ diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico index 5c36dc20..ede5aee4 100644 Binary files a/priv/static/favicon.ico and b/priv/static/favicon.ico differ diff --git a/priv/static/ms-icon-144x144.png b/priv/static/ms-icon-144x144.png new file mode 100644 index 00000000..8f39b461 Binary files /dev/null and b/priv/static/ms-icon-144x144.png differ diff --git a/priv/static/ms-icon-150x150.png b/priv/static/ms-icon-150x150.png new file mode 100644 index 00000000..93144417 Binary files /dev/null and b/priv/static/ms-icon-150x150.png differ diff --git a/priv/static/ms-icon-310x310.png b/priv/static/ms-icon-310x310.png new file mode 100644 index 00000000..029e2407 Binary files /dev/null and b/priv/static/ms-icon-310x310.png differ diff --git a/priv/static/ms-icon-70x70.png b/priv/static/ms-icon-70x70.png new file mode 100644 index 00000000..32dc6f17 Binary files /dev/null and b/priv/static/ms-icon-70x70.png differ diff --git a/test/towerops/monitoring/check_test.exs b/test/towerops/monitoring/check_test.exs index 88786abf..8df77a5b 100644 --- a/test/towerops/monitoring/check_test.exs +++ b/test/towerops/monitoring/check_test.exs @@ -1,5 +1,6 @@ defmodule Towerops.Monitoring.CheckTest do - use Towerops.DataCase, async: true + # async: false to prevent deadlocks when testing foreign key constraints + use Towerops.DataCase, async: false import Towerops.AccountsFixtures diff --git a/test/towerops/snmp/processor_test.exs b/test/towerops/snmp/processor_test.exs new file mode 100644 index 00000000..307f14d9 --- /dev/null +++ b/test/towerops/snmp/processor_test.exs @@ -0,0 +1,214 @@ +defmodule Towerops.Snmp.ProcessorTest do + use Towerops.DataCase, async: true + + alias Towerops.Snmp.Processor + + describe "changeset/2" do + setup do + # Create a valid snmp_device_id for testing + snmp_device_id = Ecto.UUID.generate() + {:ok, snmp_device_id: snmp_device_id} + end + + test "valid changeset with required fields", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + assert changeset.valid? + assert get_change(changeset, :processor_index) == "hr_1" + assert get_change(changeset, :processor_type) == "hr_processor" + end + + test "valid changeset with all fields", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "cisco_1", + description: "Cisco CPU Core 1", + processor_type: "cisco_cpu", + load_percent: 45.5, + last_checked_at: DateTime.utc_now(), + metadata: %{cores: 4} + } + + changeset = Processor.changeset(%Processor{}, attrs) + + assert changeset.valid? + assert get_change(changeset, :description) == "Cisco CPU Core 1" + assert get_change(changeset, :load_percent) == 45.5 + assert get_change(changeset, :metadata) == %{cores: 4} + end + + test "requires snmp_device_id" do + attrs = %{ + processor_index: "hr_1", + processor_type: "hr_processor" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).snmp_device_id + end + + test "requires processor_index", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_type: "hr_processor" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).processor_index + end + + test "requires processor_type", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).processor_type + end + + test "validates processor_type inclusion", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "invalid_1", + processor_type: "invalid_type" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "is invalid" in errors_on(changeset).processor_type + end + + test "accepts all valid processor types", %{snmp_device_id: snmp_device_id} do + for processor_type <- ~w(hr_processor cisco_cpu ucd_cpu) do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "test_1", + processor_type: processor_type + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid?, "Expected #{processor_type} to be valid" + end + end + + test "validates load_percent is between 0 and 100", %{snmp_device_id: snmp_device_id} do + # Valid at 0 + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor", + load_percent: 0.0 + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Valid at 100 + attrs = %{attrs | load_percent: 100.0} + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Valid in middle + attrs = %{attrs | load_percent: 50.5} + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Invalid above 100 + attrs = %{attrs | load_percent: 100.1} + changeset = Processor.changeset(%Processor{}, attrs) + refute changeset.valid? + assert "must be between 0 and 100" in errors_on(changeset).load_percent + + # Invalid below 0 + attrs = %{attrs | load_percent: -1.0} + changeset = Processor.changeset(%Processor{}, attrs) + refute changeset.valid? + assert "must be between 0 and 100" in errors_on(changeset).load_percent + end + + test "allows nil load_percent", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor", + load_percent: nil + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + end + + test "description can be nil", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor", + description: nil + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + end + + test "metadata defaults to empty map", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "ucd_0", + processor_type: "ucd_cpu" + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Default comes from schema, not changeset + processor = %Processor{} + assert processor.metadata == %{} + end + end + + describe "schema" do + test "has correct fields" do + fields = Processor.__schema__(:fields) + assert :id in fields + assert :snmp_device_id in fields + assert :processor_index in fields + assert :description in fields + assert :processor_type in fields + assert :load_percent in fields + assert :last_checked_at in fields + assert :metadata in fields + assert :inserted_at in fields + assert :updated_at in fields + end + + test "belongs to snmp_device" do + assocs = Processor.__schema__(:associations) + assert :snmp_device in assocs + + assoc = Processor.__schema__(:association, :snmp_device) + assert assoc.queryable == Towerops.Snmp.Device + end + + test "uses binary_id for primary key" do + assert Processor.__schema__(:type, :id) == :binary_id + end + + test "uses binary_id for foreign keys" do + assert Processor.__schema__(:type, :snmp_device_id) == :binary_id + end + end +end diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs index 257e93a5..eb08d98f 100644 --- a/test/towerops/snmp/profiles/base_test.exs +++ b/test/towerops/snmp/profiles/base_test.exs @@ -89,8 +89,9 @@ defmodule Towerops.Snmp.Profiles.BaseTest do ]} end) - # Mock interface data for each interface (8 OIDs per interface × 3 interfaces = 24 calls) - expect(SnmpMock, :get, 24, fn _, oid, _ -> + # Mock interface data for each interface (9 OIDs per interface × 3 interfaces = 27 calls) + # OIDs: ifDescr, ifType, ifSpeed, ifPhysAddress, ifAdminStatus, ifOperStatus, ifName, ifHighSpeed, ifAlias + expect(SnmpMock, :get, 27, fn _, oid, _ -> cond do String.ends_with?(oid, ".1") -> parts = String.split(oid, ".") @@ -104,6 +105,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do second_to_last == "7" -> {:ok, {:integer, 1}} second_to_last == "8" -> {:ok, {:integer, 1}} second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth0"}} + second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 1000}} second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}} true -> {:error, :no_such_object} end @@ -120,6 +122,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do second_to_last == "7" -> {:ok, {:integer, 1}} second_to_last == "8" -> {:ok, {:integer, 2}} second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth1"}} + second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 100}} second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}} true -> {:error, :no_such_object} end @@ -136,6 +139,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do second_to_last == "7" -> {:ok, {:integer, 1}} second_to_last == "8" -> {:ok, {:integer, 1}} second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "lo"}} + second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 10}} second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "Loopback"}} true -> {:error, :no_such_object} end @@ -884,6 +888,122 @@ defmodule Towerops.Snmp.Profiles.BaseTest do end end + describe "discover_ipv6_addresses/1" do + test "discovers IPv6 addresses from IPV6-MIB ipv6AddrTable" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # ipAddressIfIndex - modern RFC 4293 table (empty, fallback to IPV6-MIB) + "1.3.6.1.2.1.4.34.1.3" -> + {:ok, []} + + # ipv6AddrPfxLength - indexed by ifIndex.ipv6Address + # Example: interface 2 with address 2001:db8::1 (16 octets) + "1.3.6.1.2.1.55.1.8.1.2" -> + {:ok, + [ + # ifIndex=2, IPv6=2001:0db8:0000:0000:0000:0000:0000:0001 + %{ + oid: "1.3.6.1.2.1.55.1.8.1.2.2.32.1.13.184.0.0.0.0.0.0.0.0.0.0.0.1", + value: {:integer, 64} + }, + # ifIndex=3, IPv6=fe80::1 + %{ + oid: "1.3.6.1.2.1.55.1.8.1.2.3.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1", + value: {:integer, 64} + } + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts) + + assert length(ip_addresses) == 2 + + ipv6_1 = Enum.find(ip_addresses, &(&1.if_index == 2)) + assert ipv6_1.ip_address == "2001:db8:0:0:0:0:0:1" + assert ipv6_1.prefix_length == 64 + assert ipv6_1.ip_type == "ipv6" + + ipv6_2 = Enum.find(ip_addresses, &(&1.if_index == 3)) + assert ipv6_2.ip_address == "fe80:0:0:0:0:0:0:1" + assert ipv6_2.prefix_length == 64 + end + + test "returns empty list when IPV6-MIB not supported" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts) + assert ip_addresses == [] + end + + test "handles walk errors gracefully" do + stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end) + + assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts) + assert ip_addresses == [] + end + end + + describe "discover_all_ip_addresses/1" do + test "combines IPv4 and IPv6 addresses" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # IPv4 from IP-MIB + "1.3.6.1.2.1.4.20.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.4.20.1.2.192.168.1.1", value: {:integer, 1}} + ]} + + "1.3.6.1.2.1.4.20.1.3" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.4.20.1.3.192.168.1.1", value: {:ip_address, {255, 255, 255, 0}}} + ]} + + # IPv6 from modern ipAddressTable (empty, fallback) + "1.3.6.1.2.1.4.34.1.3" -> + {:ok, []} + + # IPv6 from IPV6-MIB + "1.3.6.1.2.1.55.1.8.1.2" -> + {:ok, + [ + %{ + oid: "1.3.6.1.2.1.55.1.8.1.2.1.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1", + value: {:integer, 64} + } + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts) + + assert length(all_addresses) == 2 + + ipv4 = Enum.find(all_addresses, &(&1.ip_type == "ipv4")) + assert ipv4.ip_address == "192.168.1.1" + assert ipv4.prefix_length == 24 + + ipv6 = Enum.find(all_addresses, &(&1.ip_type == "ipv6")) + assert ipv6.ip_address == "fe80:0:0:0:0:0:0:1" + assert ipv6.prefix_length == 64 + end + + test "returns empty list when no addresses found" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts) + assert all_addresses == [] + end + end + describe "discover_memory_pools/1" do test "discovers memory pools from HOST-RESOURCES-MIB" do stub(SnmpMock, :walk, fn _, oid, _ -> @@ -1326,4 +1446,153 @@ defmodule Towerops.Snmp.Profiles.BaseTest do end end end + + describe "discover_processors/1" do + test "discovers processors from HOST-RESOURCES-MIB" do + # Mock hrProcessorLoad walk + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 25}}, + %{oid: "1.3.6.1.2.1.25.3.3.1.2.769", value: {:integer, 45}} + ]} + end) + + # Mock hrDeviceDescr walk for processor descriptions + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.3.2.1.3.768", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}}, + %{oid: "1.3.6.1.2.1.25.3.2.1.3.769", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}} + ]} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 2 + + cpu1 = Enum.find(processors, &(&1.processor_index == "hr_768")) + assert cpu1.description == "Intel Core i7-9700 @ 3.00GHz" + assert cpu1.processor_type == "hr_processor" + assert cpu1.load_percent == 25.0 + + cpu2 = Enum.find(processors, &(&1.processor_index == "hr_769")) + assert cpu2.description == "Intel Core i7-9700 @ 3.00GHz" + assert cpu2.processor_type == "hr_processor" + assert cpu2.load_percent == 45.0 + end + + test "falls back to CISCO-PROCESS-MIB when HOST-RESOURCES-MIB returns empty" do + # HR-MIB returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, []} + end) + + # Cisco CPU walk returns data + expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ -> + {:ok, + [ + %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 15}}, + %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.2", value: {:integer, 22}} + ]} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 2 + + cpu1 = Enum.find(processors, &(&1.processor_index == "cisco_1")) + assert cpu1.description == "Cisco CPU 1" + assert cpu1.processor_type == "cisco_cpu" + assert cpu1.load_percent == 15.0 + + cpu2 = Enum.find(processors, &(&1.processor_index == "cisco_2")) + assert cpu2.description == "Cisco CPU 2" + assert cpu2.processor_type == "cisco_cpu" + assert cpu2.load_percent == 22.0 + end + + test "falls back to UCD-SNMP-MIB when other MIBs return empty" do + # HR-MIB returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, []} + end) + + # Cisco CPU returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ -> + {:ok, []} + end) + + # UCD-SNMP-MIB CPU stats + expect(SnmpMock, :get, 3, fn _, oid, _ -> + case oid do + "1.3.6.1.4.1.2021.11.9.0" -> {:ok, {:integer, 10}} + "1.3.6.1.4.1.2021.11.10.0" -> {:ok, {:integer, 5}} + "1.3.6.1.4.1.2021.11.11.0" -> {:ok, {:integer, 85}} + end + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 1 + + cpu = hd(processors) + assert cpu.processor_index == "ucd_0" + assert cpu.description == "System CPU" + assert cpu.processor_type == "ucd_cpu" + assert cpu.load_percent == 15.0 + assert cpu.metadata.user_percent == 10 + assert cpu.metadata.system_percent == 5 + end + + test "returns empty list when no CPU MIBs are supported" do + # All walks return empty + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + assert processors == [] + end + + test "handles HOST-RESOURCES-MIB walk errors gracefully" do + # HR-MIB walk fails + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:error, :timeout} + end) + + # Falls back to Cisco + expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ -> + {:ok, + [ + %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 30}} + ]} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + assert length(processors) == 1 + assert hd(processors).processor_type == "cisco_cpu" + end + + test "uses default description when hrDeviceDescr is missing" do + # HR processor load + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 50}} + ]} + end) + + # HR device description returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ -> + {:ok, []} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 1 + cpu = hd(processors) + assert cpu.description == "CPU 768" + assert cpu.load_percent == 50.0 + end + end end diff --git a/test/towerops/snmp/sanitizer_test.exs b/test/towerops/snmp/sanitizer_test.exs new file mode 100644 index 00000000..ec1ef064 --- /dev/null +++ b/test/towerops/snmp/sanitizer_test.exs @@ -0,0 +1,137 @@ +defmodule Towerops.Snmp.SanitizerTest do + use ExUnit.Case, async: true + + alias Towerops.Snmp.Sanitizer + + describe "sanitize_string/1" do + test "returns nil for nil input" do + assert Sanitizer.sanitize_string(nil) == nil + end + + test "returns trimmed string for printable input" do + assert Sanitizer.sanitize_string(" Hello World ") == "Hello World" + end + + test "passes through normal strings" do + assert Sanitizer.sanitize_string("test string") == "test string" + end + + test "converts non-printable binary to hex string" do + # Raw binary bytes (e.g., MAC address) + assert Sanitizer.sanitize_string(<<255, 0, 128, 64>>) == "ff:00:80:40" + end + + test "converts erlang charlist to string" do + assert Sanitizer.sanitize_string(~c"hello") == "hello" + end + + test "converts integers to string" do + assert Sanitizer.sanitize_string(123) == "123" + end + + test "handles mixed printable/non-printable as hex" do + # String with null byte - not printable + assert Sanitizer.sanitize_string(<<"test", 0>>) == "74:65:73:74:00" + end + end + + describe "sanitize_ipv4/1" do + test "formats tuple as dotted decimal" do + assert Sanitizer.sanitize_ipv4({192, 168, 1, 1}) == "192.168.1.1" + end + + test "formats 4-byte binary as dotted decimal" do + assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1>>) == "192.168.1.1" + end + + test "passes through already-formatted string" do + assert Sanitizer.sanitize_ipv4("10.0.0.1") == "10.0.0.1" + end + + test "extracts first 4 bytes from longer binary" do + assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1, 0, 0>>) == "192.168.1.1" + end + + test "returns nil for invalid input" do + assert Sanitizer.sanitize_ipv4("not an ip") == nil + assert Sanitizer.sanitize_ipv4(nil) == nil + assert Sanitizer.sanitize_ipv4(123) == nil + end + + test "handles subnet mask with 0xff bytes" do + # This was the original bug - 0xff is not valid UTF-8 + assert Sanitizer.sanitize_ipv4(<<255, 255, 255, 0>>) == "255.255.255.0" + end + end + + describe "sanitize_ipv6/1" do + test "formats 16-byte binary as IPv6" do + # ::1 (localhost) + bytes = <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1>> + assert Sanitizer.sanitize_ipv6(bytes) == "0:0:0:0:0:0:0:1" + end + + test "passes through already-formatted string" do + assert Sanitizer.sanitize_ipv6("2001:db8::1") == "2001:db8::1" + end + + test "returns nil for invalid input" do + assert Sanitizer.sanitize_ipv6("not an ipv6") == nil + assert Sanitizer.sanitize_ipv6(nil) == nil + end + end + + describe "sanitize_mac/1" do + test "formats 6-byte binary as colon-separated hex" do + assert Sanitizer.sanitize_mac(<<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>) == "aa:bb:cc:dd:ee:ff" + end + + test "normalizes dash-separated to colon-separated" do + assert Sanitizer.sanitize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff" + end + + test "passes through already-formatted lowercase" do + assert Sanitizer.sanitize_mac("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff" + end + + test "lowercases uppercase input" do + assert Sanitizer.sanitize_mac("AA:BB:CC:DD:EE:FF") == "aa:bb:cc:dd:ee:ff" + end + + test "returns nil for invalid input" do + assert Sanitizer.sanitize_mac("not a mac") == nil + assert Sanitizer.sanitize_mac(nil) == nil + assert Sanitizer.sanitize_mac(<<1, 2, 3>>) == nil + end + end + + describe "sanitize_map/1" do + test "sanitizes all string values in map" do + input = %{ + name: " Test Name ", + value: 123, + binary: <<255, 0>> + } + + result = Sanitizer.sanitize_map(input) + + assert result.name == "Test Name" + assert result.value == 123 + assert result.binary == "ff:00" + end + + test "recursively sanitizes nested maps" do + input = %{ + outer: " outer ", + nested: %{ + inner: " inner " + } + } + + result = Sanitizer.sanitize_map(input) + + assert result.outer == "outer" + assert result.nested.inner == "inner" + end + end +end diff --git a/test/towerops/workers/discovery_worker_test.exs b/test/towerops/workers/discovery_worker_test.exs index 0a79e17e..63a7741b 100644 --- a/test/towerops/workers/discovery_worker_test.exs +++ b/test/towerops/workers/discovery_worker_test.exs @@ -36,16 +36,20 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do end test "successfully discovers a device", %{device: device} do - # Mock SNMP responses for discovery (categorize_device_speed + test_connection + system_info = 8 calls) - expect(SnmpMock, :get, 8, fn _target, oid, _opts -> + # Mock SNMP responses - use stub for flexibility as discovery calls vary + stub(SnmpMock, :get, fn _target, oid, _opts -> case oid do "1.3.6.1.2.1.1.1.0" -> {:ok, "Test Device"} "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]} - # sysUpTime (also used by test_connection) "1.3.6.1.2.1.1.3.0" -> {:ok, 12_345} "1.3.6.1.2.1.1.4.0" -> {:ok, "admin@test.com"} "1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"} "1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"} + # UCD-SNMP-MIB CPU stats (processor discovery fallback) + "1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10} + "1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5} + "1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85} + _ -> {:error, :timeout} end end) @@ -82,8 +86,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do end test "successfully completes discovery with proper mocks", %{device: device} do - # Mock SNMP responses (categorize_device_speed + test_connection + system_info = 8 calls) - expect(SnmpMock, :get, 8, fn _target, oid, _opts -> + # Mock SNMP responses - use stub for flexibility as discovery calls vary + stub(SnmpMock, :get, fn _target, oid, _opts -> case oid do "1.3.6.1.2.1.1.1.0" -> {:ok, "Test"} "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1]} @@ -91,6 +95,11 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do "1.3.6.1.2.1.1.4.0" -> {:ok, ""} "1.3.6.1.2.1.1.5.0" -> {:ok, "test"} "1.3.6.1.2.1.1.6.0" -> {:ok, ""} + # UCD-SNMP-MIB CPU stats (processor discovery fallback) + "1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10} + "1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5} + "1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85} + _ -> {:error, :timeout} end end)