towerops/lib/towerops_web/channels/agent_channel.ex
Graham McIntire 9cb4c59638 dialyzer: replace suppressions with real fixes where possible
Changes to eliminate @dialyzer suppressions by fixing underlying causes:

NIF stubs (towerops_native.ex, mib_translator.ex):
- Change stubs to :erlang.nif_error(:nif_not_loaded) (no_return type).
  Real NIF replaces stubs at load time; calls to unloaded stubs now fail
  loudly instead of returning fake data. Lets dialyzer trust @spec.
- Remove @dialyzer :nowarn_function on three NIFs and on translate/1.

Discovery sync_* functions (snmp/discovery.ex, channels/agent_channel.ex):
- agent_channel passes %{device_id: _, interfaces: _} and %{id: _} maps
  into Discovery.sync_ip_addresses/sync_processors/sync_storage, which
  @spec'd only %Device{}. Add narrow map-type unions (ip_sync_device,
  snmp_device_ref) reflecting what the functions actually access.
- Remove @dialyzer :nowarn_function on three agent_channel helpers.

remote_ip.ex — real bug caught and fixed:
- `:ranch.get_addr(socket.transport_pid)` was always raising since
  Bandit uses ThousandIsland, not Ranch; the rescue _ -> nil silently
  returned nil every time. Switched to Phoenix's documented
  :peer_data connect_info (already enabled in endpoint.ex) via
  socket.assigns; remote IP now actually works.
- Remove remote_ip.ex entry from .dialyzer_ignore.exs.

Accounts / Organizations (Ecto.Multi opacity):
- Add @specs to Multi-building helpers, refactor into pipe chains.
- 6 @dialyzer :nowarn_function → 0, but 7 :no_opaque remain. Root
  cause is upstream: Ecto.Multi.new/0 returns a struct with a literal
  %MapSet{} whose @opaque internal representation trips dialyzer on
  every subsequent Multi.* call. Unfixable without an Ecto patch or
  bypassing Multi entirely. Comments document the specific upstream
  issue rather than a vague "Ecto.Multi opacity" claim.

Devices.ex:
- Adding @specs made it worse (call_without_opaque → contract_with_
  opaque); inlining the Multi didn't help either — same MapSet root
  cause. Suppression kept with a sharper comment.
2026-04-21 11:01:30 -05:00

