defmodule ToweropsWeb.AgentChannel do @moduledoc """ Phoenix channel for bidirectional agent communication. Handles WebSocket connections from remote SNMP polling agents, distributing polling jobs and collecting metrics via Protocol Buffers binary encoding. ## Messages from Server → Agent (binary protobuf) - "jobs" - List of SNMP jobs to execute - "job" - Single new job (pushed when device added) ## Messages from Agent → Server (binary protobuf) - "result" - SNMP query results - "heartbeat" - Agent metadata (version, hostname, uptime) - "error" - Job execution errors All messages use Protocol Buffers binary encoding for efficiency. """ use ToweropsWeb, :channel alias Towerops.Agent.AgentError alias Towerops.Agent.AgentHeartbeat alias Towerops.Agent.AgentJob alias Towerops.Agent.AgentJobList alias Towerops.Agent.CredentialTestResult alias Towerops.Agent.MikrotikCommand alias Towerops.Agent.MikrotikDevice alias Towerops.Agent.MikrotikResult alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpQuery alias Towerops.Agent.SnmpResult alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.BackupRequests alias Towerops.Devices.MikrotikBackups alias Towerops.Snmp alias Towerops.Snmp.AgentDiscovery require Logger @impl true @spec join(String.t(), map(), Phoenix.Socket.t()) :: {:ok, Phoenix.Socket.t()} | {:error, map()} def join("agent:" <> agent_id, %{"token" => token} = payload, socket) do Logger.info("Agent join attempt", topic: "agent:#{agent_id}", has_token: not is_nil(token), token_length: if(is_binary(token), do: byte_size(token), else: 0), payload_keys: Map.keys(payload) ) # Verify agent token from join payload case Agents.verify_agent_token(token) do {:ok, agent_token} -> socket = 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") # Subscribe to discovery requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:discovery") # Subscribe to backup requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:backup") # Subscribe to credential test requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:credential_test") # Subscribe to live poll requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:live_poll") # Update last_seen_at and IP on join remote_ip = get_remote_ip(socket) _ = Agents.update_agent_token_heartbeat(agent_token.id, remote_ip, %{}) # Send initial job list send(self(), :send_jobs) {:ok, socket} {:error, _} -> {:error, %{reason: "unauthorized"}} end end def join("agent:" <> _agent_id, _payload, _socket) do {:error, %{reason: "missing token"}} end @impl true @spec handle_info(:send_jobs, Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} def handle_info(:send_jobs, socket) do jobs = build_jobs_for_agent(socket.assigns.agent_token_id) job_list = %AgentJobList{jobs: jobs} binary = AgentJobList.encode(job_list) Logger.info("Sending #{length(jobs)} jobs to agent: #{inspect(Enum.map(jobs, & &1.job_id))}") push(socket, "jobs", %{binary: Base.encode64(binary)}) {:noreply, socket} end # Handle PubSub broadcast when device assignments change def handle_info({:assignments_changed, _event}, socket) do Logger.info("Agent assignments changed, sending updated job list", agent_token_id: socket.assigns.agent_token_id ) send(self(), :send_jobs) {:noreply, socket} end # Handle PubSub broadcast when discovery is requested for a device def handle_info({:discovery_requested, device_id}, socket) do Logger.info("Discovery requested for device, sending discovery job to agent", agent_token_id: socket.assigns.agent_token_id, device_id: device_id ) case Devices.get_device_with_details(device_id) do nil -> Logger.error("Device not found for discovery request", device_id: device_id) {:noreply, socket} device -> # Build and send discovery job to agent job = build_discovery_job(device) job_list = %AgentJobList{jobs: [job]} binary = AgentJobList.encode(job_list) push(socket, "discovery_job", %{binary: Base.encode64(binary)}) {:noreply, socket} end end # Handle PubSub broadcast when backup is requested for a device def handle_info({:backup_requested, job}, socket) do Logger.info("Backup requested for device, sending backup job to agent", agent_token_id: socket.assigns.agent_token_id, device_id: job.device_id, job_id: job.job_id ) job_list = %AgentJobList{jobs: [job]} binary = AgentJobList.encode(job_list) push(socket, "backup_job", %{binary: Base.encode64(binary)}) {:noreply, socket} end # Handle PubSub broadcast when credential test is requested def handle_info({:credential_test_requested, test_id, snmp_config}, socket) do Logger.info("Credential test requested, sending test job to agent", agent_token_id: socket.assigns.agent_token_id, test_id: test_id ) # Build SNMP device from config snmp_device = %SnmpDevice{ ip: snmp_config[:ip], port: snmp_config[:port], version: snmp_config[:version], community: snmp_config[:community], v3_security_level: snmp_config[:v3_security_level], v3_username: snmp_config[:v3_username], v3_auth_protocol: snmp_config[:v3_auth_protocol], v3_auth_password: snmp_config[:v3_auth_password], v3_priv_protocol: snmp_config[:v3_priv_protocol], v3_priv_password: snmp_config[:v3_priv_password] } # Build test credentials job job = %AgentJob{ job_id: test_id, job_type: :TEST_CREDENTIALS, device_id: test_id, snmp_device: snmp_device, queries: [], mikrotik_device: nil, mikrotik_commands: [] } job_list = %AgentJobList{jobs: [job]} binary = AgentJobList.encode(job_list) push(socket, "jobs", %{binary: Base.encode64(binary)}) {:noreply, socket} end # Handle PubSub broadcast when live polling is requested for a device def handle_info({:live_poll_requested, device_id, sensor_oids, reply_topic}, socket) do maybe_debug_log(socket, "Live poll requested for device", device_id: device_id, sensor_count: length(sensor_oids), reply_topic: reply_topic ) case Devices.get_device_with_details(device_id) do nil -> Logger.error("Device not found for live poll request", device_id: device_id) {:noreply, socket} device -> # Build live polling job with only requested sensor OIDs job = build_live_polling_job(device, sensor_oids, reply_topic) job_list = %AgentJobList{jobs: [job]} binary = AgentJobList.encode(job_list) maybe_debug_log(socket, "Sending live poll job to agent", device_id: device_id, device_name: device.name, oid_count: length(sensor_oids) ) push(socket, "jobs", %{binary: Base.encode64(binary)}) {:noreply, socket} end end @impl true @spec handle_in(String.t(), map(), Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} def handle_in("result", %{"binary" => binary_b64}, socket) do binary = Base.decode64!(binary_b64) result = SnmpResult.decode(binary) maybe_debug_log(socket, "Received SNMP result from agent", device_id: result.device_id, job_type: result.job_type, job_id: result.job_id, binary_size: byte_size(binary_b64), oid_count: map_size(result.oid_values) ) # Check if this is a live poll result if String.starts_with?(result.job_id, "live_poll:") do handle_live_poll_result(result, socket) else _ = process_snmp_result(socket.assigns.organization_id, result, socket) end {:noreply, socket} end def handle_in("heartbeat", %{"binary" => binary_b64}, socket) do binary = Base.decode64!(binary_b64) heartbeat = AgentHeartbeat.decode(binary) metadata = %{ "version" => heartbeat.version, "hostname" => heartbeat.hostname, "uptime_seconds" => heartbeat.uptime_seconds } _ = Agents.update_agent_token_heartbeat( socket.assigns.agent_token_id, heartbeat.ip_address, metadata ) {:noreply, socket} end @impl true def handle_in("error", %{"binary" => binary_b64}, socket) 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, error: error.message ) {:noreply, socket} end def handle_in("mikrotik_result", %{"binary" => binary_b64}, socket) do binary = Base.decode64!(binary_b64) result = MikrotikResult.decode(binary) maybe_debug_log(socket, "Received MikroTik result from agent", device_id: result.device_id, job_id: result.job_id, has_error: result.error != "", sentence_count: length(result.sentences) ) # Check if this is a backup job by the job_id prefix if String.starts_with?(result.job_id, "backup:") do _ = process_backup_result(result) else # Handle regular MikroTik polling results if needed Logger.debug("Received non-backup MikroTik result", job_id: result.job_id) end {:noreply, socket} end def handle_in("credential_test_result", %{"binary" => binary_b64}, socket) do binary = Base.decode64!(binary_b64) result = CredentialTestResult.decode(binary) maybe_debug_log(socket, "Received credential test result from agent", test_id: result.test_id, success: result.success, has_error: result.error_message != "" ) # Broadcast result to the requesting LiveView via PubSub # The test_id contains the device_id, so we can broadcast to that topic Phoenix.PubSub.broadcast( Towerops.PubSub, "credential_test:#{result.test_id}", {:credential_test_result, result} ) {:noreply, socket} end # Private helpers @spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()] defp build_jobs_for_agent(agent_token_id) do agent_token_id |> Agents.list_agent_polling_targets() |> Enum.flat_map(&build_jobs_for_device/1) end defp build_jobs_for_device(device) do # Build SNMP job (discovery or polling) snmp_job = if needs_discovery?(device) do build_discovery_job(device) else build_polling_job(device) end # Build MikroTik job only during discovery (not during regular polling) # MikroTik commands are discovery-type operations that should run once per 24h mikrotik_job = if needs_discovery?(device) and mikrotik_device?(device) do build_mikrotik_job(device) end # Return list of jobs (filter out nil) Enum.reject([snmp_job, mikrotik_job], &is_nil/1) end defp needs_discovery?(device) do # Need discovery if no SNMP device or last discovery was >24 hours ago is_nil(device.snmp_device) or is_nil(device.last_discovery_at) or DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour) > 24 end defp mikrotik_device?(device) do # Check if device is MikroTik based on SNMP discovery data device.snmp_device && (String.contains?(device.snmp_device.manufacturer || "", "MikroTik") || String.contains?(device.snmp_device.sys_descr || "", "RouterOS")) end # Resolves SNMP credentials for a device. # For SNMPv3, returns a map with resolved credentials via cascade. # For v1/v2c, returns a simple map with community string and version. defp resolve_snmp_credentials(device) do if device.snmp_version == "3" do Devices.get_snmpv3_config(device) else %{ community: Devices.resolve_snmp_community(device), version: device.snmp_version } end end # Checks if SNMP credentials are present (not nil/empty). defp credentials_present?(%{community: community}) when is_binary(community) do community != "" end defp credentials_present?(%{username: username}) when is_binary(username) do username != "" end defp credentials_present?(_), do: false # Builds the SnmpDevice protobuf message with appropriate credentials. # For v1/v2c: Uses community string # For v3: Uses SNMPv3 credentials (security_level, username, auth/priv protocols/passwords) defp build_snmp_device_message(device, snmp_config) do if device.snmp_version == "3" do build_v3_snmp_device(device, snmp_config) else build_v2c_snmp_device(device, snmp_config) end end defp build_v3_snmp_device(device, snmp_config) do %SnmpDevice{ ip: device.ip_address, version: device.snmp_version, port: device.snmp_port || 161, transport: device.snmp_transport || "udp", community: "", v3_security_level: snmp_config.security_level || "", v3_username: snmp_config.username || "", v3_auth_protocol: snmp_config.auth_protocol || "", v3_auth_password: snmp_config.auth_password || "", v3_priv_protocol: snmp_config.priv_protocol || "", v3_priv_password: snmp_config.priv_password || "" } end defp build_v2c_snmp_device(device, snmp_config) do community = snmp_config.community || "" %SnmpDevice{ ip: device.ip_address, version: device.snmp_version, port: device.snmp_port || 161, transport: device.snmp_transport || "udp", community: community } end defp build_discovery_job(device) do snmp_credentials = resolve_snmp_credentials(device) maybe_debug_log(%{assigns: %{agent_token_id: "system"}}, "Building discovery job", device_id: device.id, device_name: device.name, snmp_version: device.snmp_version, credentials_present: credentials_present?(snmp_credentials), credential_source: if(device.snmp_version == "3", do: Map.get(snmp_credentials, :source), else: device.snmp_community_source ) ) %AgentJob{ job_id: "discover:#{device.id}", job_type: :DISCOVER, device_id: device.id, snmp_device: build_snmp_device_message(device, snmp_credentials), queries: build_discovery_queries() } end defp build_polling_job(device) do _snmp_device = device.snmp_device snmp_credentials = resolve_snmp_credentials(device) maybe_debug_log(%{assigns: %{agent_token_id: "system"}}, "Building polling job", device_id: device.id, device_name: device.name, snmp_version: device.snmp_version, credentials_present: credentials_present?(snmp_credentials), credential_source: if(device.snmp_version == "3", do: Map.get(snmp_credentials, :source), else: device.snmp_community_source ), site_id: device.site_id, site_loaded: not is_nil(Map.get(device, :site)) ) %AgentJob{ job_id: "poll:#{device.id}", job_type: :POLL, device_id: device.id, snmp_device: build_snmp_device_message(device, snmp_credentials), queries: build_polling_queries(device) } end defp build_live_polling_job(device, sensor_oids, reply_topic) do snmp_credentials = resolve_snmp_credentials(device) %AgentJob{ job_id: "live_poll:#{device.id}:#{reply_topic}", job_type: :POLL, device_id: device.id, snmp_device: build_snmp_device_message(device, snmp_credentials), queries: [ %SnmpQuery{ query_type: :GET, oids: sensor_oids } ] } end # Handle live poll result - broadcast to reply topic instead of saving defp handle_live_poll_result(result, socket) do # Extract reply topic from job_id: "live_poll:device_id:reply_topic" case String.split(result.job_id, ":", parts: 3) do ["live_poll", _device_id, reply_topic] -> maybe_debug_log(socket, "Broadcasting live poll result", reply_topic: reply_topic, oid_count: map_size(result.oid_values) ) # Broadcast OID values directly to the waiting LiveView Phoenix.PubSub.broadcast( Towerops.PubSub, reply_topic, {:live_poll_result, result.oid_values} ) _ -> Logger.warning("Invalid live poll job_id format: #{result.job_id}") end end defp build_discovery_queries do [ # System info (GET) %SnmpQuery{ query_type: :GET, oids: [ # sysDescr "1.3.6.1.2.1.1.1.0", # sysObjectID "1.3.6.1.2.1.1.2.0", # sysUpTime "1.3.6.1.2.1.1.3.0", # sysContact "1.3.6.1.2.1.1.4.0", # sysName "1.3.6.1.2.1.1.5.0", # sysLocation "1.3.6.1.2.1.1.6.0" ] }, # Interface table (WALK) %SnmpQuery{ query_type: :WALK, # ifTable oids: ["1.3.6.1.2.1.2.2.1"] }, # Interface extensions (WALK) %SnmpQuery{ query_type: :WALK, # ifXTable oids: ["1.3.6.1.2.1.31.1.1.1"] }, # Sensor discovery - ENTITY-SENSOR-MIB (WALK) %SnmpQuery{ query_type: :WALK, oids: [ # ENTITY-SENSOR-MIB - sensor tables "1.3.6.1.2.1.99.1.1.1", # ENTITY-MIB - physical entity descriptions (needed for sensor names) "1.3.6.1.2.1.47.1.1.1.1.2", # entPhysicalName "1.3.6.1.2.1.47.1.1.1.1.7", # entPhysicalClass "1.3.6.1.2.1.47.1.1.1.1.5" ] }, # Vendor-specific MIBs (WALK) %SnmpQuery{ query_type: :WALK, oids: [ # Cisco "1.3.6.1.4.1.9", # MikroTik "1.3.6.1.4.1.14988", # Ubiquiti "1.3.6.1.4.1.41112" ] }, # Neighbor discovery (WALK) %SnmpQuery{ query_type: :WALK, oids: [ # LLDP "1.0.8802.1.1.2.1.4.1.1", # Cisco CDP "1.3.6.1.4.1.9.9.23" ] } ] end defp build_polling_queries(device) do sensor_oids = Enum.map(device.snmp_device.sensors, & &1.sensor_oid) interface_oids = Enum.flat_map(device.snmp_device.interfaces, fn iface -> idx = iface.if_index [ # ifInOctets "1.3.6.1.2.1.2.2.1.10.#{idx}", # ifOutOctets "1.3.6.1.2.1.2.2.1.16.#{idx}", # ifInErrors "1.3.6.1.2.1.2.2.1.14.#{idx}", # ifOutErrors "1.3.6.1.2.1.2.2.1.20.#{idx}", # ifInDiscards "1.3.6.1.2.1.2.2.1.13.#{idx}", # ifOutDiscards "1.3.6.1.2.1.2.2.1.19.#{idx}" ] end) # Neighbor discovery OIDs neighbor_oids = [ # LLDP "1.0.8802.1.1.2.1.4.1.1", # Cisco CDP "1.3.6.1.4.1.9.9.23" ] [ %SnmpQuery{ query_type: :GET, oids: sensor_oids ++ interface_oids }, %SnmpQuery{ query_type: :WALK, oids: neighbor_oids } ] end defp build_mikrotik_job(device) do mikrotik_config = Devices.get_mikrotik_config(device) # Only build job if MikroTik API is enabled and has credentials if mikrotik_config.enabled && mikrotik_config.username do %AgentJob{ job_id: "mikrotik:#{device.id}", job_type: :MIKROTIK, device_id: device.id, mikrotik_device: %MikrotikDevice{ ip: device.ip_address, username: mikrotik_config.username, password: mikrotik_config.password, port: mikrotik_config.port, use_ssl: mikrotik_config.use_ssl }, mikrotik_commands: build_mikrotik_commands() } end end defp build_mikrotik_commands do [ # System identity %MikrotikCommand{ command: "/system/identity/print", args: %{} }, # System resources %MikrotikCommand{ command: "/system/resource/print", args: %{} } ] end 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, socket) else {:error, :device_not_found} -> Logger.error("Device not found: #{result.device_id}") {:error, :wrong_organization} -> Logger.error("Device #{result.device_id} not in agent's organization") end end defp fetch_device(device_id) do case Devices.get_device_with_details(device_id) do nil -> {:error, :device_not_found} device -> {:ok, device} end end defp verify_device_organization(device, organization_id) do # Cloud pollers (organization_id = nil) can access any device if is_nil(organization_id) or device.organization_id == organization_id do :ok else {:error, :wrong_organization} end end 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, socket) do process_polling_result(device, result, socket) end 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") # Update device timestamp to mark discovery as complete case Devices.update_device(device, %{last_discovery_at: DateTime.utc_now()}) do {:ok, updated_device} -> Logger.info("Updated last_discovery_at for device #{device.name}") # 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, 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, socket) do oid_values = Map.new(result.oid_values) Logger.info("Processing discovery with #{map_size(oid_values)} OIDs", device_id: device.id, device_name: device.name ) # 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, 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) ) # 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, _socket) do snmp_device = device.snmp_device oid_values = Map.new(result.oid_values) timestamp = DateTime.from_unix!(result.timestamp, :second) Logger.info( "Processing polling result for #{device.name}: #{map_size(oid_values)} OIDs, #{length(snmp_device.sensors)} sensors" ) # Process sensor readings Enum.each(snmp_device.sensors, &process_sensor_reading(&1, oid_values, timestamp)) # Process interface stats Enum.each(device.snmp_device.interfaces, &process_interface_stats(&1, oid_values, timestamp)) # Note: Neighbor discovery is handled by separate polling worker # since it requires complex LLDP/CDP parsing end defp process_sensor_reading(sensor, oid_values, timestamp) do case Map.get(oid_values, sensor.sensor_oid) do nil -> :ok value -> with parsed_value when not is_nil(parsed_value) <- parse_float(value) do final_value = parsed_value / sensor.sensor_divisor Snmp.create_sensor_reading(%{ sensor_id: sensor.id, value: final_value, status: "ok", checked_at: timestamp }) end end end defp process_interface_stats(iface, oid_values, timestamp) do idx = iface.if_index Snmp.create_interface_stat(%{ interface_id: iface.id, if_in_octets: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.10.#{idx}")), if_out_octets: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.16.#{idx}")), if_in_errors: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.14.#{idx}")), if_out_errors: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.20.#{idx}")), if_in_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.13.#{idx}")), if_out_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.19.#{idx}")), checked_at: timestamp }) end defp parse_integer(nil), do: nil defp parse_integer(value) when is_integer(value), do: value defp parse_integer(value) when is_binary(value) do case Integer.parse(value) do {int, _} -> int :error -> nil end end defp parse_integer(_), do: nil defp parse_float(value) when is_float(value), do: value defp parse_float(value) when is_integer(value), do: value / 1.0 defp parse_float(value) when is_binary(value) do case Float.parse(value) do {float, _} -> float :error -> nil end end defp parse_float(_), do: nil defp get_remote_ip(socket) do case socket.transport_pid do nil -> nil pid when is_pid(pid) -> case :ranch.get_addr(pid) do {ip, _port} when is_tuple(ip) -> format_ip(ip) _ -> nil end end rescue _ -> nil end defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}" 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 # Uses :info level since production logger filters out :debug defp maybe_debug_log(socket, message, metadata) do if socket.assigns[:debug_enabled] do Logger.info( message, Keyword.merge( [ agent_token_id: socket.assigns.agent_token_id, debug_enabled: true ], metadata ) ) end end defp process_backup_result(result) do case BackupRequests.get_request_by_job_id(result.job_id) do nil -> Logger.warning("Backup request not found for job_id", job_id: result.job_id) request -> handle_backup_request(request, result) end end defp handle_backup_request(request, %{error: ""} = result) do case extract_config_from_result(result) do {:ok, config_text} -> # Manual backups always create a snapshot, even if config unchanged # Automated backups (daily_cron) use deduplication to save storage should_create_backup = request.source == "manual" || backup_needed?(result.device_id, config_text) if should_create_backup do create_and_save_backup(request, result, config_text) else mark_backup_skipped(request, result) end {:error, reason} -> mark_backup_failed(request, result, "Failed to extract config: #{reason}") end end defp handle_backup_request(request, result) do mark_backup_failed(request, result, result.error) end defp create_and_save_backup(request, result, config_text) do case MikrotikBackups.create_backup(result.device_id, config_text, request.source) do {:ok, _backup} -> BackupRequests.update_request_status(request.id, "success", nil) Logger.info("Backup created", device_id: result.device_id, source: request.source) {:error, reason} -> mark_backup_failed(request, result, "Failed to create backup: #{inspect(reason)}") end end defp backup_needed?(device_id, config_text) do {:ok, needs_backup?} = MikrotikBackups.needs_backup?(device_id, config_text) needs_backup? end defp mark_backup_skipped(request, result) do BackupRequests.update_request_status(request.id, "success", nil) Logger.info("Backup skipped (no changes)", device_id: result.device_id) end defp mark_backup_failed(request, result, error_message) do BackupRequests.update_request_status(request.id, "failed", error_message) Logger.error("Backup failed", device_id: result.device_id, error: error_message) end defp extract_config_from_result(result) do # SSH backups return a single sentence with "config" attribute (new method) # API backups use /file/read which returns chunks with "data" attribute (old method) # Try SSH backup format first (single sentence with "config" attribute) config_direct = Enum.find_value(result.sentences, fn sentence -> Map.get(sentence.attributes, "config") end) case config_direct do nil -> # Try chunked API backup format extract_config_from_chunked_data(result) config_text when is_binary(config_text) and config_text != "" -> {:ok, config_text} _ -> {:error, "Config attribute found but empty or invalid"} end end defp extract_config_from_chunked_data(result) do # The old API backup workflow uses /file/read which returns chunks with "data" attribute data_chunks = result.sentences |> Enum.filter(fn sentence -> Map.has_key?(sentence.attributes, "data") end) |> Enum.map(fn sentence -> Map.get(sentence.attributes, "data") end) case data_chunks do [] -> # Fallback: try other methods extract_config_from_fallback(result) chunks -> # Combine all chunks in order config_text = Enum.join(chunks, "") if config_text == "" do {:error, "No config data in result - all chunks were empty"} else {:ok, config_text} end end end defp extract_config_from_fallback(result) do # Fallback: try to find any sentence with configuration-like content # Look for sentences with common RouterOS config markers config_text = Enum.find_value(result.sentences, fn sentence -> find_config_in_attributes(sentence.attributes) end) case config_text do nil -> {:error, "No config data in result - received #{length(result.sentences)} sentences"} text -> {:ok, text} end end defp find_config_in_attributes(attributes) do Enum.find_value(attributes, fn {_key, value} -> if routeros_config?(value), do: value end) end defp routeros_config?(value) when is_binary(value) do String.contains?(value, ["# ", "/", "set "]) and String.length(value) > 100 end defp routeros_config?(_), do: false end