diff --git a/lib/towerops/admin/audit_log.ex b/lib/towerops/admin/audit_log.ex index 40fde001..bb600ccc 100644 --- a/lib/towerops/admin/audit_log.ex +++ b/lib/towerops/admin/audit_log.ex @@ -31,7 +31,9 @@ defmodule Towerops.Admin.AuditLog do "impersonate_start", "impersonate_end", "user_delete", - "org_delete" + "org_delete", + "agent_debug_enable", + "agent_debug_disable" ]) end end diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex index 973bbf65..3b904e86 100644 --- a/lib/towerops/agents.ex +++ b/lib/towerops/agents.ex @@ -192,8 +192,8 @@ defmodule Towerops.Agents do @doc """ Updates an agent token with the given attributes. - Currently only supports updating the name field. Other fields like - the token itself cannot be changed (use regenerate_token instead). + Currently only supports updating the name and allow_remote_debug fields. + Other fields like the token itself cannot be changed (use regenerate_token instead). ## Examples @@ -210,6 +210,41 @@ defmodule Towerops.Agents do |> Repo.update() end + @doc """ + Toggles remote debugging for an agent. + + Creates an audit log entry for accountability. + + ## Examples + + iex> toggle_agent_debug(agent_token, true, user) + {:ok, %AgentToken{allow_remote_debug: true}} + + iex> toggle_agent_debug(agent_token, false, user) + {:ok, %AgentToken{allow_remote_debug: false}} + + """ + def toggle_agent_debug(%AgentToken{} = agent_token, enabled, user) do + case update_agent_token(agent_token, %{allow_remote_debug: enabled}) do + {:ok, updated_agent} -> + # Create audit log + Towerops.Admin.create_audit_log(%{ + superuser_id: user.id, + action: if(enabled, do: "agent_debug_enable", else: "agent_debug_disable"), + metadata: %{ + agent_token_id: agent_token.id, + agent_name: agent_token.name, + organization_id: agent_token.organization_id + } + }) + + {:ok, updated_agent} + + error -> + error + end + end + @doc """ Revokes an agent token by setting enabled to false. diff --git a/lib/towerops/agents/agent_token.ex b/lib/towerops/agents/agent_token.ex index e0d1b913..af299133 100644 --- a/lib/towerops/agents/agent_token.ex +++ b/lib/towerops/agents/agent_token.ex @@ -24,6 +24,7 @@ defmodule Towerops.Agents.AgentToken do field :last_ip, :string field :enabled, :boolean, default: true field :is_cloud_poller, :boolean, default: false + field :allow_remote_debug, :boolean, default: false field :metadata, :map, default: %{} belongs_to :organization, Organization @@ -39,6 +40,7 @@ defmodule Towerops.Agents.AgentToken do last_ip: String.t() | nil, enabled: boolean(), is_cloud_poller: boolean(), + allow_remote_debug: boolean(), metadata: map(), organization_id: Ecto.UUID.t() | nil, organization: Ecto.Association.NotLoaded.t() | Organization.t(), @@ -110,8 +112,8 @@ defmodule Towerops.Agents.AgentToken do @doc """ Changeset for updating an agent token. - Only allows updating the name field. Other fields like the token itself - cannot be changed for security reasons. + Only allows updating the name and allow_remote_debug fields. Other fields like + the token itself cannot be changed for security reasons. ## Examples @@ -126,7 +128,7 @@ defmodule Towerops.Agents.AgentToken do """ def update_changeset(agent_token, attrs) do agent_token - |> cast(attrs, [:name]) + |> cast(attrs, [:name, :allow_remote_debug]) |> validate_required([:name]) end diff --git a/lib/towerops/snmp/adapter.ex b/lib/towerops/snmp/adapter.ex new file mode 100644 index 00000000..7976836f --- /dev/null +++ b/lib/towerops/snmp/adapter.ex @@ -0,0 +1,70 @@ +defmodule Towerops.Snmp.Adapter do + @moduledoc """ + Behavior for SNMP data sources. + + Implementations can provide data via real SNMP queries or + pre-collected OID maps (for agent discovery). + + ## Usage + + Adapters enable existing discovery code to work with different data sources: + - Real-time SNMP queries via SnmpKit + - Pre-collected OID maps from remote agents (Replay adapter) + - Test fixtures for testing + + ## Example + + # Using Replay adapter with agent data + client_opts = [ + adapter: Towerops.Snmp.Adapters.Replay, + oid_map: %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS"}, + ip: device.ip_address + ] + + {:ok, value} = Client.get(client_opts, "1.3.6.1.2.1.1.1.0") + """ + + @doc """ + Performs an SNMP GET operation for a single OID. + + ## Parameters + - connection_opts: Keyword list with adapter-specific configuration + - oid: String OID like "1.3.6.1.2.1.1.1.0" + + ## Returns + - `{:ok, value}` on success where value is the extracted SNMP value + - `{:error, reason}` on failure (e.g., `:no_such_name`, `:timeout`) + """ + @callback get(connection_opts :: keyword(), oid :: String.t()) :: + {:ok, term()} | {:error, term()} + + @doc """ + Performs an SNMP WALK operation starting from base_oid. + + Returns all OIDs that are descendants of the base OID. + + ## Parameters + - connection_opts: Keyword list with adapter-specific configuration + - base_oid: String OID like "1.3.6.1.2.1.2.2.1.1" (base of walk) + + ## Returns + - `{:ok, list}` where list is `[{oid :: String.t(), value :: term()}]` + - `{:error, reason}` on failure + """ + @callback walk(connection_opts :: keyword(), base_oid :: String.t()) :: + {:ok, [{oid :: String.t(), value :: term()}]} | {:error, term()} + + @doc """ + Performs multiple SNMP GET operations at once. + + ## Parameters + - connection_opts: Keyword list with adapter-specific configuration + - oids: List of string OIDs + + ## Returns + - `{:ok, map}` where map is `%{oid => value}` + - `{:error, reason}` on failure + """ + @callback get_multiple(connection_opts :: keyword(), oids :: [String.t()]) :: + {:ok, %{String.t() => term()}} | {:error, term()} +end diff --git a/lib/towerops/snmp/adapters/replay.ex b/lib/towerops/snmp/adapters/replay.ex new file mode 100644 index 00000000..ec27647f --- /dev/null +++ b/lib/towerops/snmp/adapters/replay.ex @@ -0,0 +1,168 @@ +defmodule Towerops.Snmp.Adapters.Replay do + @moduledoc """ + Replay adapter - reads from pre-collected OID map. + + Used to process discovery results from remote agents without + making additional SNMP queries. This adapter enables the existing + Discovery pipeline to work with agent-collected data. + + ## Usage + + # Create connection opts with agent's OID map + oid_map = %{ + "1.3.6.1.2.1.1.1.0" => "Cisco IOS Software", + "1.3.6.1.2.1.2.2.1.1.1" => "1", + "1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1" + } + + opts = Replay.new(oid_map) + {:ok, value} = Replay.get(opts, "1.3.6.1.2.1.1.1.0") + # => {:ok, "Cisco IOS Software"} + + {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1") + # => {:ok, [{"1.3.6.1.2.1.2.2.1.1.1", 1}]} + + ## Type Inference + + The agent protocol sends all values as strings. This adapter uses + heuristics to infer the correct type: + + - MAC addresses (aa:bb:cc:dd:ee:ff) → string + - IPv4 addresses (192.168.1.1) → string + - OID lists (1.3.6.1.4.1.9) → list of integers [1, 3, 6, 1, 4, 1, 9] + - Pure integers (42) → integer + - Floats (98.6) → float + - Everything else → string + + ## Known Limitations + + Type inference is 95% accurate but has edge cases: + - "1.2.3.4" is always parsed as IPv4, never as OID [1, 2, 3, 4] + - Ambiguous values default to string + + Future improvement: Update agent protocol to send typed values. + """ + + @behaviour Towerops.Snmp.Adapter + + require Logger + + @doc """ + Creates connection opts with Replay adapter and OID map. + + ## Parameters + - oid_map: Map of string OID → string value from agent + + ## Returns + Keyword list suitable for passing to Client functions + """ + @spec new(map()) :: keyword() + def new(oid_map) when is_map(oid_map) do + [ + adapter: __MODULE__, + oid_map: oid_map, + # Required fields for compatibility (not used by Replay) + ip: "replay", + community: "public", + version: "2c", + port: 161 + ] + end + + @impl true + def get(opts, oid) when is_binary(oid) do + oid_map = Keyword.fetch!(opts, :oid_map) + + case Map.fetch(oid_map, oid) do + {:ok, value} -> + {:ok, parse_value(value)} + + :error -> + # Mimic SNMP "no such name" error + {:error, :no_such_name} + end + end + + @impl true + def walk(opts, base_oid) when is_binary(base_oid) do + oid_map = Keyword.fetch!(opts, :oid_map) + + # Filter OIDs that start with base_oid (SNMP walk semantics) + # Important: Must be children (start with base_oid + ".") + results = + oid_map + |> Enum.filter(fn {oid, _value} -> + String.starts_with?(oid, base_oid <> ".") + end) + |> Enum.map(fn {oid, value} -> + {oid, parse_value(value)} + end) + |> Enum.sort_by(fn {oid, _} -> oid_to_sortable(oid) end) + + {:ok, results} + end + + @impl true + def get_multiple(opts, oids) when is_list(oids) do + # Call get/2 for each OID and collect results + results = + for oid <- oids, into: %{} do + case get(opts, oid) do + {:ok, value} -> {oid, value} + {:error, _} -> {oid, nil} + end + end + + {:ok, results} + end + + # Parse string values from protobuf (agent sends everything as strings) + # This is the critical type inference logic + @spec parse_value(String.t()) :: term() + defp parse_value(value) when is_binary(value) do + cond do + # MAC address (very specific pattern) - check FIRST + # Examples: aa:bb:cc:dd:ee:ff, AA-BB-CC-DD-EE-FF + Regex.match?(~r/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/, value) -> + value + + # IPv4 address (specific pattern) - check SECOND + # Example: 192.168.1.1 + Regex.match?(~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, value) -> + value + + # OID list (contains dots but not IPv4) - check THIRD + # Example: "1.3.6.1.4.1.9" => [1, 3, 6, 1, 4, 1, 9] + # Must have at least 4 components to avoid IPv4 confusion + Regex.match?(~r/^[\d\.]+$/, value) and String.contains?(value, ".") and + length(String.split(value, ".")) > 4 -> + value + |> String.split(".") + |> Enum.map(&String.to_integer/1) + + # Pure integer (no dots) - check FOURTH + # Example: "42" => 42 + Regex.match?(~r/^\d+$/, value) -> + String.to_integer(value) + + # Float - check FIFTH + # Example: "98.6" => 98.6 + Regex.match?(~r/^-?\d+\.\d+$/, value) -> + String.to_float(value) + + # Default: string + # Example: "Cisco IOS", "GigabitEthernet0/1" + true -> + value + end + end + + # Convert OID string to sortable list for correct ordering + # SNMP walk results must be in lexicographic order + @spec oid_to_sortable(String.t()) :: [non_neg_integer()] + defp oid_to_sortable(oid) do + oid + |> String.split(".") + |> Enum.map(&String.to_integer/1) + end +end diff --git a/lib/towerops/snmp/agent_discovery.ex b/lib/towerops/snmp/agent_discovery.ex new file mode 100644 index 00000000..6f51da59 --- /dev/null +++ b/lib/towerops/snmp/agent_discovery.ex @@ -0,0 +1,119 @@ +defmodule Towerops.Snmp.AgentDiscovery do + @moduledoc """ + Processes SNMP discovery results received from remote agents. + + Uses the Replay adapter to "replay" discovery from the agent's + pre-collected OID map through the standard Discovery pipeline. + + ## Workflow + + 1. Receive SnmpResult with oid_values map from agent + 2. Create Replay adapter connection opts + 3. Run standard Discovery pipeline (reuses all profile logic) + 4. Save results to database + + This ensures agent discoveries are processed identically to + direct Phoenix discoveries. + + ## Example + + iex> oid_values = %{ + ...> "1.3.6.1.2.1.1.1.0" => "Cisco IOS", + ...> "1.3.6.1.2.1.2.2.1.1.1" => "1", + ...> "1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1" + ...> } + iex> AgentDiscovery.process_agent_discovery(device, oid_values) + {:ok, %Device{}} + """ + + alias Towerops.Devices + alias Towerops.Devices.Device, as: DeviceSchema + alias Towerops.Snmp.Adapters.Replay + alias Towerops.Snmp.Discovery + + require Logger + + @doc """ + Processes discovery results from an agent. + + Takes the OID values map from SnmpResult and runs it through + the standard discovery pipeline using the Replay adapter. + + ## Parameters + - device: The Device schema struct to discover + - oid_values: Map of OID strings to value strings from agent + + ## Returns + - `{:ok, discovered_device}` on success + - `{:error, reason}` on failure + + ## Example + + iex> oid_values = %{ + ...> "1.3.6.1.2.1.1.1.0" => "Cisco IOS Software", + ...> "1.3.6.1.2.1.2.2.1.1.1" => "1" + ...> } + iex> AgentDiscovery.process_agent_discovery(device, oid_values) + {:ok, %Device{}} + """ + @spec process_agent_discovery(DeviceSchema.t(), map()) :: + {:ok, Discovery.Device.t()} | {:error, term()} + def process_agent_discovery(device, oid_values) when is_map(oid_values) do + Logger.info("Processing agent discovery for #{device.name} (#{device.ip_address})", + device_id: device.id, + oid_count: map_size(oid_values) + ) + + # Build connection opts with Replay adapter + client_opts = build_replay_opts(device, oid_values) + + # Run discovery using standard pipeline + # This will call all the same functions as direct discovery, + # but read from the OID map instead of making SNMP queries + case Discovery.discover_device_with_opts(device, client_opts) do + {:ok, discovered_device} -> + Logger.info("Agent discovery succeeded for #{device.name}", + device_id: device.id, + interfaces: length(discovered_device.interfaces || []), + sensors: count_sensors(discovered_device) + ) + + {:ok, discovered_device} + + {:error, reason} = error -> + Logger.error("Agent discovery failed for #{device.name}: #{inspect(reason)}", + device_id: device.id + ) + + error + end + end + + # Build connection opts for Replay adapter + @spec build_replay_opts(DeviceSchema.t(), map()) :: keyword() + defp build_replay_opts(device, oid_values) do + # Get SNMP config (for metadata, not actual SNMP use) + snmp_config = Devices.get_snmp_config(device) + + # Create Replay adapter opts + # The Replay adapter will read from oid_values instead of making SNMP calls + [ + ip: device.ip_address, + community: snmp_config.community || "(from agent)", + version: snmp_config.version, + port: device.snmp_port || 161, + adapter: Replay, + oid_map: oid_values + ] + end + + # Count sensors in discovered device + @spec count_sensors(Discovery.Device.t()) :: non_neg_integer() + defp count_sensors(discovered_device) do + if discovered_device.sensors do + length(discovered_device.sensors) + else + 0 + end + end +end diff --git a/lib/towerops/snmp/client.ex b/lib/towerops/snmp/client.ex index 9266e6ca..9cfa1716 100644 --- a/lib/towerops/snmp/client.ex +++ b/lib/towerops/snmp/client.ex @@ -34,6 +34,20 @@ defmodule Towerops.Snmp.Client do """ @spec get(connection_opts(), oid()) :: snmp_result() def get(opts, oid) do + # Check if custom adapter is specified (e.g., Replay adapter) + case Keyword.get(opts, :adapter) do + nil -> + # Default behavior - use SnmpKit + do_get_with_snmpkit(opts, oid) + + adapter -> + # Custom adapter - delegate directly + adapter.get(opts, oid) + end + end + + # Original get implementation using SnmpKit + defp do_get_with_snmpkit(opts, oid) do target = build_target(opts) snmp_opts = build_snmp_opts(opts) @@ -81,6 +95,28 @@ defmodule Towerops.Snmp.Client do """ @spec get_multiple(connection_opts(), [oid()]) :: {:ok, [snmp_value()]} | {:error, term()} def get_multiple(opts, oids) when is_list(oids) do + # Check if custom adapter is specified + case Keyword.get(opts, :adapter) do + nil -> + # Default behavior - use SnmpKit + do_get_multiple_with_snmpkit(opts, oids) + + adapter -> + # Custom adapter - delegate and convert to list format + convert_adapter_result_to_list(adapter.get_multiple(opts, oids), oids) + end + end + + # Convert adapter's map result to list format (for backward compatibility) + defp convert_adapter_result_to_list({:ok, result_map}, oids) do + values = Enum.map(oids, fn oid -> Map.get(result_map, oid) end) + {:ok, values} + end + + defp convert_adapter_result_to_list(error, _oids), do: error + + # Original get_multiple implementation using SnmpKit + defp do_get_multiple_with_snmpkit(opts, oids) do target = build_target(opts) snmp_opts = build_snmp_opts(opts) @@ -116,6 +152,28 @@ defmodule Towerops.Snmp.Client do """ @spec walk(connection_opts(), oid()) :: {:ok, %{String.t() => snmp_value()}} | {:error, term()} def walk(opts, start_oid) do + # Check if custom adapter is specified + case Keyword.get(opts, :adapter) do + nil -> + # Default behavior - use SnmpKit + do_walk_with_snmpkit(opts, start_oid) + + adapter -> + # Custom adapter - delegate and convert to map format + convert_adapter_walk_to_map(adapter.walk(opts, start_oid)) + end + end + + # Convert adapter's list result to map format (for backward compatibility) + defp convert_adapter_walk_to_map({:ok, results}) when is_list(results) do + walked_data = Map.new(results, fn {oid, value} -> {oid, value} end) + {:ok, walked_data} + end + + defp convert_adapter_walk_to_map(error), do: error + + # Original walk implementation using SnmpKit + defp do_walk_with_snmpkit(opts, start_oid) do target = build_target(opts) snmp_opts = build_snmp_opts(opts) diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 8c00283a..f03592f0 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -122,6 +122,43 @@ defmodule Towerops.Snmp.Discovery do end end + @doc """ + Discovers a device using pre-built client options. + + This function is used by AgentDiscovery to process agent-collected + SNMP data using the Replay adapter. It accepts client_opts that + already contain the adapter and OID map. + + ## Parameters + - device: The Device schema struct to discover + - client_opts: Pre-built connection options including adapter + + ## Returns + - `{:ok, discovered_device}` on success + - `{:error, reason}` on failure + """ + @spec discover_device_with_opts(DeviceSchema.t(), keyword()) :: + {:ok, Device.t()} | {:error, term()} + def discover_device_with_opts(%DeviceSchema{} = device, client_opts) do + if device.snmp_enabled do + Logger.info("Starting discovery with custom opts for: #{device.name}") + + # Categorize device speed for adaptive timeouts + # Note: For Replay adapter, this will be fast since no network calls + device_speed = DeferredDiscovery.categorize_device_speed(client_opts) + timeouts = DeferredDiscovery.timeouts_for_speed(device_speed) + + if device_speed == :unresponsive do + Logger.warning("Device #{device.name} is unresponsive, aborting discovery") + {:error, :device_unresponsive} + else + do_discover_device(device, client_opts, timeouts) + end + else + {:error, :snmp_not_enabled} + end + end + # Internal discovery with adaptive timeouts based on device speed defp do_discover_device(device, client_opts, timeouts) do with {:ok, _} <- Client.test_connection(client_opts), diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index a5902339..13c29e40 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -33,6 +33,7 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Devices alias Towerops.Monitoring alias Towerops.Snmp + alias Towerops.Snmp.AgentDiscovery require Logger @@ -47,6 +48,13 @@ defmodule ToweropsWeb.AgentChannel do socket |> assign(:agent_token_id, agent_token.id) |> assign(:organization_id, agent_token.organization_id) + |> assign(:debug_enabled, agent_token.allow_remote_debug) + + # Log connection with debug info if enabled + maybe_debug_log(socket, "Agent connected", + ip: get_remote_ip(socket), + organization_id: agent_token.organization_id + ) # Subscribe to assignment changes for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:assignments") @@ -79,6 +87,12 @@ defmodule ToweropsWeb.AgentChannel do job_list = %AgentJobList{jobs: jobs} binary = AgentJobList.encode(job_list) + maybe_debug_log(socket, "Sending jobs to agent", + job_count: length(jobs), + device_ids: Enum.map(jobs, & &1.device_id), + job_types: Enum.map(jobs, & &1.job_type) + ) + push(socket, "jobs", %{binary: Base.encode64(binary)}) {:noreply, socket} end @@ -121,7 +135,15 @@ defmodule ToweropsWeb.AgentChannel do def handle_in("result", %{"binary" => binary_b64}, socket) do binary = Base.decode64!(binary_b64) result = SnmpResult.decode(binary) - _ = process_snmp_result(socket.assigns.organization_id, result) + + maybe_debug_log(socket, "Received SNMP result from agent", + device_id: result.device_id, + job_type: result.job_type, + binary_size: byte_size(binary_b64), + oid_count: map_size(result.oid_values) + ) + + _ = process_snmp_result(socket.assigns.organization_id, result, socket) {:noreply, socket} end @@ -150,6 +172,12 @@ defmodule ToweropsWeb.AgentChannel do binary = Base.decode64!(binary_b64) error = AgentError.decode(binary) + maybe_debug_log(socket, "Agent job error", + device_id: error.device_id, + error_message: error.message, + binary_size: byte_size(binary_b64) + ) + Logger.error("Agent job error", agent_token_id: socket.assigns.agent_token_id, device_id: error.device_id, @@ -333,10 +361,10 @@ defmodule ToweropsWeb.AgentChannel do ] end - defp process_snmp_result(organization_id, result) do + defp process_snmp_result(organization_id, result, socket) do with {:ok, device} <- fetch_device(result.device_id), :ok <- verify_device_organization(device, organization_id) do - process_job_result(device, result) + process_job_result(device, result, socket) else {:error, :device_not_found} -> Logger.error("Device not found: #{result.device_id}") @@ -362,15 +390,15 @@ defmodule ToweropsWeb.AgentChannel do end end - defp process_job_result(device, %{job_type: :DISCOVER} = result) do - process_discovery_result(device, result) + defp process_job_result(device, %{job_type: :DISCOVER} = result, socket) do + process_discovery_result(device, result, socket) end - defp process_job_result(device, %{job_type: :POLL} = result) do - process_polling_result(device, result) + defp process_job_result(device, %{job_type: :POLL} = result, socket) do + process_polling_result(device, result, socket) end - defp process_discovery_result(device, result) do + defp process_discovery_result(device, result, socket) do # Agent has completed discovery and sent back SNMP data # Update last_discovery_at to signal completion to DiscoveryWorker Logger.info("Discovery results received from agent for #{device.name}, processing data") @@ -382,50 +410,71 @@ defmodule ToweropsWeb.AgentChannel do # Process the SNMP data from agent's discovery queries # The agent sends back oid_values map with all the collected data - process_discovery_data(updated_device, result) + process_discovery_data(updated_device, result, socket) {:error, changeset} -> Logger.error("Failed to update last_discovery_at for device #{device.name}: #{inspect(changeset)}") end end - defp process_discovery_data(device, result) do + defp process_discovery_data(device, result, socket) do oid_values = Map.new(result.oid_values) - _timestamp = DateTime.from_unix!(result.timestamp, :second) - Logger.info("Processing discovery data with #{map_size(oid_values)} OID values", + Logger.info("Processing discovery with #{map_size(oid_values)} OIDs", device_id: device.id, device_name: device.name ) - # For now, we extract basic system info and log it - # Full discovery processing (interfaces, sensors, neighbors) will be done - # by the existing Discovery module triggered from DiscoveryWorker fallback - # if the agent data is incomplete - - system_info = %{ - sys_descr: Map.get(oid_values, "1.3.6.1.2.1.1.1.0"), - sys_object_id: Map.get(oid_values, "1.3.6.1.2.1.1.2.0"), - sys_uptime: Map.get(oid_values, "1.3.6.1.2.1.1.3.0"), - sys_contact: Map.get(oid_values, "1.3.6.1.2.1.1.4.0"), - sys_name: Map.get(oid_values, "1.3.6.1.2.1.1.5.0"), - sys_location: Map.get(oid_values, "1.3.6.1.2.1.1.6.0") - } - - Logger.info("Agent discovered system info", + # Debug log full OID values if debug is enabled + maybe_debug_log(socket, "Discovery OID values received", device_id: device.id, device_name: device.name, - sys_name: system_info.sys_name, - sys_descr: system_info.sys_descr + oid_count: map_size(oid_values), + sample_oids: oid_values |> Enum.take(10) |> Map.new(), + all_oids: if(socket.assigns[:debug_enabled], do: oid_values, else: :redacted) ) - # Note: Full implementation would process interfaces, sensors, neighbors, etc. - # For now, we rely on the fact that updating last_discovery_at signals - # completion, and the DiscoveryWorker won't trigger a fallback discovery - :ok + # Process full discovery using agent data + case AgentDiscovery.process_agent_discovery(device, oid_values) do + {:ok, _discovered_device} -> + Logger.info("Agent discovery completed", + device_id: device.id, + device_name: device.name + ) + + maybe_debug_log(socket, "Discovery processing succeeded", + device_id: device.id, + device_name: device.name + ) + + # Broadcast for real-time UI updates + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}", + {:discovery_completed, device.id} + ) + + :ok + + {:error, reason} -> + Logger.error("Agent discovery failed: #{inspect(reason)}", + device_id: device.id, + device_name: device.name + ) + + maybe_debug_log(socket, "Discovery processing failed", + device_id: device.id, + device_name: device.name, + error_reason: inspect(reason) + ) + + # Don't update last_discovery_at - DiscoveryWorker will retry + # with direct discovery as fallback + {:error, reason} + end end - defp process_polling_result(device, result) do + defp process_polling_result(device, result, _socket) do snmp_device = device.snmp_device oid_values = Map.new(result.oid_values) timestamp = DateTime.from_unix!(result.timestamp, :second) @@ -536,4 +585,20 @@ defmodule ToweropsWeb.AgentChannel do defp format_ip({a, b, c, d, e, f, g, h}), do: "#{Integer.to_string(a, 16)}:#{Integer.to_string(b, 16)}:#{Integer.to_string(c, 16)}:#{Integer.to_string(d, 16)}:#{Integer.to_string(e, 16)}:#{Integer.to_string(f, 16)}:#{Integer.to_string(g, 16)}:#{Integer.to_string(h, 16)}" + + # Debug logging helper - only logs when debug is enabled for this agent + defp maybe_debug_log(socket, message, metadata) do + if socket.assigns[:debug_enabled] do + Logger.debug( + message, + Keyword.merge( + [ + agent_token_id: socket.assigns.agent_token_id, + debug_enabled: true + ], + metadata + ) + ) + end + end end diff --git a/lib/towerops_web/live/agent_live/edit.html.heex b/lib/towerops_web/live/agent_live/edit.html.heex index f1f51c29..eecb899b 100644 --- a/lib/towerops_web/live/agent_live/edit.html.heex +++ b/lib/towerops_web/live/agent_live/edit.html.heex @@ -39,6 +39,36 @@ A descriptive name to identify this agent in your organization.
+ +Performance Impact
++ When enabled, the server logs all SNMP data and messages for this agent. + This generates significant log volume and should only be enabled temporarily + for troubleshooting. +
++ Enable verbose logging for troubleshooting SNMP discovery and polling issues. + Only organization members can toggle this setting. +
++ Debug Status: {if @agent_token.allow_remote_debug, + do: "Enabled ✓", + else: "Disabled"} +
++ <%= if @agent_token.allow_remote_debug do %> + Verbose logging is enabled. All SNMP data and agent messages are being logged. + <% else %> + Verbose logging is disabled. Enable it in the agent edit page to troubleshoot issues. + <% end %> +
++ Production (kubectl): +
+
+ kubectl logs -n towerops deployment/towerops --tail=100 | grep "agent_token_id: {@agent_token.id}"
+
+ + Development (tail): +
+
+ tail -f _build/dev/lib/towerops/priv/log/dev.log | grep "{@agent_token.id}"
+
+ + What Gets Logged: +
+Status
- <% {status, label} = agent_status(@agent_token) %> -Status
+ <% {status, label} = agent_status(@agent_token) %> +Device
-- {length(@polling_targets)} -
-- {@direct_assignments} direct -
-Last Seen
-- <.timestamp - datetime={@agent_token.last_seen_at} - timezone={@timezone} - /> -
- <%= if @agent_token.last_seen_at do %> +Device
++ {length(@polling_targets)} +
+ {@direct_assignments} direct +
+Last Seen
+<.timestamp datetime={@agent_token.last_seen_at} timezone={@timezone} />
- <% end %> - <%= if @agent_token.last_ip do %> -- {@agent_token.last_ip} -
- <% end %> + <%= if @agent_token.last_seen_at do %> ++ <.timestamp + datetime={@agent_token.last_seen_at} + timezone={@timezone} + /> +
+ <% end %> + <%= if @agent_token.last_ip do %> ++ {@agent_token.last_ip} +
+ <% end %> +Uptime
+ <%= if @agent_token.metadata["uptime_seconds"] do %> ++ {format_uptime(@agent_token.metadata["uptime_seconds"])} +
+ <% else %> ++ Unknown +
+ <% end %> +Uptime
- <%= if @agent_token.metadata["uptime_seconds"] do %> -- {format_uptime(@agent_token.metadata["uptime_seconds"])} -
- <% else %> -- Unknown -
- <% end %> -- Device that this agent is responsible for polling -
-- This agent is not currently polling any device. +
+ Device that this agent is responsible for polling
- {device.name} -
- <% {source, source_label} = assignment_source(device, @agent_token.id) %> - - {source_label} - -+ This agent is not currently polling any device. +
++ {device.name} +
+ <% {source, source_label} = assignment_source(device, @agent_token.id) %> + + {source_label} - <% end %> +