2443 lines
77 KiB
Elixir

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.Check, as: CheckProto
alias Towerops.Agent.CheckList
alias Towerops.Agent.CheckResult, as: CheckResultProto
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.DnsCheckConfig
alias Towerops.Agent.HttpCheckConfig
alias Towerops.Agent.LldpTopologyResult
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MonitoringCheck, as: MonitoringCheckProto
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
alias Towerops.Agent.SslCheckConfig
alias Towerops.Agent.TcpCheckConfig
alias Towerops.Agents
alias Towerops.Alerts
alias Towerops.ConfigChanges
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
alias Towerops.Devices.MikrotikBackups
alias Towerops.Monitoring
alias Towerops.Snmp
alias Towerops.Snmp.AgentDiscovery
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.SensorChangeDetector
alias Towerops.Topology
alias ToweropsWeb.GraphQL.Subscriptions
alias ToweropsWeb.RemoteIp
require Logger
@typep socket :: Phoenix.Socket.t()
@typep base64_string :: String.t()
@typep protobuf_binary :: binary()
# Heartbeat timeout: close channel if no heartbeat in 5 minutes
@heartbeat_timeout_seconds 300
# Check heartbeat every 2 minutes
@heartbeat_check_interval_ms 120_000
# Maximum message size: 10MB (protobuf messages should be much smaller)
@max_message_size 10 * 1024 * 1024
@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")
# Subscribe to check changes for this agent (service checks added/removed/updated)
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "checks:agent:#{agent_token.id}")
# Subscribe to token lifecycle events (disable/delete triggers disconnect)
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
# Subscribe to latency probe requests (cloud pollers only)
_ =
if agent_token.is_cloud_poller do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:latency_probe")
end
# Update last_seen_at and IP on join
now = DateTime.utc_now()
remote_ip = get_remote_ip(socket)
_ = Agents.update_agent_token_heartbeat(agent_token.id, remote_ip, %{})
# Broadcast agent connection for real-time UI updates
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agents:health",
{:agent_connected, agent_token.id, agent_token.organization_id}
)
# Track heartbeat time and schedule periodic check
# Also set last_heartbeat_db_update since we just updated the DB
socket =
socket
|> assign(:last_heartbeat_at, now)
|> assign(:last_heartbeat_db_update, now)
Process.send_after(self(), :check_heartbeat, @heartbeat_check_interval_ms)
# Send initial job list after join completes (push/3 cannot be called during join)
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
def terminate(_reason, socket) do
# Broadcast agent disconnection for real-time UI updates
_ =
if Map.has_key?(socket.assigns, :agent_token_id) do
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agents:health",
{:agent_disconnected, socket.assigns.agent_token_id, socket.assigns.organization_id}
)
end
:ok
end
@impl true
@spec handle_info(:send_jobs, Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()}
def handle_info(:send_jobs, socket) do
_ = cancel_poll_timer(socket)
updated_socket = build_and_push_jobs(socket)
build_and_push_check_jobs(updated_socket)
# Schedule next job dispatch cycle
interval_ms = poll_interval_ms()
timer = Process.send_after(self(), :send_jobs, interval_ms)
{:noreply, assign(updated_socket, :poll_timer, timer)}
end
# Poll a device immediately after discovery completes.
# Uses send(self(), ...) so discovery DB writes commit first.
def handle_info({:poll_after_discovery, device_id}, socket) do
case Devices.get_device_with_details(device_id) do
nil ->
Logger.error("Device not found for post-discovery poll", device_id: device_id)
device ->
jobs = [build_polling_job(device)]
jobs =
if device.monitoring_enabled do
jobs ++ [build_ping_job(device)]
else
jobs
end
job_list = %AgentJobList{jobs: jobs}
binary = AgentJobList.encode(job_list)
Logger.info("Sending immediate poll after discovery",
device_id: device_id,
device_name: device.name,
job_count: length(jobs)
)
push(socket, "jobs", %{binary: Base.encode64(binary)})
end
{:noreply, socket}
end
# Periodic heartbeat check — close channel if agent hasn't sent a heartbeat recently
def handle_info(:check_heartbeat, socket) do
seconds_since = DateTime.diff(DateTime.utc_now(), socket.assigns.last_heartbeat_at, :second)
if seconds_since > @heartbeat_timeout_seconds do
Logger.warning("Agent heartbeat timeout after #{seconds_since}s",
agent_token_id: socket.assigns.agent_token_id
)
{:stop, {:shutdown, :heartbeat_timeout}, socket}
else
Process.send_after(self(), :check_heartbeat, @heartbeat_check_interval_ms)
{:noreply, socket}
end
end
# Handle check changes — push updated check list to agent
def handle_info(:checks_changed, socket) do
build_and_push_check_jobs(socket)
{:noreply, socket}
end
# Handle token disabled/deleted — disconnect the agent
def handle_info(:token_disabled, socket) do
Logger.warning("Agent token disabled, disconnecting",
agent_token_id: socket.assigns.agent_token_id
)
{:stop, {:shutdown, :token_disabled}, socket}
end
# Handle restart request — push restart event to agent, then stop channel
def handle_info(:restart_requested, socket) do
Logger.info("Restart requested, notifying agent",
agent_token_id: socket.assigns.agent_token_id
)
push(socket, "restart", %{})
{:stop, {:shutdown, :restart_requested}, socket}
end
# Handle update request — push update event with download URL and checksum to agent
def handle_info({:update_requested, url, checksum}, socket) do
Logger.info("Update requested, notifying agent",
agent_token_id: socket.assigns.agent_token_id,
url: url
)
push(socket, "update", %{url: url, checksum: checksum})
{:noreply, socket}
end
# Handle PubSub broadcast when device assignments change.
# Debounces rapid changes — cancels any pending refresh and schedules a new one.
def handle_info({:assignments_changed, event}, socket) do
Logger.info("Agent assignments changed, scheduling job refresh",
agent_token_id: socket.assigns.agent_token_id,
event: event,
previous_device_ids: socket.assigns[:last_job_device_ids] || []
)
# Cancel any existing poll timer (regular cycle or pending debounce)
_ = cancel_poll_timer(socket)
# Schedule new refresh after debounce delay (configurable, defaults to 500ms in prod, 50ms in test)
debounce_ms = Application.get_env(:towerops, :agent_channel_debounce_ms, 500)
timer = Process.send_after(self(), :send_jobs, debounce_ms)
{:noreply, assign(socket, :poll_timer, timer)}
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(
"AgentChannel sending credential test to agent: ip=#{snmp_config[:ip]} port=#{snmp_config[:port]} version=#{snmp_config[:version]} community_present=#{!is_nil(snmp_config[:community])} 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)})
# Schedule timeout — if no result in 60s, notify the waiting LiveView
Process.send_after(self(), {:live_poll_timeout, reply_topic}, 60_000)
{:noreply, socket}
end
end
# Handle live poll timeout — notify the LiveView that the poll didn't return in time
def handle_info({:live_poll_timeout, reply_topic}, socket) do
Logger.warning("Live poll timed out", reply_topic: reply_topic)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
reply_topic,
{:live_poll_error, :timeout}
)
{:noreply, socket}
end
# Handle latency probe requests — send PING jobs for cloud-polled devices
def handle_info({:latency_probe_jobs, devices}, socket) do
jobs =
Enum.flat_map(devices, fn device ->
# 5 pings per device for statistical validity
for i <- 1..5 do
%AgentJob{
job_id: "probe:#{device.id}:#{i}",
job_type: :PING,
device_id: device.id,
snmp_device: %SnmpDevice{
ip: to_string(device.ip_address),
port: 0,
version: "",
community: ""
},
queries: []
}
end
end)
if jobs != [] do
job_list = %AgentJobList{jobs: jobs}
binary = AgentJobList.encode(job_list)
Logger.info("Sending #{length(jobs)} latency probe pings to cloud poller",
agent_token_id: socket.assigns.agent_token_id,
device_count: length(devices)
)
push(socket, "jobs", %{binary: Base.encode64(binary)})
end
{:noreply, socket}
end
@impl true
@spec handle_in(String.t(), map(), socket()) :: {:noreply, socket()}
def handle_in("result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
# Validate message size to prevent DoS attacks (early return pattern)
if byte_size(binary_b64) > @max_message_size do
Logger.warning("Rejected oversized message from agent",
agent_token_id: socket.assigns.agent_token_id,
size: byte_size(binary_b64),
max_size: @max_message_size
)
{:reply, {:error, %{reason: "Message too large (max #{@max_message_size} bytes)"}}, socket}
else
process_snmp_result_message(binary_b64, socket)
end
end
@spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) ::
{:noreply, socket()}
def handle_in("heartbeat", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
# Validate message size to prevent DoS attacks (early return pattern)
if byte_size(binary_b64) > @max_message_size do
Logger.warning("Rejected oversized heartbeat from agent",
agent_token_id: socket.assigns.agent_token_id,
size: byte_size(binary_b64),
max_size: @max_message_size
)
{:reply, {:error, %{reason: "Message too large"}}, socket}
else
process_heartbeat_message(binary_b64, socket)
end
end
def handle_in("error", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
# Validate message size to prevent DoS attacks
if byte_size(binary_b64) > @max_message_size do
Logger.warning("Rejected oversized error message from agent",
agent_token_id: socket.assigns.agent_token_id,
size: byte_size(binary_b64),
max_size: @max_message_size
)
{:reply, {:error, %{reason: "Message too large"}}, socket}
else
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, error} <- AgentError.decode(binary) do
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}
else
{:error, {type, message}} ->
Logger.error("Invalid error message from agent",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message
)
{:noreply, socket}
end
end
end
def handle_in("mikrotik_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- MikrotikResult.decode(binary) do
handle_validated_mikrotik_result(result, socket)
else
{:error, {type, message}} ->
Logger.error("Invalid MikroTik result from agent",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message
)
{:noreply, socket}
end
end
def handle_in("credential_test_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- CredentialTestResult.decode(binary) do
Logger.info("AgentChannel received credential test result from agent, broadcasting to LiveView",
agent_token_id: socket.assigns.agent_token_id,
test_id: result.test_id,
success: result.success,
has_error: result.error_message != "",
broadcast_topic: "credential_test:#{result.test_id}"
)
# 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}
else
{:error, {type, message}} ->
Logger.error(
"Invalid credential test result from agent: #{type} - #{message}",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message,
binary_size: byte_size(binary_b64)
)
{:noreply, socket}
end
end
@spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) ::
{:noreply, socket()}
def handle_in("monitoring_check", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, check} <- MonitoringCheckProto.decode(binary) do
maybe_debug_log(socket, "Received ping result from agent",
device_id: check.device_id,
status: check.status,
response_time_ms: check.response_time_ms,
binary_size: byte_size(binary_b64)
)
# Store ping result in database
case store_monitoring_check(check, socket) do
:ok ->
:ok
{:error, reason} ->
Logger.error("Failed to store monitoring check",
device_id: check.device_id,
reason: inspect(reason)
)
end
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid ping result from agent: #{type} - #{message}",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message,
binary_size: byte_size(binary_b64)
)
{:noreply, socket}
end
end
@spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) ::
{:noreply, socket()}
def handle_in("check_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- CheckResultProto.decode(binary) do
maybe_debug_log(socket, "Received check result from agent",
check_id: result.check_id,
status: result.status,
response_time_ms: result.response_time_ms,
binary_size: byte_size(binary_b64)
)
case store_check_result(result, socket) do
:ok ->
:ok
{:error, reason} ->
Logger.error("Failed to store check result",
check_id: result.check_id,
reason: inspect(reason)
)
end
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid check result from agent: #{type} - #{message}",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message,
binary_size: byte_size(binary_b64)
)
{:noreply, socket}
end
end
@spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) ::
{:noreply, socket()}
def handle_in("lldp_topology_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- LldpTopologyResult.decode(binary) do
maybe_debug_log(socket, "Received LLDP topology result from agent",
device_id: result.device_id,
job_id: result.job_id,
neighbor_count: length(result.neighbors),
local_system_name: result.local_system_name
)
# Store LLDP neighbors in database
case store_lldp_neighbors(result) do
{:ok, count} ->
Logger.info("Stored LLDP neighbors for device",
device_id: result.device_id,
neighbor_count: count
)
{:error, reason} ->
Logger.error("Failed to store LLDP neighbors",
device_id: result.device_id,
reason: inspect(reason)
)
end
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid LLDP topology result from agent: #{type} - #{message}",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message,
binary_size: byte_size(binary_b64)
)
{:noreply, socket}
end
end
# Catch-all for unmatched events (diagnostic)
def handle_in(event, payload, socket) do
Logger.warning("Unhandled agent channel event",
event: event,
payload_keys: Map.keys(payload),
agent_token_id: socket.assigns.agent_token_id
)
{:noreply, socket}
end
# Private helpers
defp process_snmp_result_message(binary_b64, socket) do
Logger.info("Received SNMP result from agent (binary size: #{byte_size(binary_b64)})")
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- SnmpResult.decode(binary) do
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_and_log_snmp_result(socket, result)
end
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid SNMP result from agent: #{type} - #{message}",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message,
binary_size: byte_size(binary_b64)
)
{:noreply, socket}
end
end
defp process_heartbeat_message(binary_b64, socket) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, heartbeat} <- AgentHeartbeat.decode(binary) do
now = DateTime.utc_now()
last_db_update = socket.assigns[:last_heartbeat_db_update]
# Only update database if last update was >30s ago to prevent flooding
# This limits heartbeat DB writes to ~2/minute max per agent
socket =
if is_nil(last_db_update) or DateTime.diff(now, last_db_update) > 30 do
metadata = %{
"version" => heartbeat.version,
"uptime_seconds" => heartbeat.uptime_seconds,
"arch" => heartbeat.arch
}
_ =
Agents.update_agent_token_heartbeat(
socket.assigns.agent_token_id,
get_remote_ip(socket),
metadata
)
# Broadcast heartbeat for real-time UI updates (especially important for stale agents coming back online)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agents:health",
{:agent_heartbeat, socket.assigns.agent_token_id, socket.assigns.organization_id}
)
socket
|> assign(:last_heartbeat_at, now)
|> assign(:last_heartbeat_db_update, now)
else
# Just update in-memory tracking, skip DB write
assign(socket, :last_heartbeat_at, now)
end
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid heartbeat from agent",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message
)
{:noreply, socket}
end
end
@spec handle_validated_mikrotik_result(MikrotikResult.t(), socket()) :: {:noreply, socket()}
defp handle_validated_mikrotik_result(result, socket) do
_binary = MikrotikResult.encode(result)
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
case process_backup_result(result) do
:ok ->
:ok
{:error, reason} ->
Logger.error("Failed to process backup result",
device_id: result.device_id,
job_id: result.job_id,
reason: inspect(reason)
)
end
else
# Handle regular MikroTik polling results if needed
Logger.debug("Received non-backup MikroTik result", job_id: result.job_id)
end
{:noreply, socket}
end
# Builds all jobs for the agent and pushes them to the channel.
# Used both during join (synchronous) and for subsequent refreshes.
defp build_and_push_jobs(socket) do
jobs = build_jobs_for_agent(socket.assigns.agent_token_id)
job_list = %AgentJobList{jobs: jobs}
binary = AgentJobList.encode(job_list)
# Extract device IDs for better debugging
device_ids = jobs |> Enum.map(& &1.device_id) |> Enum.uniq() |> Enum.sort()
Logger.info("Sending #{length(jobs)} jobs to agent",
agent_token_id: socket.assigns.agent_token_id,
device_count: length(device_ids),
device_ids: inspect(device_ids),
job_ids: inspect(Enum.map(jobs, & &1.job_id))
)
# Store last job list to detect changes on next refresh
socket = assign(socket, :last_job_device_ids, device_ids)
push(socket, "jobs", %{binary: Base.encode64(binary)})
socket
end
# Builds service check jobs for the agent and pushes them via "check_jobs" event.
defp build_and_push_check_jobs(socket) do
agent_token_id = socket.assigns.agent_token_id
checks = Monitoring.list_checks_for_agent(agent_token_id, enabled: true)
if checks != [] do
proto_checks = Enum.map(checks, &build_check_protobuf/1)
check_list = %CheckList{checks: proto_checks}
binary = CheckList.encode(check_list)
Logger.info("Sending #{length(proto_checks)} check jobs to agent",
agent_token_id: agent_token_id,
check_ids: inspect(Enum.map(checks, & &1.id))
)
push(socket, "check_jobs", %{binary: Base.encode64(binary)})
end
end
defp build_check_protobuf(check) do
config = check.config || %{}
base_fields = [
id: check.id,
check_type: check.check_type,
interval_seconds: check.interval_seconds,
timeout_ms: check.timeout_ms
]
struct!(CheckProto, base_fields ++ check_type_config(check.check_type, config))
end
defp check_type_config("http", config) do
[
http: %HttpCheckConfig{
url: config["url"] || "",
method: config["method"] || "GET",
expected_status: config["expected_status"] || 200,
verify_ssl: config["verify_ssl"] != false,
regex: config["regex"] || "",
follow_redirects: config["follow_redirects"] != false
}
]
end
defp check_type_config("tcp", config) do
[
tcp: %TcpCheckConfig{
host: config["host"] || "",
port: config["port"] || 0,
send: config["send"] || "",
expect: config["expect"] || ""
}
]
end
defp check_type_config("dns", config) do
[
dns: %DnsCheckConfig{
hostname: config["hostname"] || "",
server: config["server"] || "",
record_type: config["record_type"] || "A",
expected: config["expected"] || ""
}
]
end
defp check_type_config("ssl", config) do
[
ssl: %SslCheckConfig{
host: config["host"] || "",
port: config["port"] || 443,
warning_days: config["warning_days"] || 30
}
]
end
defp check_type_config(_, _config), do: []
@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) only if SNMP is enabled
snmp_job =
if device.snmp_enabled do
if needs_discovery?(device) do
build_discovery_job(device)
else
build_polling_job(device)
end
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 device.snmp_enabled and needs_discovery?(device) and mikrotik_device?(device) do
build_mikrotik_job(device)
end
# Build ping job if monitoring is enabled
ping_job =
if device.monitoring_enabled do
build_ping_job(device)
end
# Return list of jobs (filter out nil)
Enum.reject([snmp_job, mikrotik_job, ping_job], &is_nil/1)
end
defp needs_discovery?(device) do
# Need discovery if no SNMP device or last discovery was >24 hours ago
# Clamp diff to 0 to handle clock skew (future timestamps)
is_nil(device.snmp_device) or
is_nil(device.last_discovery_at) or
max(DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour), 0) > 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
log_v3_device_build(device, snmp_config)
%SnmpDevice{
ip: device.ip_address,
version: device.snmp_version,
port: device.snmp_port || 161,
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 log_v3_device_build(device, snmp_config) do
auth_password_present = !is_nil(snmp_config.auth_password) && snmp_config.auth_password != ""
priv_password_present = !is_nil(snmp_config.priv_password) && snmp_config.priv_password != ""
Logger.info(
"Building SNMPv3 device message",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
security_level: snmp_config.security_level || "",
username: snmp_config.username || "",
auth_protocol: snmp_config.auth_protocol || "",
auth_password_present: auth_password_present,
priv_protocol: snmp_config.priv_protocol || "",
priv_password_present: priv_password_present
)
end
defp build_v2c_snmp_device(device, snmp_config) do
community = snmp_config.community || ""
# Use v2c when possible for HC (64-bit) counter support, but respect
# the device's configured version — some devices only support v1.
version =
case device.snmp_version do
"1" -> "1"
_ -> "2c"
end
%SnmpDevice{
ip: device.ip_address,
version: version,
port: device.snmp_port || 161,
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_ping_job(device) do
%AgentJob{
job_id: "ping:#{device.id}",
job_type: :PING,
device_id: device.id,
# Ping only needs IP address - use minimal SnmpDevice message
snmp_device: %SnmpDevice{
ip: to_string(device.ip_address),
port: 0,
version: "",
community: ""
},
queries: []
}
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"
]
},
# State sensors - ENTITY-STATE-MIB (WALK)
%SnmpQuery{
query_type: :WALK,
oids: ["1.3.6.1.2.1.131.1.1.1.1"]
},
# HOST-RESOURCES-MIB (WALK) - processors, storage, devices
%SnmpQuery{
query_type: :WALK,
oids: [
# hrProcessorTable
"1.3.6.1.2.1.25.3.3",
# hrStorageTable
"1.3.6.1.2.1.25.2.3",
# hrDeviceTable (for processor descriptions)
"1.3.6.1.2.1.25.3.2"
]
},
# UCD-SNMP-MIB (GET) - Linux CPU stats fallback
%SnmpQuery{
query_type: :GET,
oids: [
# ssCpuUser
"1.3.6.1.4.1.2021.11.9.0",
# ssCpuSystem
"1.3.6.1.4.1.2021.11.10.0",
# ssCpuIdle
"1.3.6.1.4.1.2021.11.11.0"
]
},
# Vendor-specific MIBs (WALK) - targeted subtrees only
%SnmpQuery{
query_type: :WALK,
oids: [
# Cisco PROCESS-MIB (CPU)
"1.3.6.1.4.1.9.9.109",
# Cisco ENVMON-MIB (temp/voltage/fan/PSU)
"1.3.6.1.4.1.9.9.13",
# Cisco CDP
"1.3.6.1.4.1.9.9.23",
# Cisco WAP
"1.3.6.1.4.1.9.9.618",
# MikroTik
"1.3.6.1.4.1.14988",
# Ubiquiti
"1.3.6.1.4.1.41112",
# Juniper
"1.3.6.1.4.1.2636",
# HP/H3C/Comware
"1.3.6.1.4.1.25506",
# Fortinet
"1.3.6.1.4.1.12356",
# Cambium
"1.3.6.1.4.1.17713"
]
},
# 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"
]
},
# IP address discovery (WALK)
%SnmpQuery{
query_type: :WALK,
oids: [
# IP-MIB ipAddrTable (IPv4 - deprecated but widely supported)
"1.3.6.1.2.1.4.20",
# IP-MIB ipAddressTable (RFC 4293 - unified IPv4/IPv6)
"1.3.6.1.2.1.4.34"
]
}
]
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
[
# ifHCInOctets (64-bit counter, preferred)
"1.3.6.1.2.1.31.1.1.1.6.#{idx}",
# ifHCOutOctets (64-bit counter, preferred)
"1.3.6.1.2.1.31.1.1.1.10.#{idx}",
# ifInOctets (32-bit fallback for devices without HC counter support)
"1.3.6.1.2.1.2.2.1.10.#{idx}",
# ifOutOctets (32-bit fallback for devices without HC counter support)
"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)
# Build separate WALK queries to isolate failures
# If one table doesn't exist, others can still succeed
base_queries = [
%SnmpQuery{
query_type: :GET,
oids: sensor_oids ++ interface_oids
}
]
# Neighbor discovery (LLDP/CDP) - separate query
neighbor_query = %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"
]
}
# ARP table - separate query (may not be supported on all devices)
arp_query = %SnmpQuery{
query_type: :WALK,
oids: [
# IP-MIB ipNetToMediaTable (IPv4 ARP)
"1.3.6.1.2.1.4.22",
# IP-MIB ipNetToPhysicalTable (RFC 4293 - unified IPv4/IPv6)
"1.3.6.1.2.1.4.35"
]
}
# MAC address table - separate query (only on switches)
mac_query = %SnmpQuery{
query_type: :WALK,
oids: [
# BRIDGE-MIB dot1dTpFdbTable
"1.3.6.1.2.1.17.4.3"
]
}
# IP address tables - separate query
ip_query = %SnmpQuery{
query_type: :WALK,
oids: [
# IP-MIB ipAddrTable (IPv4)
"1.3.6.1.2.1.4.20",
# IP-MIB ipAddressTable (IPv6)
"1.3.6.1.2.1.4.34"
]
}
# HOST-RESOURCES-MIB - separate query (may not be supported)
host_resources_query = %SnmpQuery{
query_type: :WALK,
oids: [
# hrProcessorTable
"1.3.6.1.2.1.25.3.3",
# hrStorageTable
"1.3.6.1.2.1.25.2.3"
]
}
base_queries ++ [neighbor_query, arp_query, mac_query, ip_query, host_resources_query]
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_and_log_snmp_result(socket, result) do
case process_snmp_result(socket.assigns.organization_id, result, socket) do
:ok ->
:ok
{:error, reason} ->
Logger.error("Failed to process SNMP result",
device_id: result.device_id,
job_id: result.job_id,
reason: inspect(reason)
)
end
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),
:ok <- verify_device_assignment(device, socket.assigns.agent_token_id) do
_ = process_job_result(device, result, socket)
:ok
else
{:error, :device_not_found} = error ->
Logger.error("Device not found: #{result.device_id}")
error
{:error, :wrong_organization} = error ->
Logger.error("Device #{result.device_id} not in agent's organization")
error
{:error, :device_reassigned} = error ->
Logger.warning("Ignoring stale result for reassigned device",
device_id: result.device_id,
agent_token_id: socket.assigns.agent_token_id
)
error
end
end
# Verify device is still assigned to this agent before processing results
defp verify_device_assignment(device, agent_token_id) do
# Get effective agent token (resolves inheritance from site/org)
effective_agent_token_id = Agents.get_effective_agent_token(device)
# Check if it matches the requesting agent
if effective_agent_token_id == agent_token_id do
# Also check if there's an explicit device assignment that's disabled
case Agents.get_device_assignment(device.id) do
%{enabled: false} ->
{:error, :device_reassigned}
_ ->
:ok
end
else
{:error, :device_reassigned}
end
end
defp store_monitoring_check(check, socket) do
organization_id = socket.assigns.organization_id
agent_token_id = socket.assigns.agent_token_id
with {:ok, device} <- fetch_device(check.device_id),
:ok <- verify_device_organization(device, organization_id) do
# Use server time to avoid clock skew issues
# Agent timestamps can be unreliable due to clock drift
checked_at = Towerops.Time.now()
# Keep status as string for storage, convert to atom for device status update
status_string = check.status
status_atom = String.to_existing_atom(status_string)
attrs = %{
device_id: check.device_id,
agent_token_id: agent_token_id,
status: status_string,
response_time_ms: check.response_time_ms,
checked_at: checked_at
}
case Monitoring.create_monitoring_check(attrs) do
{:ok, _check} ->
update_device_status_from_check(device, status_atom)
:ok
{:error, changeset} ->
Logger.error("Failed to store ping result",
device_id: check.device_id,
errors: inspect(changeset.errors)
)
{:error, :storage_failed}
end
else
{:error, :device_not_found} ->
Logger.error("Device not found for ping result: #{check.device_id}")
{:error, :device_not_found}
{:error, :wrong_organization} ->
Logger.error("Device #{check.device_id} not in agent's organization")
{:error, :wrong_organization}
end
end
defp store_check_result(%CheckResultProto{} = result, socket) do
organization_id = socket.assigns.organization_id
agent_token_id = socket.assigns.agent_token_id
with check when not is_nil(check) <- Monitoring.get_check(result.check_id),
true <- check.organization_id == organization_id do
record_and_process_check_result(check, result, organization_id, agent_token_id)
else
nil ->
Logger.error("Check not found for check result: #{result.check_id}")
{:error, :check_not_found}
false ->
Logger.error("Check #{result.check_id} not in agent's organization")
{:error, :wrong_organization}
end
end
defp record_and_process_check_result(check, result, organization_id, agent_token_id) do
now = Towerops.Time.now()
case Monitoring.create_check_result(%{
check_id: check.id,
organization_id: organization_id,
status: result.status,
output: result.output,
response_time_ms: result.response_time_ms,
checked_at: now,
agent_token_id: agent_token_id
}) do
{:ok, _} ->
old_state = check.current_state
{:ok, updated_check} = Monitoring.update_check_state(check, result.status, result.output)
handle_check_state_change(old_state, updated_check, result.output)
_ = broadcast_check_update(check.device_id)
:ok
{:error, changeset} ->
Logger.error("Failed to store check result",
check_id: check.id,
errors: inspect(changeset.errors)
)
{:error, :storage_failed}
end
end
defp broadcast_check_update(nil), do: :ok
defp broadcast_check_update(device_id) do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device_id}",
{:monitoring_check_updated, device_id}
)
end
defp handle_check_state_change(old_state, check, output) do
# State changed from OK to problem and is now hard state
_ =
if old_state == 0 and check.current_state in [1, 2] and check.current_state_type == "hard" do
maybe_create_check_alert(check, output)
end
# State changed from problem to OK
if old_state in [1, 2] and check.current_state == 0 do
Alerts.resolve_check_alerts(check.id)
end
end
defp maybe_create_check_alert(check, output) do
if !Alerts.has_active_check_alert?(check.id) do
severity = if check.current_state == 1, do: 1, else: 2
Alerts.create_alert(%{
check_id: check.id,
device_id: check.device_id,
organization_id: check.organization_id,
alert_type: "check_#{check.check_type}",
severity: severity,
message: "Check '#{check.name}' is #{Monitoring.Check.state_label(check.current_state)}",
output: output,
triggered_at: DateTime.utc_now()
})
end
end
defp store_lldp_neighbors(%LldpTopologyResult{} = result) do
now = DateTime.utc_now()
device_id = result.device_id
# Store each neighbor using Topology module's upsert function
results =
Enum.map(result.neighbors, fn neighbor ->
# Call the private upsert function via the public Topology API
# The neighbor struct from protobuf matches the expected format
Topology.upsert_neighbor(device_id, neighbor, now)
end)
success_count = Enum.count(results, &match?({:ok, _}, &1))
{:ok, success_count}
rescue
e ->
Logger.error("Exception storing LLDP neighbors",
device_id: result.device_id,
error: Exception.message(e)
)
{:error, :storage_exception}
end
defp update_device_status_from_check(device, check_status) do
new_status = if check_status in [:success, :up, :ok], do: :up, else: :down
old_status = device.status
Devices.update_device_status(device, new_status)
_ =
if old_status != new_status do
handle_agent_status_change(device, old_status, new_status)
end
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:device_status_changed, device.id, new_status, nil}
)
Subscriptions.publish_device_status(
device.id,
device.organization_id,
%{device_name: device.name, status: to_string(new_status)}
)
end
defp handle_agent_status_change(device, _old_status, :down) do
if !Alerts.has_active_alert?(device.id, :device_down) do
now = Towerops.Time.now()
message =
if device.snmp_enabled,
do: "Device is not responding to SNMP",
else: "Device is not responding to ping"
_ =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: now,
message: message
})
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{device.organization_id}:new",
{:new_alert, device.id, :device_down}
)
end
end
defp handle_agent_status_change(device, _old_status, :up) do
now = Towerops.Time.now()
message =
if device.snmp_enabled,
do: "Device is now responding to SNMP",
else: "Device is now responding to ping"
_ =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_up,
triggered_at: now,
resolved_at: now,
message: message
})
case Alerts.get_active_alert(device.id, :device_down) do
nil -> :ok
alert -> Alerts.resolve_alert(alert)
end
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{device.organization_id}:resolved",
{:alert_resolved, device.id, :device_down}
)
end
# Resolve any stuck alerts for a device that's currently up
# This handles edge cases where device is UP but alerts didn't resolve (e.g., due to bugs)
defp resolve_any_stuck_down_alerts(device) do
now = Towerops.Time.now()
# Resolve stuck device_down alerts
_ =
case Alerts.get_active_alert(device.id, :device_down) do
nil ->
:ok
alert ->
Logger.info(
"Resolving stuck device_down alert for #{device.name} - device is UP but alert was not resolved",
device_id: device.id,
alert_id: alert.id
)
# Create device_up alert (informational)
message =
if device.snmp_enabled,
do: "Device is now responding to SNMP",
else: "Device is now responding to ping"
_ =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_up,
triggered_at: now,
resolved_at: now,
message: message
})
# Resolve the stuck alert
Alerts.resolve_alert(alert)
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{device.organization_id}:resolved",
{:alert_resolved, device.id, :device_down}
)
end
# Also resolve any stuck device_up alerts (should have been created with resolved_at)
case Alerts.get_active_alert(device.id, :device_up) do
nil ->
:ok
alert ->
Logger.info(
"Resolving stuck device_up alert for #{device.name} - should have been auto-resolved",
device_id: device.id,
alert_id: alert.id
)
Alerts.resolve_alert(alert)
end
:ok
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_job_result(device, %{job_type: :PING} = result, socket) do
# Backward compatibility: older agents send PING results as SnmpResult instead of MonitoringCheck
# Newer agents should use the "monitoring_check" event instead
Logger.warning("Agent sent PING result as SnmpResult (deprecated). Agent should be upgraded.",
agent_token_id: socket.assigns.agent_token_id,
device_id: device.id,
job_id: result.job_id
)
# PING jobs sent as SnmpResult have empty oid_values
# If we receive a result (not an error), assume success
check = %{
device_id: device.id,
status: "success",
# Unknown - older agents don't send this
response_time_ms: 0.0,
timestamp: result.timestamp
}
store_monitoring_check(check, socket)
end
defp process_discovery_result(device, result, socket) do
Logger.info("Discovery results received from agent for #{device.name}, processing data")
# Process discovery data first, only update last_discovery_at on success.
# This ensures failed discoveries are retried on the next poll cycle
# instead of being blocked for 24 hours.
process_discovery_data(device, result, socket)
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} ->
# Only mark discovery timestamp on success so failures are retried
# on the next poll cycle instead of waiting 24 hours
_ = Devices.update_device(device, %{last_discovery_at: DateTime.utc_now()})
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}
)
# Poll the device immediately so it doesn't sit idle until the next cycle
send(self(), {:poll_after_discovery, 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)
)
{:error, reason}
end
end
defp process_polling_result(device, result, _socket) do
Devices.update_snmp_poll_time(device)
# Successful SNMP poll means device is up - update status and resolve any stuck alerts
old_status = device.status
new_status = :up
# Update device status if needed
if old_status != new_status do
Devices.update_device_status(device, new_status)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:device_status_changed, device.id, new_status, nil}
)
Subscriptions.publish_device_status(
device.id,
device.organization_id,
%{device_name: device.name, status: to_string(new_status)}
)
end
# Always check for and resolve stuck device_down alerts after successful poll
# This handles edge cases where alerts got stuck (device already UP but alert not resolved)
resolve_any_stuck_down_alerts(device)
snmp_device = device.snmp_device
oid_values = normalize_oid_keys(result.oid_values)
# Use server time to avoid clock drift issues between agents
timestamp = Towerops.Time.now()
Logger.info(
"Processing polling result for #{device.name}: #{map_size(oid_values)} OIDs, #{length(snmp_device.sensors)} sensors, #{length(snmp_device.interfaces)} interfaces"
)
# Batch insert sensor readings and process metadata updates
process_sensor_readings_batch(snmp_device.sensors, oid_values, timestamp)
# Publish sensor readings to GraphQL subscriptions
publish_sensor_readings_to_graphql(device, snmp_device.sensors, oid_values, timestamp)
# Batch insert interface stats
_ = process_interface_stats_batch(snmp_device.interfaces, oid_values, timestamp)
# Process complex SNMP data (neighbors, ARP, MAC, IP addresses, processors, storage)
# using Replay adapter to reuse existing parsing logic
_ =
if map_size(oid_values) > length(snmp_device.sensors) + length(snmp_device.interfaces) * 6 do
process_additional_polling_data(device, oid_values)
end
# Notify LiveViews that new sensor/interface data is available for graphing
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:state_sensors_updated, device.id}
)
end
# Process neighbors, ARP, MAC, IP addresses, processors, storage from agent polling
defp process_additional_polling_data(device, oid_values) do
# Build Replay adapter opts to reuse existing parsing logic
client_opts = [
ip: device.ip_address,
adapter: Towerops.Snmp.Adapters.Replay,
oid_map: oid_values
]
# Reuse existing polling functions with Replay adapter.
# Runs in a spawned process to avoid blocking the channel.
device_id = device.id
device_name = device.name
snmp_device_id = device.snmp_device.id
Task.Supervisor.start_child(Towerops.TaskSupervisor, fn ->
try do
alias Towerops.Snmp.ArpDiscovery
alias Towerops.Snmp.MacDiscovery
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.Profiles.Base
# Fetch interfaces once for reuse across multiple operations
interfaces = Snmp.list_interfaces(snmp_device_id)
# Add device_id to interfaces for neighbor discovery (required by build_neighbor_record)
interfaces_with_device_id = Enum.map(interfaces, &Map.put(&1, :device_id, device_id))
# Process neighbors (LLDP/CDP)
process_neighbors(client_opts, device_id, interfaces_with_device_id)
# Process ARP entries
process_arp_entries(client_opts, device_id, interfaces)
# Process MAC addresses
_ = process_mac_addresses(client_opts, device_id, interfaces)
# Process IP addresses
process_ip_addresses(client_opts, device_id, interfaces)
# Process processors
process_processors(client_opts, snmp_device_id)
# Process storage
process_storage(client_opts, snmp_device_id)
rescue
e ->
Logger.error(
"Polling data processing crashed for device #{device_name}: #{Exception.message(e)}",
device_id: device_id,
error: Exception.format(:error, e, __STACKTRACE__)
)
end
end)
end
defp process_neighbors(client_opts, device_id, interfaces_with_device_id) do
alias Towerops.Snmp.NeighborDiscovery
case NeighborDiscovery.discover_neighbors(client_opts, interfaces_with_device_id) do
{:ok, neighbors} when neighbors != [] ->
Discovery.save_neighbors(device_id, neighbors)
_ ->
:ok
end
end
defp process_arp_entries(client_opts, device_id, interfaces) do
alias Towerops.Snmp.ArpDiscovery
case ArpDiscovery.discover_arp_table(client_opts) do
{:ok, arp_entries} when arp_entries != [] ->
Discovery.save_arp_entries(device_id, arp_entries, interfaces)
_ ->
:ok
end
end
defp process_mac_addresses(client_opts, device_id, interfaces) do
alias Towerops.Snmp.MacDiscovery
case MacDiscovery.discover_mac_table(client_opts) do
{:ok, mac_addresses} when mac_addresses != [] ->
Snmp.upsert_mac_addresses(device_id, mac_addresses, interfaces)
_ ->
:ok
end
end
defp process_ip_addresses(client_opts, device_id, interfaces) do
alias Towerops.Snmp.Profiles.Base
case Base.discover_all_ip_addresses(client_opts) do
{:ok, ip_addresses} when ip_addresses != [] ->
discovered_device = %{
device_id: device_id,
interfaces: interfaces
}
Discovery.sync_ip_addresses(discovered_device, ip_addresses)
_ ->
:ok
end
end
defp process_processors(client_opts, snmp_device_id) do
alias Towerops.Snmp.Profiles.Base
case Base.discover_processors(client_opts) do
{:ok, processors} when processors != [] ->
discovered_device = %{id: snmp_device_id}
Discovery.sync_processors(discovered_device, processors)
_ ->
:ok
end
end
defp process_storage(client_opts, snmp_device_id) do
alias Towerops.Snmp.Profiles.Base
case Base.discover_storage(client_opts) do
{:ok, storage} when storage != [] ->
discovered_device = %{id: snmp_device_id}
Discovery.sync_storage(discovered_device, storage)
_ ->
:ok
end
end
defp process_sensor_readings_batch(sensors, oid_values, timestamp) do
# Build reading entries and collect sensor updates in one pass
{reading_entries, sensor_updates} =
Enum.reduce(sensors, {[], []}, fn sensor, {readings, updates} ->
case resolve_sensor_value(sensor, oid_values) do
nil ->
{readings, updates}
final_value ->
reading = %{
sensor_id: sensor.id,
value: final_value,
status: "ok",
checked_at: timestamp
}
{[reading | readings], [{sensor, final_value} | updates]}
end
end)
# Batch insert all sensor readings in one SQL statement
_ = Snmp.create_sensor_readings_batch(reading_entries)
# Process change detection and metadata updates individually
Enum.each(sensor_updates, fn {sensor, final_value} ->
SensorChangeDetector.detect_and_broadcast(sensor, final_value, timestamp)
Snmp.update_sensor(sensor, %{
last_value: final_value,
last_checked_at: timestamp
})
end)
end
defp publish_sensor_readings_to_graphql(device, sensors, oid_values, timestamp) do
readings =
sensors
|> Enum.filter(fn sensor ->
normalized_oid = String.trim_leading(sensor.sensor_oid, ".")
Map.has_key?(oid_values, normalized_oid)
end)
|> Enum.map(fn sensor ->
normalized_oid = String.trim_leading(sensor.sensor_oid, ".")
raw_value = Map.get(oid_values, normalized_oid)
parsed = parse_float(raw_value)
value = if parsed, do: Float.round(parsed / sensor.sensor_divisor, 1)
%{
sensor_id: sensor.id,
sensor_type: sensor.sensor_type,
sensor_descr: sensor.sensor_descr,
sensor_unit: sensor.sensor_unit,
value: value,
checked_at: to_string(timestamp)
}
end)
|> Enum.reject(fn r -> is_nil(r.value) end)
if readings != [] do
Subscriptions.publish_sensor_readings(
device.id,
device.organization_id,
device.name,
readings
)
end
end
defp resolve_sensor_value(sensor, oid_values) do
normalized_oid = String.trim_leading(sensor.sensor_oid, ".")
with value when not is_nil(value) <- Map.get(oid_values, normalized_oid),
parsed when not is_nil(parsed) <- parse_float(value) do
Float.round(parsed / sensor.sensor_divisor, 1)
else
_ -> nil
end
end
defp process_interface_stats_batch(interfaces, oid_values, timestamp) do
entries =
Enum.map(interfaces, fn iface ->
idx = iface.if_index
# Prefer 64-bit HC counters; fall back to 32-bit for devices that don't support ifXTable
in_octets =
parse_integer(Map.get(oid_values, "1.3.6.1.2.1.31.1.1.1.6.#{idx}")) ||
parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.10.#{idx}"))
out_octets =
parse_integer(Map.get(oid_values, "1.3.6.1.2.1.31.1.1.1.10.#{idx}")) ||
parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.16.#{idx}"))
%{
interface_id: iface.id,
if_in_octets: in_octets,
if_out_octets: out_octets,
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)
Snmp.create_interface_stats_batch(entries)
end
defp parse_integer(value) do
case Towerops.Numeric.parse_integer(value) do
{:ok, i} -> i
:error -> nil
end
end
# Normalize OID keys to strip leading dots.
# The agent (via gosnmp/net-snmp) returns OIDs with leading dots
# (e.g., ".1.3.6.1.2.1.1.1.0") but sensor OIDs and hardcoded interface
# OIDs use the format without (e.g., "1.3.6.1.2.1.1.1.0").
defp normalize_oid_keys(oid_values) do
Map.new(oid_values, fn {oid, value} ->
{String.trim_leading(oid, "."), value}
end)
end
defp parse_float(value) do
case Towerops.Numeric.parse_float(value) do
{:ok, f} -> f
:error -> nil
end
end
defp get_remote_ip(socket), do: RemoteIp.from_socket(socket)
# 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)
{:error, :backup_request_not_found}
request ->
_ = handle_backup_request(request, result)
:ok
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, new_backup} ->
BackupRequests.update_request_status(request.id, "success", nil)
Logger.info("Backup created", device_id: result.device_id, source: request.source)
# Emit config change event if there was a previous backup
maybe_emit_config_change_event(result.device_id, new_backup)
{:error, reason} ->
mark_backup_failed(request, result, "Failed to create backup: #{inspect(reason)}")
end
end
defp maybe_emit_config_change_event(device_id, new_backup) do
# Find the previous backup (second most recent)
backups = MikrotikBackups.list_device_backups(device_id, limit: 2)
case backups do
[_new, previous | _] ->
maybe_record_change(device_id, previous, new_backup)
_ ->
:ok
end
end
defp maybe_record_change(_device_id, %{config_hash_normalized: hash}, %{config_hash_normalized: hash}), do: :ok
defp maybe_record_change(device_id, previous, new_backup) do
case ConfigChanges.record_change_event(device_id, previous, new_backup) do
{:ok, %{id: event_id} = event} ->
Logger.info("Config change event recorded", device_id: device_id, event_id: event_id)
Task.start(fn -> ConfigChanges.Correlator.correlate(event) end)
{:ok, :no_changes} ->
:ok
{:error, reason} ->
Logger.warning("Failed to record config change event: #{inspect(reason)}",
device_id: device_id
)
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
defp cancel_poll_timer(socket) do
if timer = socket.assigns[:poll_timer] do
Process.cancel_timer(timer)
end
end
defp poll_interval_ms do
Application.get_env(:towerops, :agent_poll_interval_ms, 60_000)
end
## Safe Encoding/Decoding Helpers
# Safely decode Base64-encoded protobuf binary.
# Returns {:ok, binary} on success or {:error, {type, message}} on failure.
@spec safe_base64_decode(base64_string()) :: {:ok, protobuf_binary()} | {:error, {atom(), String.t()}}
defp safe_base64_decode(base64_string) when is_binary(base64_string) do
case Base.decode64(base64_string) do
{:ok, binary} ->
{:ok, binary}
:error ->
{:error, {:invalid_base64, "Message is not valid Base64-encoded data"}}
end
rescue
e in ArgumentError ->
Logger.error("Base64 decode error: #{Exception.message(e)}")
{:error, {:invalid_base64, "Failed to decode Base64 data"}}
end
end