diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index 484f7a3d..b91514e1 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -190,28 +190,31 @@ defmodule Towerops.Devices do end defp resolve_snmp_config(device) do - cond do - device.snmp_community != nil -> - build_snmp_config(device.snmp_version, device.snmp_community, :device) + # Resolve community and version independently with hierarchical fallback + # This allows setting version at device level while inheriting community from org + community = + device.snmp_community || + device.site.snmp_community || + device.site.organization.snmp_community - device.site.snmp_community != nil -> - build_snmp_config(device.site.snmp_version, device.site.snmp_community, :site) + version = + device.snmp_version || + device.site.snmp_version || + device.site.organization.snmp_version || + "2c" - device.site.organization.snmp_community != nil -> - build_snmp_config( - device.site.organization.snmp_version, - device.site.organization.snmp_community, - :organization - ) + # Determine source based on where community string came from (primary credential) + # If no community anywhere, source is :default + source = + cond do + device.snmp_community != nil -> :device + device.site.snmp_community != nil -> :site + device.site.organization.snmp_community != nil -> :organization + true -> :default + end - true -> - %{version: "2c", community: nil, source: :default} - end - end - - defp build_snmp_config(version, community, source) do %{ - version: version || "2c", + version: version, community: community, source: source } diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex index a49344a6..4ffba380 100644 --- a/lib/towerops/snmp/device.ex +++ b/lib/towerops/snmp/device.ex @@ -27,6 +27,8 @@ defmodule Towerops.Snmp.Device do field :model, :string field :firmware_version, :string field :serial_number, :string + field :raw_discovery_data, :map + field :last_discovery_at, :utc_datetime belongs_to :device, DeviceSchema @@ -48,6 +50,8 @@ defmodule Towerops.Snmp.Device do model: String.t() | nil, firmware_version: String.t() | nil, serial_number: String.t() | nil, + raw_discovery_data: map() | nil, + last_discovery_at: DateTime.t() | nil, device_id: Ecto.UUID.t(), device: NotLoaded.t() | Devices.t(), interfaces: NotLoaded.t() | [Interface.t()], @@ -70,7 +74,9 @@ defmodule Towerops.Snmp.Device do :manufacturer, :model, :firmware_version, - :serial_number + :serial_number, + :raw_discovery_data, + :last_discovery_at ]) |> validate_required([:device_id]) |> unique_constraint(:device_id) diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index f82ddddc..bb248659 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -117,6 +117,13 @@ defmodule Towerops.Snmp.Discovery do _ = update_device_discovery_time(device) Logger.info("SNMP discovery completed successfully for: #{device.name}") + # Determine if this was first discovery or rediscovery + # Check if device existed before (had last_discovery_at set) + is_rediscovery = device.last_discovery_at != nil + + # Log discovery event + log_discovery_event(device, discovered_device, is_rediscovery) + # Broadcast discovery completion for real-time updates _ = Phoenix.PubSub.broadcast( @@ -236,7 +243,8 @@ defmodule Towerops.Snmp.Discovery do Cisco # Ubiquiti devices (check before generic Linux since they run Linux) - String.contains?(sys_object_id, "41112") or + # OID 10002 = old Ubiquiti/UniFi devices, 41112 = newer airOS devices + String.contains?(sys_object_id, ["10002", "41112"]) or String.contains?(sys_descr, ["airOS", "AirFiber", "airMAX", "Ubiquiti"]) -> Logger.debug("Selected Ubiquiti profile") Ubiquiti @@ -263,14 +271,53 @@ defmodule Towerops.Snmp.Discovery do {:ok, system_info} end - defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do + defp build_device_info(client_opts, system_info, profile) when is_atom(profile) do + Logger.info( + "build_device_info: system_info has _raw_discovery_data: #{inspect(Map.has_key?(system_info, :_raw_discovery_data))}" + ) + + # Get profile-specific system info if profile has discover_system_info/1 + enriched_system_info = + if function_exported?(profile, :discover_system_info, 1) do + case profile.discover_system_info(client_opts) do + {:ok, profile_info} -> + Logger.info("Enriched system_info with profile-specific data") + Map.merge(system_info, profile_info) + + {:error, _reason} -> + Logger.warning("Failed to get profile-specific system info, using base info") + system_info + end + else + system_info + end + # Let the hard-coded profile identify the device - identified_info = profile.identify_device(system_info) - {:ok, identified_info} + identified_info = profile.identify_device(enriched_system_info) + + # Collect raw debug data if profile supports it + identified_info_with_debug = + if function_exported?(profile, :collect_raw_debug_data, 1) do + raw_data = profile.collect_raw_debug_data(client_opts) + Logger.info("Collected raw debug data: #{map_size(raw_data)} keys") + + identified_info + |> Map.put(:_raw_discovery_data, raw_data) + |> Map.put(:_last_discovery_at, DateTime.utc_now()) + else + identified_info + end + + Logger.info( + "build_device_info: identified_info has _raw_discovery_data: #{inspect(Map.has_key?(identified_info_with_debug, :_raw_discovery_data))}" + ) + + {:ok, identified_info_with_debug} rescue _error -> - Logger.error("Failed to identify device") - {:ok, system_info} + Logger.error("Failed to identify device with profile, falling back to Base profile") + # Fall back to Base profile identification to ensure manufacturer/model are set + {:ok, Base.identify_device(system_info)} end @spec discover_interfaces(Client.connection_opts(), profile()) :: @@ -342,15 +389,34 @@ defmodule Towerops.Snmp.Discovery do @spec upsert_device(DeviceSchema.t(), device_info()) :: Device.t() defp upsert_device(device, device_info) do + Logger.info("upsert_device called with device_info keys: #{inspect(Map.keys(device_info))}") + + # Extract raw discovery data if present (using underscore prefix to distinguish from regular fields) + raw_discovery_data = Map.get(device_info, :_raw_discovery_data) + last_discovery_at = Map.get(device_info, :_last_discovery_at, DateTime.utc_now()) + + Logger.info("Extracted raw_discovery_data: #{inspect(raw_discovery_data != nil)}") + + # Remove internal keys before saving + device_attrs = + device_info + |> Map.delete(:_raw_discovery_data) + |> Map.delete(:_last_discovery_at) + |> Map.put(:device_id, device.id) + |> Map.put(:raw_discovery_data, raw_discovery_data) + |> Map.put(:last_discovery_at, last_discovery_at) + + Logger.info("device_attrs has raw_discovery_data: #{inspect(Map.has_key?(device_attrs, :raw_discovery_data))}") + case Repo.get_by(Device, device_id: device.id) do nil -> %Device{} - |> Device.changeset(Map.put(device_info, :device_id, device.id)) + |> Device.changeset(device_attrs) |> Repo.insert!() existing_device -> existing_device - |> Device.changeset(device_info) + |> Device.changeset(device_attrs) |> Repo.update!() end end @@ -427,4 +493,44 @@ defmodule Towerops.Snmp.Discovery do {:ok, device} end end + + defp log_discovery_event(device, discovered_device, is_rediscovery) do + event_type = if is_rediscovery, do: "device_rediscovered", else: "device_discovered" + + message = + if is_rediscovery do + "Device rediscovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})" + else + "Device discovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})" + end + + metadata = %{ + manufacturer: discovered_device.manufacturer, + model: discovered_device.model, + firmware_version: discovered_device.firmware_version, + serial_number: discovered_device.serial_number, + interface_count: length(discovered_device.interfaces || []), + sensor_count: + Repo.aggregate( + from(s in Sensor, where: s.snmp_device_id == ^discovered_device.id), + :count + ) + } + + event_attrs = %{ + device_id: device.id, + event_type: event_type, + severity: "info", + message: message, + metadata: metadata, + occurred_at: DateTime.utc_now() + } + + # Publish event for EventLogger to persist + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:events", + {:device_event, event_attrs} + ) + end end diff --git a/lib/towerops/snmp/mib_translator.ex b/lib/towerops/snmp/mib_translator.ex index 29aea440..fd7a5572 100644 --- a/lib/towerops/snmp/mib_translator.ex +++ b/lib/towerops/snmp/mib_translator.ex @@ -58,7 +58,9 @@ defmodule Towerops.Snmp.MibTranslator do defp translate_mib_name(mib_name) do # Get MIB directories from config or use default mib_dirs = Application.get_env(:towerops, :mib_dirs, ["/app/mibs", "/usr/share/snmp/mibs"]) - mib_dir_args = Enum.flat_map(mib_dirs, fn dir -> ["-M", "+#{dir}"] end) + # snmptranslate requires colon-separated paths with + prefix for each directory + mib_path = Enum.map_join(mib_dirs, ":", fn dir -> "+#{dir}" end) + mib_dir_args = if mib_path == "", do: [], else: ["-M", mib_path] # Use snmptranslate to convert MIB name to numeric OID # -On: Output numeric OID diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index 19c4ab74..626613ad 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -335,4 +335,50 @@ defmodule Towerops.Snmp.Profiles.Base do defp parse_sensor_status(2), do: "unavailable" defp parse_sensor_status(3), do: "nonoperational" defp parse_sensor_status(_), do: "unknown" + + @doc """ + Collects raw debug data for troubleshooting. + Includes system OIDs and interface/sensor tables. + """ + def collect_raw_debug_data(client_opts) do + # Collect system OID values + system_oids = + Map.new(@system_oids, fn {key, oid} -> + value = + case Client.get(client_opts, oid) do + {:ok, val} -> val + {:error, reason} -> "ERROR: #{inspect(reason)}" + end + + {key, %{oid: oid, value: value}} + end) + + # Walk interface tables + if_table = + case Client.walk(client_opts, "1.3.6.1.2.1.2.2") do + {:ok, results} -> results + {:error, _} -> %{} + end + + ifx_table = + case Client.walk(client_opts, "1.3.6.1.2.1.31.1.1") do + {:ok, results} -> results + {:error, _} -> %{} + end + + # Walk entity sensor table + entity_sensor_table = + case Client.walk(client_opts, "1.3.6.1.2.1.99.1.1") do + {:ok, results} -> results + {:error, _} -> %{} + end + + %{ + timestamp: DateTime.utc_now(), + system_oids: system_oids, + if_table: if_table, + ifx_table: ifx_table, + entity_sensor_table: entity_sensor_table + } + end end diff --git a/lib/towerops/snmp/profiles/cisco.ex b/lib/towerops/snmp/profiles/cisco.ex index 0c3297cf..5d70e260 100644 --- a/lib/towerops/snmp/profiles/cisco.ex +++ b/lib/towerops/snmp/profiles/cisco.ex @@ -30,12 +30,14 @@ defmodule Towerops.Snmp.Profiles.Cisco do } @doc """ - Discovers system information using Base profile. + Discovers system information. + Cisco profile doesn't query additional system OIDs beyond Base profile. """ @spec discover_system_info(Client.connection_opts()) :: {:ok, Discovery.system_info()} | {:error, term()} - def discover_system_info(client_opts) do - Base.discover_system_info(client_opts) + def discover_system_info(_client_opts) do + # No additional system info beyond Base profile + {:ok, %{}} end @doc """ @@ -244,4 +246,31 @@ defmodule Towerops.Snmp.Profiles.Cisco do defp parse_cisco_sensor_status(2), do: "unavailable" defp parse_cisco_sensor_status(3), do: "nonoperational" defp parse_cisco_sensor_status(_), do: "unknown" + + @doc """ + Collects raw debug data for troubleshooting. + Includes Cisco-specific sensor OIDs and entity tables. + """ + def collect_raw_debug_data(client_opts) do + # Walk Cisco entity sensor table + cisco_sensor_table = + case Client.walk(client_opts, "1.3.6.1.4.1.9.9.91.1.1.1") do + {:ok, results} -> results + {:error, _} -> %{} + end + + # Walk entity physical table for descriptions + entity_phys_table = + case Client.walk(client_opts, "1.3.6.1.2.1.47.1.1.1") do + {:ok, results} -> results + {:error, _} -> %{} + end + + %{ + timestamp: DateTime.utc_now(), + cisco_sensor_oids: @cisco_sensor_oids, + cisco_sensor_table: cisco_sensor_table, + entity_phys_table: entity_phys_table + } + end end diff --git a/lib/towerops/snmp/profiles/mikrotik.ex b/lib/towerops/snmp/profiles/mikrotik.ex index 8cffdc33..636b6e49 100644 --- a/lib/towerops/snmp/profiles/mikrotik.ex +++ b/lib/towerops/snmp/profiles/mikrotik.ex @@ -60,10 +60,18 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do total_memory: "1.3.6.1.2.1.25.2.2.0", used_memory: "1.3.6.1.4.1.14988.1.1.1.2.0", total_hdd: "1.3.6.1.4.1.14988.1.1.1.6.0", - used_hdd: "1.3.6.1.4.1.14988.1.1.1.7.0" + used_hdd: "1.3.6.1.4.1.14988.1.1.1.7.0", + + # Network Services + dhcp_lease_count: "1.3.6.1.4.1.14988.1.1.1.3.0", + + # Connection Tracking (mtxrFWConnection) + fw_connection_count: "1.3.6.1.4.1.14988.1.1.13.1.1.1.0", + fw_connection_max: "1.3.6.1.4.1.14988.1.1.13.1.1.2.0" } # Table OID bases for walking + @health_sensor_table "1.3.6.1.4.1.14988.1.1.3.100.1" @poe_table "1.3.6.1.4.1.14988.1.1.15.1" @optical_table "1.3.6.1.4.1.14988.1.1.16.1" @@ -129,17 +137,20 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do _ -> "MikroTik RouterOS" end - # Build formatted firmware version like LibreNMS: "Mikrotik RouterOS 7.20.6 (Level 6)" + # Build formatted firmware version firmware_version = Map.get(system_info, :firmware_version) license_level = Map.get(system_info, :license_version) + # Treat empty string as nil for license level + license_level = if license_level == "", do: nil, else: license_level + formatted_firmware = cond do firmware_version && license_level -> - "Mikrotik RouterOS #{firmware_version} (Level #{license_level})" + "RouterOS #{firmware_version} (Level #{license_level})" firmware_version -> - "Mikrotik RouterOS #{firmware_version}" + "RouterOS #{firmware_version}" true -> # Fallback to extracting from sysDescr @@ -206,13 +217,92 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do license_version: license_level }} - {:error, _} -> + {:error, reason} -> # MikroTik-specific OIDs not available or partially available + Logger.warning("Failed to get MikroTik device info: #{inspect(reason)}") {:ok, %{}} end end defp discover_health_sensors(client_opts) do + # First try the dynamic health sensor table (available on newer devices like CCR2004) + table_sensors = discover_health_table_sensors(client_opts) + + # Also try individual health OIDs for older devices + oid_sensors = discover_health_oid_sensors(client_opts) + + # Combine both, preferring table sensors (more accurate) + table_sensors ++ oid_sensors + end + + defp discover_health_table_sensors(client_opts) do + # Walk health sensor table: mtxrHealthTable (1.3.6.1.4.1.14988.1.1.3.100.1) + # Column 2: Name, Column 3: Value, Column 4: Type + with {:ok, name_results} <- Client.walk(client_opts, "#{@health_sensor_table}.2"), + {:ok, value_results} <- Client.walk(client_opts, "#{@health_sensor_table}.3"), + {:ok, type_results} <- Client.walk(client_opts, "#{@health_sensor_table}.4") do + indices = + name_results + |> Map.keys() + |> Enum.map(fn oid -> oid |> String.split(".") |> List.last() end) + + indices + |> Enum.map(fn index -> + name_oid = "#{@health_sensor_table}.2.#{index}" + value_oid = "#{@health_sensor_table}.3.#{index}" + type_oid = "#{@health_sensor_table}.4.#{index}" + + name = Map.get(name_results, name_oid, "unknown") + value = Map.get(value_results, value_oid, 0) + type_code = Map.get(type_results, type_oid, 0) + + {sensor_type, unit, divisor} = decode_health_sensor_type(type_code, name) + + # Skip sensors with value 0 (disabled PSUs, etc.) + if value > 0 do + %{ + sensor_type: sensor_type, + sensor_index: "health_#{index}", + sensor_oid: value_oid, + sensor_descr: format_health_sensor_name(name), + sensor_unit: unit, + sensor_divisor: divisor, + last_value: value / divisor, + status: "ok" + } + end + end) + |> Enum.reject(&is_nil/1) + else + _ -> [] + end + end + + defp decode_health_sensor_type(type_code, name) do + cond do + # Type 1 = Temperature (Celsius) + type_code == 1 -> {"temperature", "°C", 1} + # Type 3 = Voltage (V, value in decivolts) + type_code == 3 -> {"voltage", "V", 10} + # Type 2 = Current (mA) + type_code == 2 -> {"current", "mA", 1} + # Fallback: infer from name + String.contains?(name, "temperature") -> {"temperature", "°C", 1} + String.contains?(name, "voltage") -> {"voltage", "V", 10} + String.contains?(name, "current") -> {"current", "mA", 1} + String.contains?(name, "power") -> {"power", "W", 10} + true -> {"unknown", "", 1} + end + end + + defp format_health_sensor_name(name) do + name + |> String.replace("-", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end + + defp discover_health_oid_sensors(client_opts) do # Try to get all health OIDs - voltages, temperatures, power, fans health_oids = [ # Voltages @@ -262,10 +352,11 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do defp discover_resource_sensors(client_opts) do cpu_sensors = discover_cpu_sensors(client_opts) storage_sensors = discover_storage_sensors(client_opts) + network_sensors = discover_network_sensors(client_opts) poe_sensors = discover_poe_sensors(client_opts) optical_sensors = discover_optical_sensors(client_opts) - cpu_sensors ++ storage_sensors ++ poe_sensors ++ optical_sensors + cpu_sensors ++ storage_sensors ++ network_sensors ++ poe_sensors ++ optical_sensors end defp discover_cpu_sensors(client_opts) do @@ -292,6 +383,35 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do end end + defp discover_network_sensors(client_opts) do + # Get DHCP lease count and connection tracking stats + network_oids = [ + {@mikrotik_oids.dhcp_lease_count, "dhcp_leases", "count", 1}, + {@mikrotik_oids.fw_connection_count, "connection_count", "count", 1} + ] + + network_oids + |> Enum.map(fn {oid, type, unit, divisor} -> + case Client.get(client_opts, oid) do + {:ok, value} when is_integer(value) and value >= 0 -> + %{ + sensor_type: type, + sensor_index: type, + sensor_oid: oid, + sensor_descr: format_network_sensor_name(type), + sensor_unit: unit, + sensor_divisor: divisor, + last_value: value / divisor, + status: "ok" + } + + _ -> + nil + end + end) + |> Enum.reject(&is_nil/1) + end + defp discover_storage_sensors(client_opts) do with {:ok, descr_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.3"), {:ok, size_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.5"), @@ -544,6 +664,10 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do defp format_sensor_name("processor_frequency"), do: "Processor Frequency" defp format_sensor_name(other), do: String.capitalize(other) + defp format_network_sensor_name("dhcp_leases"), do: "DHCP Leases" + defp format_network_sensor_name("connection_count"), do: "Active Connections" + defp format_network_sensor_name(other), do: String.capitalize(other) + defp cpu_status(load) when load < 70, do: "ok" defp cpu_status(load) when load < 90, do: "warning" defp cpu_status(_), do: "critical" @@ -568,4 +692,34 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do _ -> nil end end + + @doc """ + Collects raw SNMP OID data for debugging purposes. + """ + def collect_raw_debug_data(client_opts) do + # Collect raw SNMP data for debugging + system_oids = + Map.new(@mikrotik_oids, fn {key, oid} -> + value = + case Client.get(client_opts, oid) do + {:ok, val} -> val + {:error, reason} -> "ERROR: #{inspect(reason)}" + end + + {key, %{oid: oid, value: value}} + end) + + # Walk health sensor table + health_table = + case Client.walk(client_opts, @health_sensor_table) do + {:ok, results} -> results + {:error, _} -> %{} + end + + %{ + timestamp: DateTime.utc_now(), + system_oids: system_oids, + health_sensor_table: health_table + } + end end diff --git a/lib/towerops/snmp/profiles/net_snmp.ex b/lib/towerops/snmp/profiles/net_snmp.ex index b30c28d7..3d5eb989 100644 --- a/lib/towerops/snmp/profiles/net_snmp.ex +++ b/lib/towerops/snmp/profiles/net_snmp.ex @@ -38,12 +38,14 @@ defmodule Towerops.Snmp.Profiles.NetSnmp do } @doc """ - Discovers system information using Base profile. + Discovers system information. + NetSnmp profile doesn't query additional system OIDs beyond Base profile. """ @spec discover_system_info(Client.connection_opts()) :: {:ok, Discovery.system_info()} | {:error, term()} - def discover_system_info(client_opts) do - Base.discover_system_info(client_opts) + def discover_system_info(_client_opts) do + # No additional system info beyond Base profile + {:ok, %{}} end @doc """ @@ -313,4 +315,53 @@ defmodule Towerops.Snmp.Profiles.NetSnmp do defp parse_float(value) when is_integer(value), do: value / 1.0 defp parse_float(value) when is_binary(value), do: String.to_float(value) defp parse_float(_), do: nil + + @doc """ + Collects raw debug data for troubleshooting. + Includes LM-SENSORS and UCD-SNMP tables. + """ + def collect_raw_debug_data(client_opts) do + # Walk LM-SENSORS tables + lm_temp_table = + case Client.walk(client_opts, "1.3.6.1.4.1.2021.13.16.2") do + {:ok, results} -> results + {:error, _} -> %{} + end + + lm_fan_table = + case Client.walk(client_opts, "1.3.6.1.4.1.2021.13.16.3") do + {:ok, results} -> results + {:error, _} -> %{} + end + + lm_voltage_table = + case Client.walk(client_opts, "1.3.6.1.4.1.2021.13.16.4") do + {:ok, results} -> results + {:error, _} -> %{} + end + + # Walk UCD-SNMP tables + ucd_load_table = + case Client.walk(client_opts, "1.3.6.1.4.1.2021.10") do + {:ok, results} -> results + {:error, _} -> %{} + end + + ucd_memory_table = + case Client.walk(client_opts, "1.3.6.1.4.1.2021.4") do + {:ok, results} -> results + {:error, _} -> %{} + end + + %{ + timestamp: DateTime.utc_now(), + lm_sensors_oids: @lm_sensors_oids, + ucd_snmp_oids: @ucd_snmp_oids, + lm_temp_table: lm_temp_table, + lm_fan_table: lm_fan_table, + lm_voltage_table: lm_voltage_table, + ucd_load_table: ucd_load_table, + ucd_memory_table: ucd_memory_table + } + end end diff --git a/lib/towerops/snmp/profiles/ubiquiti.ex b/lib/towerops/snmp/profiles/ubiquiti.ex index d25dd1de..dddebecc 100644 --- a/lib/towerops/snmp/profiles/ubiquiti.ex +++ b/lib/towerops/snmp/profiles/ubiquiti.ex @@ -18,6 +18,10 @@ defmodule Towerops.Snmp.Profiles.Ubiquiti do # Ubiquiti enterprise MIB base: 1.3.6.1.4.1.41112 @ubnt_base "1.3.6.1.4.1.41112" + # UniFi specific OIDs (.1.6.3) + @unifi_system_model "#{@ubnt_base}.1.6.3.3.0" + @unifi_system_version "#{@ubnt_base}.1.6.3.6.0" + # Radio configuration OIDs (.1.3.1.1) @radio_config_base "#{@ubnt_base}.1.3.1.1" @radio_tx_freq "#{@radio_config_base}.5" @@ -36,10 +40,27 @@ defmodule Towerops.Snmp.Profiles.Ubiquiti do @radio_remote_rx_power "#{@radio_stats_base}.14" @doc """ - Discovers system information using base profile. + Discovers system information, extending Base with UniFi-specific OIDs. """ def discover_system_info(client_opts) do - Base.discover_system_info(client_opts) + with {:ok, base_info} <- Base.discover_system_info(client_opts) do + # Try to get UniFi-specific model and version + unifi_info = discover_unifi_system_info(client_opts) + {:ok, Map.merge(base_info, unifi_info)} + end + end + + defp discover_unifi_system_info(client_opts) do + case Client.get_multiple(client_opts, [@unifi_system_model, @unifi_system_version]) do + {:ok, [model, version]} when is_binary(model) or is_binary(version) -> + %{ + unifi_model: model, + unifi_version: version + } + + _ -> + %{} + end end @doc """ @@ -70,21 +91,93 @@ defmodule Towerops.Snmp.Profiles.Ubiquiti do end @doc """ - Identifies Ubiquiti device from sysDescr. + Identifies Ubiquiti device from sysDescr and UniFi-specific OIDs. """ def identify_device(system_info) do sys_descr = Map.get(system_info, :sys_descr, "") + unifi_model = Map.get(system_info, :unifi_model) + unifi_version = Map.get(system_info, :unifi_version) - # Extract airOS version if present - firmware = extract_airos_version(sys_descr) + # Prefer UniFi-specific model/version if available, otherwise detect from sysDescr + {model, firmware} = + if unifi_model || unifi_version do + {unifi_model || "UniFi Device", unifi_version} + else + {detect_model(sys_descr), extract_airos_version(sys_descr)} + end Map.merge(system_info, %{ manufacturer: "Ubiquiti", - model: detect_model(sys_descr), + model: model, firmware_version: firmware }) end + @doc """ + Collects raw debug data for troubleshooting. + Includes UniFi system OIDs, radio configuration and statistics. + """ + def collect_raw_debug_data(client_opts) do + # UniFi system OIDs + unifi_system_oids = %{ + unifi_model: @unifi_system_model, + unifi_version: @unifi_system_version + } + + # Radio configuration OIDs + radio_config_oids = %{ + radio_tx_freq: @radio_tx_freq, + radio_rx_freq: @radio_rx_freq, + radio_tx_power: @radio_tx_power + } + + # Radio statistics OIDs + radio_stats_oids = %{ + radio_tx_throughput: @radio_tx_throughput, + radio_rx_throughput: @radio_rx_throughput, + radio_tx_capacity: @radio_tx_capacity_bps, + radio_rx_capacity: @radio_rx_capacity_bps, + radio_tx_signal: @radio_tx_signal, + radio_rx_signal: @radio_rx_signal, + radio_remote_tx_power: @radio_remote_tx_power, + radio_remote_rx_power: @radio_remote_rx_power + } + + # Get UniFi system values + unifi_system_values = + Map.new(unifi_system_oids, fn {key, oid} -> + value = + case Client.get(client_opts, oid) do + {:ok, val} -> val + {:error, reason} -> "ERROR: #{inspect(reason)}" + end + + {key, %{oid: oid, value: value}} + end) + + # Walk radio config and stats tables + radio_config_table = + case Client.walk(client_opts, @radio_config_base) do + {:ok, results} -> results + {:error, _} -> %{} + end + + radio_stats_table = + case Client.walk(client_opts, @radio_stats_base) do + {:ok, results} -> results + {:error, _} -> %{} + end + + %{ + timestamp: DateTime.utc_now(), + unifi_system_oids: unifi_system_values, + radio_config_oids: radio_config_oids, + radio_stats_oids: radio_stats_oids, + radio_config_table: radio_config_table, + radio_stats_table: radio_stats_table + } + end + # Private functions defp discover_radio_sensors(client_opts) do diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 7822921c..ad6d7af4 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -97,6 +97,22 @@ > Logs + + <%= if @snmp_device && @snmp_device.raw_discovery_data do %> + <.link + patch={~p"/devices/#{@device.id}?tab=debug"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if @active_tab == "debug" do + "border-blue-500 text-blue-600 dark:text-blue-400" + else + "border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300" + end + ]} + > + Debug + + <% end %> @@ -746,6 +762,116 @@ <% end %> + <% "debug" -> %> +
+ Raw SNMP OID values collected during the last discovery run at + <%= if @snmp_device.last_discovery_at do %> + {Calendar.strftime(@snmp_device.last_discovery_at, "%Y-%m-%d %H:%M:%S UTC")} + <% else %> + unknown time + <% end %>. +
+| + Field + | ++ OID + | ++ Value + | +
|---|---|---|
| + {key} + | ++ {data["oid"]} + | +
+ {inspect(data["value"])}
+ |
+
| + OID + | ++ Value + | +
|---|---|
| + {oid} + | +
+ {inspect(value)}
+ |
+
+ Click the text box to select all data, then copy with Cmd+C or Ctrl+C +
++ No raw discovery data available. Run discovery again to collect debug data. +
+Tab not found
diff --git a/priv/repo/migrations/20260119162749_add_mikrotik_resource_sensors.exs b/priv/repo/migrations/20260119162749_add_mikrotik_resource_sensors.exs new file mode 100644 index 00000000..28e29cee --- /dev/null +++ b/priv/repo/migrations/20260119162749_add_mikrotik_resource_sensors.exs @@ -0,0 +1,23 @@ +defmodule Towerops.Repo.Migrations.AddMikrotikResourceSensors do + use Ecto.Migration + + def up do + # Disable the database MikroTik profile to use the hardcoded profile instead + # The hardcoded profile has comprehensive sensor discovery including: + # - CPU load (per core) + # - Memory and storage (via HOST-RESOURCES-MIB walks) + # - DHCP leases + # - Connection tracking + # - POE sensors + # - Optical/SFP sensors + # + # The database profile system doesn't yet support table walks, + # so we fall back to the hardcoded profile for full discovery. + execute("UPDATE device_profiles SET enabled = false WHERE name = 'mikrotik'") + end + + def down do + # Re-enable the database profile + execute("UPDATE device_profiles SET enabled = true WHERE name = 'mikrotik'") + end +end diff --git a/priv/repo/migrations/20260119163701_add_raw_discovery_data_to_snmp_devices.exs b/priv/repo/migrations/20260119163701_add_raw_discovery_data_to_snmp_devices.exs new file mode 100644 index 00000000..c9b83071 --- /dev/null +++ b/priv/repo/migrations/20260119163701_add_raw_discovery_data_to_snmp_devices.exs @@ -0,0 +1,10 @@ +defmodule Towerops.Repo.Migrations.AddRawDiscoveryDataToSnmpDevices do + use Ecto.Migration + + def change do + alter table(:snmp_devices) do + add :raw_discovery_data, :map + add :last_discovery_at, :utc_datetime + end + end +end diff --git a/test/towerops/snmp/discovery_test.exs b/test/towerops/snmp/discovery_test.exs index 3e4fe95c..bdc7c011 100644 --- a/test/towerops/snmp/discovery_test.exs +++ b/test/towerops/snmp/discovery_test.exs @@ -51,14 +51,10 @@ defmodule Towerops.Snmp.DiscoveryTest do snmp_port: 161 }) - # Mock successful connection test - expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ -> - {:ok, {:timeticks, 12_345}} - end) - - # Mock system info discovery - expect(SnmpMock, :get, 6, fn _, oid, _ -> + # Mock all GET calls (connection test + system info + interfaces + health + network sensors) + stub(SnmpMock, :get, fn _, oid, _ -> case oid do + # Connection test + Base system info "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "RouterOS RB750"}} @@ -77,77 +73,114 @@ defmodule Towerops.Snmp.DiscoveryTest do "1.3.6.1.2.1.1.6.0" -> {:ok, {:octet_string, "Data Center"}} + # MikroTik-specific system info + "1.3.6.1.4.1.14988.1.1.7.3.0" -> + {:ok, {:octet_string, "1234567890"}} + + "1.3.6.1.4.1.14988.1.1.7.4.0" -> + {:ok, {:octet_string, "7.13.2"}} + + "1.3.6.1.4.1.14988.1.1.7.9.0" -> + {:ok, {:octet_string, "RB750Gr3"}} + + "1.3.6.1.4.1.14988.1.1.7.8.0" -> + {:ok, {:octet_string, "Main Router"}} + + "1.3.6.1.4.1.14988.1.1.7.5.0" -> + {:ok, {:integer, 6}} + + # Network sensors (MikroTik-specific) + "1.3.6.1.4.1.14988.1.1.1.3.0" -> + {:ok, {:integer, 10}} + + "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> + {:ok, {:integer, 500}} + + # Catch-all for interface data and health sensors _ -> - {:error, :no_such_object} + cond do + # Health sensor OIDs (all return error for this test) + String.starts_with?(oid, "1.3.6.1.4.1.14988.1.1.3.") and + not String.contains?(oid, "1.3.6.1.4.1.14988.1.1.13") -> + {:error, :no_such_object} + + # Interface 1 data + String.ends_with?(oid, ".1") -> + parts = String.split(oid, ".") + second_to_last = Enum.at(parts, -2) + + cond do + second_to_last == "2" -> {:ok, {:octet_string, "ether1"}} + second_to_last == "3" -> {:ok, {:integer, 6}} + second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}} + second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}} + 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, "ether1"}} + second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}} + true -> {:error, :no_such_object} + end + + # Interface 2 data + String.ends_with?(oid, ".2") -> + parts = String.split(oid, ".") + second_to_last = Enum.at(parts, -2) + + cond do + second_to_last == "2" -> {:ok, {:octet_string, "ether2"}} + second_to_last == "3" -> {:ok, {:integer, 6}} + second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}} + second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x56>>}} + 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, "ether2"}} + second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}} + true -> {:error, :no_such_object} + end + + true -> + {:error, :no_such_object} + end end end) - # Mock interface discovery - expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ -> - {:ok, - [ - %{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}}, - %{oid: "1.3.6.1.2.1.2.2.1.1.2", value: {:integer, 2}} - ]} - end) - - # Mock interface data fetches for each interface - expect(SnmpMock, :get, 16, fn _, oid, _ -> - cond do - String.ends_with?(oid, ".1") -> - parts = String.split(oid, ".") - second_to_last = Enum.at(parts, -2) - - cond do - second_to_last == "2" -> {:ok, {:octet_string, "ether1"}} - second_to_last == "3" -> {:ok, {:integer, 6}} - second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}} - second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}} - 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, "ether1"}} - second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}} - true -> {:error, :no_such_object} - end - - String.ends_with?(oid, ".2") -> - parts = String.split(oid, ".") - second_to_last = Enum.at(parts, -2) - - cond do - second_to_last == "2" -> {:ok, {:octet_string, "ether2"}} - second_to_last == "3" -> {:ok, {:integer, 6}} - second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}} - second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x56>>}} - 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, "ether2"}} - second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}} - true -> {:error, :no_such_object} - end - - true -> - {:error, :no_such_object} - end - end) - - # Note: Dynamic profile would try to discover device OIDs and sensor OIDs from database, - # but MibTranslator fails in tests (MIKROTIK-MIB not installed), so no get calls are made - - # Mock sensor discovery - MikroTik profiles make many walk calls for sensors - # CPU (1), storage (3), POE (4), optical (5), ENTITY-SENSOR-MIB (1) = 14 walks + # Mock all walk calls stub(SnmpMock, :walk, fn _, oid, _ -> - if String.starts_with?(oid, "1.3.6.1.2.1.99") do - {:ok, []} - else - {:error, :no_such_object} + cond do + # Interface discovery - return 2 interfaces + oid == "1.3.6.1.2.1.2.2.1.1" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.2.2.1.1.2", value: {:integer, 2}} + ]} + + # ENTITY-SENSOR-MIB + String.starts_with?(oid, "1.3.6.1.2.1.99") -> + {:ok, []} + + # LLDP neighbor discovery + String.starts_with?(oid, "1.0.8802") -> + {:ok, []} + + # CDP neighbor discovery + String.starts_with?(oid, "1.3.6.1.4.1.9.9.23") -> + {:ok, []} + + # MikroTik health sensor table + String.starts_with?(oid, "1.3.6.1.4.1.14988.1.1.3.100") -> + {:ok, []} + + # Other walks (debug data collection, sensors, etc.) + true -> + {:ok, []} end end) assert {:ok, device} = Discovery.discover_device(device) assert device.manufacturer == "MikroTik" - assert device.model == "RouterOS RB750" + assert device.model == "RB750" assert device.sys_name == "router1" # Verify interfaces were created @@ -172,23 +205,18 @@ defmodule Towerops.Snmp.DiscoveryTest do snmp_port: 161 }) - # Mock successful connection test - expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ -> - {:ok, {:timeticks, 54_321}} - end) - - # Mock system info discovery - expect(SnmpMock, :get, 6, fn _, oid, _ -> + # Mock all GET calls (connection test + system info + debug data) + stub(SnmpMock, :get, fn _, oid, _ -> case oid do + "1.3.6.1.2.1.1.3.0" -> + {:ok, {:timeticks, 54_321}} + "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Cisco IOS Software, C2960 Software"}} "1.3.6.1.2.1.1.2.0" -> {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 9, 1, 1208]}} - "1.3.6.1.2.1.1.3.0" -> - {:ok, {:timeticks, 54_321}} - "1.3.6.1.2.1.1.4.0" -> {:ok, {:octet_string, "netadmin@cisco.com"}} @@ -203,10 +231,8 @@ defmodule Towerops.Snmp.DiscoveryTest do end end) - # Mock interface discovery - no interfaces for simplicity - # Walk called 5 times: interfaces (1), cisco sensors (1), base sensors fallback (1), - # LLDP neighbors (1), CDP neighbors (1) - expect(SnmpMock, :walk, 5, fn _, _, _ -> + # Mock all walk calls (debug data collection, interfaces, sensors, neighbors) + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) @@ -249,14 +275,13 @@ defmodule Towerops.Snmp.DiscoveryTest do snmp_port: 161 }) - # Mock successful connection test - expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ -> - {:ok, {:timeticks, 999}} - end) - - # Mock system info discovery - expect(SnmpMock, :get, 6, fn _, oid, _ -> + # Mock all GET calls (connection + system info + debug data) + # Connection: 1, System info: 6, Debug data: 6 = 13 total + stub(SnmpMock, :get, fn _, oid, _ -> case oid do + "1.3.6.1.2.1.1.3.0" -> + {:ok, {:timeticks, 999}} + "1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Generic Device"}} @@ -265,23 +290,31 @@ defmodule Towerops.Snmp.DiscoveryTest do end end) - # Mock failed interface discovery - expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ -> - {:error, :timeout} - end) - - # Mock failed sensor discovery and neighbor discovery (LLDP + CDP) - expect(SnmpMock, :walk, 3, fn _, oid, _ -> + # Mock all walk calls in execution order: + # 1. Debug data collection happens first (inside build_device_info) + # 2. Then interface discovery + # 3. Then sensor discovery + # 4. Then neighbor discovery (LLDP + CDP) + stub(SnmpMock, :walk, fn _, oid, _ -> cond do + # Entity sensor queries (sensor discovery + debug entity_sensor_table) String.starts_with?(oid, "1.3.6.1.2.1.99") -> {:error, :timeout} + # Interface ifIndex + oid == "1.3.6.1.2.1.2.2.1.1" -> + {:error, :timeout} + + # LLDP - return empty String.starts_with?(oid, "1.0.8802") -> - # LLDP - return empty {:ok, []} + # CDP - return empty String.starts_with?(oid, "1.3.6.1.4.1.9.9.23") -> - # CDP - return empty + {:ok, []} + + # Debug data collection (if_table, ifx_table) + String.starts_with?(oid, "1.3.6.1.2.1") -> {:ok, []} true -> @@ -533,13 +566,14 @@ defmodule Towerops.Snmp.DiscoveryTest do end) # Mock interface and sensor discovery - no interfaces/sensors for simplicity - # Walk called 10 times: + # Walk called 15 times: # - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1), # voltage devices (1), voltage values (1) # - Base sensors fallback (1) # - Interfaces (1) # - Neighbors: LLDP (1), CDP (1) - expect(SnmpMock, :walk, 10, fn _, _, _ -> + # - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1) + expect(SnmpMock, :walk, 15, fn _, _, _ -> {:ok, []} end) @@ -599,13 +633,14 @@ defmodule Towerops.Snmp.DiscoveryTest do end) # Mock interface and sensor discovery - # Walk called 10 times: + # Walk called 15 times: # - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1), # voltage devices (1), voltage values (1) # - Base sensors fallback (1) # - Interfaces (1) # - Neighbors: LLDP (1), CDP (1) - expect(SnmpMock, :walk, 10, fn _, _, _ -> + # - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1) + expect(SnmpMock, :walk, 15, fn _, _, _ -> {:ok, []} end) diff --git a/test/towerops/snmp/profile_behaviour_test.exs b/test/towerops/snmp/profile_behaviour_test.exs index 9cfd17f0..b72c6d9b 100644 --- a/test/towerops/snmp/profile_behaviour_test.exs +++ b/test/towerops/snmp/profile_behaviour_test.exs @@ -60,7 +60,9 @@ defmodule Towerops.Snmp.ProfileBehaviourTest do case profile.discover_system_info(@test_opts) do {:ok, info} -> assert is_map(info) - assert map_size(info) > 0 + + # Some profiles return empty map (no additional info beyond Base) + # Others return profile-specific data (e.g., MikroTik, Ubiquiti) {:error, _reason} -> # Some profiles might fail if required data is missing diff --git a/test/towerops/snmp/profiles/cisco_test.exs b/test/towerops/snmp/profiles/cisco_test.exs index 13c9401a..e7c3b84b 100644 --- a/test/towerops/snmp/profiles/cisco_test.exs +++ b/test/towerops/snmp/profiles/cisco_test.exs @@ -11,33 +11,13 @@ defmodule Towerops.Snmp.Profiles.CiscoTest do @client_opts [ip: "192.168.1.1", community: "public", version: :v2c, port: 161] describe "discover_system_info/1" do - test "delegates to Base profile" do - # Mock the SNMP calls that Base.discover_system_info makes - # Base profile queries 6 system OIDs - match by OID string - expect(SnmpMock, :get, 6, fn _target, oid, _opts -> - case oid do - # sysDescr - "1.3.6.1.2.1.1.1.0" -> {:ok, "Cisco IOS Software"} - # sysObjectID - "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]} - # sysUpTime - "1.3.6.1.2.1.1.3.0" -> {:ok, 12_345} - # sysContact - "1.3.6.1.2.1.1.4.0" -> {:ok, "admin@example.com"} - # sysName - "1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"} - # sysLocation - "1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"} - _ -> {:error, :unknown_oid} - end - end) - + test "returns empty map (no additional system info beyond Base)" do + # Cisco profile doesn't query additional system OIDs + # Base profile handles all standard system info discovery result = Cisco.discover_system_info(@client_opts) - # Should delegate to Base and return its result assert {:ok, system_info} = result - assert is_map(system_info) - assert system_info.sys_name == "test-device" + assert system_info == %{} end end diff --git a/test/towerops/snmp/profiles/mikrotik_test.exs b/test/towerops/snmp/profiles/mikrotik_test.exs index 25bd4004..b51b71c8 100644 --- a/test/towerops/snmp/profiles/mikrotik_test.exs +++ b/test/towerops/snmp/profiles/mikrotik_test.exs @@ -143,8 +143,9 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do describe "discover_sensors/1" do test "discovers health sensors (temperature, voltage, fan)" do - # Mock health sensor OIDs - expect(SnmpMock, :get, 15, fn _, oid, _ -> + # Mock health sensor OIDs + network sensors (DHCP leases, connection count) + # Health: 15 OIDs, Network: 2 OIDs = 17 total + expect(SnmpMock, :get, 17, fn _, oid, _ -> case oid do # Voltages "1.3.6.1.4.1.14988.1.1.3.1.0" -> {:ok, {:integer, 12}} @@ -166,31 +167,84 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do "1.3.6.1.4.1.14988.1.1.3.18.0" -> {:ok, {:integer, 0}} # Processor frequency "1.3.6.1.4.1.14988.1.1.3.14.0" -> {:ok, {:integer, 1400}} + # Network sensors + "1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 42}} + "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 1500}} end end) # Mock all walk calls - use stub to allow any number of calls (code may short-circuit) stub(SnmpMock, :walk, fn _, oid, _ -> case oid do + # Health sensor table (new dynamic sensor discovery) + # SNMP adapter returns list of %{oid:, value:} maps + "1.3.6.1.4.1.14988.1.1.3.100.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.2.1", value: "CPU Temperature"}, + %{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.2.2", value: "Board Temperature"} + ]} + + "1.3.6.1.4.1.14988.1.1.3.100.1.3" -> + {:ok, + [ + %{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.3.1", value: 450}, + %{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.3.2", value: 480} + ]} + + "1.3.6.1.4.1.14988.1.1.3.100.1.4" -> + {:ok, + [ + %{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.4.1", value: 1}, + %{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.4.2", value: 1} + ]} + # CPU sensors - "1.3.6.1.2.1.25.3.3.1.2" -> {:ok, [%{oid: "1.3.6.1.2.1.25.3.3.1.2.1", value: 45}]} + "1.3.6.1.2.1.25.3.3.1.2" -> + {:ok, [%{oid: "1.3.6.1.2.1.25.3.3.1.2.1", value: 45}]} + # Storage sensors - "1.3.6.1.2.1.25.2.3.1.3" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: "RAM"}]} - "1.3.6.1.2.1.25.2.3.1.5" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: 1000}]} - "1.3.6.1.2.1.25.2.3.1.6" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: 750}]} + "1.3.6.1.2.1.25.2.3.1.3" -> + {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: "RAM"}]} + + "1.3.6.1.2.1.25.2.3.1.5" -> + {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: 1000}]} + + "1.3.6.1.2.1.25.2.3.1.6" -> + {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: 750}]} + # POE sensors (empty) - "1.3.6.1.4.1.14988.1.1.15.1.1.3" -> {:error, :no_such_object} - "1.3.6.1.4.1.14988.1.1.15.1.1.5" -> {:error, :no_such_object} - "1.3.6.1.4.1.14988.1.1.15.1.1.6" -> {:error, :no_such_object} - "1.3.6.1.4.1.14988.1.1.15.1.1.7" -> {:error, :no_such_object} + "1.3.6.1.4.1.14988.1.1.15.1.1.3" -> + {:error, :no_such_object} + + "1.3.6.1.4.1.14988.1.1.15.1.1.5" -> + {:error, :no_such_object} + + "1.3.6.1.4.1.14988.1.1.15.1.1.6" -> + {:error, :no_such_object} + + "1.3.6.1.4.1.14988.1.1.15.1.1.7" -> + {:error, :no_such_object} + # Optical sensors (empty) - "1.3.6.1.4.1.14988.1.1.16.1.1.2" -> {:error, :no_such_object} - "1.3.6.1.4.1.14988.1.1.16.1.1.3" -> {:error, :no_such_object} - "1.3.6.1.4.1.14988.1.1.16.1.1.5" -> {:error, :no_such_object} - "1.3.6.1.4.1.14988.1.1.16.1.1.7" -> {:error, :no_such_object} - "1.3.6.1.4.1.14988.1.1.16.1.1.8" -> {:error, :no_such_object} + "1.3.6.1.4.1.14988.1.1.16.1.1.2" -> + {:error, :no_such_object} + + "1.3.6.1.4.1.14988.1.1.16.1.1.3" -> + {:error, :no_such_object} + + "1.3.6.1.4.1.14988.1.1.16.1.1.5" -> + {:error, :no_such_object} + + "1.3.6.1.4.1.14988.1.1.16.1.1.7" -> + {:error, :no_such_object} + + "1.3.6.1.4.1.14988.1.1.16.1.1.8" -> + {:error, :no_such_object} + # Entity sensors from Base - "1.3.6.1.2.1.99.1.1.1.1" -> {:ok, []} + "1.3.6.1.2.1.99.1.1.1.1" -> + {:ok, []} end end) @@ -217,8 +271,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do end test "discovers CPU load sensors" do - # Mock health sensors (none available) - expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end) + # Mock health sensors (15, all return error) + network sensors (2) + expect(SnmpMock, :get, 17, fn _, oid, _ -> + case oid do + # Network sensors + "1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}} + "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}} + # All health sensors return error + _ -> {:error, :no_such_object} + end + end) # Mock all walk calls stub(SnmpMock, :walk, fn _, oid, _ -> @@ -255,8 +317,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do end test "discovers storage sensors with proper status" do - # Mock health sensors (none) - expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end) + # Mock health sensors (15, all return error) + network sensors (2) + expect(SnmpMock, :get, 17, fn _, oid, _ -> + case oid do + # Network sensors + "1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}} + "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}} + # All health sensors return error + _ -> {:error, :no_such_object} + end + end) # Mock all walk calls stub(SnmpMock, :walk, fn _, oid, _ -> @@ -307,12 +377,30 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do end test "discovers POE sensors per port" do - # Mock health, cpu, storage as empty - expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end) + # Mock health sensors (15, all return error) + network sensors (2) + expect(SnmpMock, :get, 17, fn _, oid, _ -> + case oid do + # Network sensors + "1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}} + "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}} + # All health sensors return error + _ -> {:error, :no_such_object} + end + end) # Mock all walk calls stub(SnmpMock, :walk, fn _, oid, _ -> case oid do + # Health sensor table (checked before POE) + "1.3.6.1.4.1.14988.1.1.3.100.1.2" -> + {:ok, []} + + "1.3.6.1.4.1.14988.1.1.3.100.1.3" -> + {:ok, []} + + "1.3.6.1.4.1.14988.1.1.3.100.1.4" -> + {:ok, []} + # CPU sensors "1.3.6.1.2.1.25.3.3.1.2" -> {:ok, []} @@ -385,7 +473,7 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do assert result.manufacturer == "MikroTik" assert result.model == "RB5009UG+S+" - assert result.firmware_version == "Mikrotik RouterOS 7.13.2" + assert result.firmware_version == "RouterOS 7.13.2" end test "identifies generic RouterOS device when model not found" do @@ -426,7 +514,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do describe "sensor status determination" do test "CPU status changes with load" do - expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end) + # Mock health sensors (15, all return error) + network sensors (2) + expect(SnmpMock, :get, 17, fn _, oid, _ -> + case oid do + # Network sensors + "1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}} + "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}} + # All health sensors return error + _ -> {:error, :no_such_object} + end + end) stub(SnmpMock, :walk, fn _, oid, _ -> case oid do @@ -455,7 +552,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do end test "memory status changes with usage percentage" do - expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end) + # Mock health sensors (15, all return error) + network sensors (2) + expect(SnmpMock, :get, 17, fn _, oid, _ -> + case oid do + # Network sensors + "1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}} + "1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}} + # All health sensors return error + _ -> {:error, :no_such_object} + end + end) stub(SnmpMock, :walk, fn _, oid, _ -> case oid do diff --git a/test/towerops/snmp/profiles/net_snmp_test.exs b/test/towerops/snmp/profiles/net_snmp_test.exs index 4e737fc8..42a28bcf 100644 --- a/test/towerops/snmp/profiles/net_snmp_test.exs +++ b/test/towerops/snmp/profiles/net_snmp_test.exs @@ -11,26 +11,13 @@ defmodule Towerops.Snmp.Profiles.NetSnmpTest do @client_opts [ip: "192.168.1.100", community: "public", version: :v2c, port: 161] describe "discover_system_info/1" do - test "delegates to Base profile" do - # Mock the SNMP calls that Base.discover_system_info makes - # Base profile queries 6 system OIDs - match by OID string - expect(SnmpMock, :get, 6, fn _target, oid, _opts -> - case oid do - "1.3.6.1.2.1.1.1.0" -> {:ok, "Linux ubuntu 5.4.0-42-generic #46-Ubuntu"} - "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 8072]} - "1.3.6.1.2.1.1.3.0" -> {:ok, 123_456} - "1.3.6.1.2.1.1.4.0" -> {:ok, "root@localhost"} - "1.3.6.1.2.1.1.5.0" -> {:ok, "ubuntu-server"} - "1.3.6.1.2.1.1.6.0" -> {:ok, "Data Center"} - _ -> {:error, :unknown_oid} - end - end) - + test "returns empty map (no additional system info beyond Base)" do + # NetSnmp profile doesn't query additional system OIDs + # Base profile handles all standard system info discovery result = NetSnmp.discover_system_info(@client_opts) assert {:ok, system_info} = result - assert is_map(system_info) - assert system_info.sys_name == "ubuntu-server" + assert system_info == %{} end end