towerops/lib/towerops_web/channels/agent_channel.ex

347 lines
9.4 KiB
Elixir

defmodule ToweropsWeb.AgentChannel do
@moduledoc """
Phoenix channel for bidirectional agent communication.
## 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.
"""
use ToweropsWeb, :channel
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
alias Towerops.Agents
alias Towerops.Equipment
alias Towerops.Snmp
alias Towerops.Snmp.Discovery
require Logger
@impl true
def join("agent:" <> agent_token_id, _payload, socket) do
# Verify the agent token ID matches the authenticated socket
if socket.assigns.agent_token_id == agent_token_id do
# Update last_seen_at on join
Agents.update_agent_token_heartbeat(agent_token_id, nil, %{})
# Send initial job list
send(self(), :send_jobs)
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
@impl true
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)
push(socket, "jobs", {:binary, binary})
{:noreply, socket}
end
@impl true
def handle_in("result", {:binary, binary}, socket) do
result = SnmpResult.decode(binary)
process_snmp_result(socket.assigns.organization_id, result)
{:noreply, socket}
end
@impl true
def handle_in("heartbeat", {:binary, binary}, socket) do
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}, socket) do
error = AgentError.decode(binary)
Logger.error("Agent job error",
agent_token_id: socket.assigns.agent_token_id,
equipment_id: error.equipment_id,
error: error.message
)
{:noreply, socket}
end
# Private helpers
defp build_jobs_for_agent(agent_token_id) do
agent_token_id
|> Agents.list_agent_polling_targets()
|> Enum.map(&build_job_for_equipment/1)
end
defp build_job_for_equipment(equipment) do
if needs_discovery?(equipment) do
build_discovery_job(equipment)
else
build_polling_job(equipment)
end
end
defp needs_discovery?(equipment) do
# Need discovery if no SNMP device or last discovery was >24 hours ago
is_nil(equipment.snmp_device) or
is_nil(equipment.last_discovery_at) or
DateTime.diff(DateTime.utc_now(), equipment.last_discovery_at, :hour) > 24
end
defp build_discovery_job(equipment) do
%AgentJob{
job_id: "discover:#{equipment.id}",
job_type: :DISCOVER,
equipment_id: equipment.id,
device: %SnmpDevice{
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port || 161
},
queries: build_discovery_queries()
}
end
defp build_polling_job(equipment) do
device = equipment.snmp_device
%AgentJob{
job_id: "poll:#{equipment.id}",
job_type: :POLL,
equipment_id: equipment.id,
device: %SnmpDevice{
ip: equipment.ip_address,
community: equipment.snmp_community,
version: equipment.snmp_version,
port: equipment.snmp_port || 161
},
queries: build_polling_queries(device)
}
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"]
},
# 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.sensors, & &1.sensor_oid)
interface_oids =
Enum.flat_map(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 process_snmp_result(organization_id, result) do
case Equipment.get_equipment_with_details(result.equipment_id) do
nil ->
Logger.error("Equipment not found: #{result.equipment_id}")
equipment ->
# Verify equipment belongs to agent's organization
if equipment.organization_id == organization_id do
case result.job_type do
:DISCOVER -> process_discovery_result(equipment, result)
:POLL -> process_polling_result(equipment, result)
end
else
Logger.error("Equipment #{result.equipment_id} not in agent's organization")
end
end
end
defp process_discovery_result(equipment, _result) do
# Agent confirmed device is reachable, trigger full discovery from server
# This hybrid approach simplifies the initial implementation:
# - Agent acts as remote executor
# - Server does SNMP queries and parsing using existing logic
Logger.info("Discovery results received for #{equipment.name}, triggering full discovery")
Task.start(fn ->
case Discovery.discover_equipment(equipment) do
{:ok, _device} ->
Logger.info("Full discovery completed for #{equipment.name}")
{:error, reason} ->
Logger.error("Discovery failed for #{equipment.name}: #{inspect(reason)}")
end
end)
end
defp process_polling_result(equipment, result) do
device = equipment.snmp_device
oid_values = Map.new(result.oid_values)
timestamp = DateTime.from_unix!(result.timestamp, :second)
# Process sensor readings
Enum.each(device.sensors, fn sensor ->
if value = Map.get(oid_values, sensor.sensor_oid) do
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: parse_float(value) / sensor.sensor_divisor,
status: "ok",
checked_at: timestamp
})
end
end)
# Process interface stats
Enum.each(device.interfaces, fn iface ->
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)
# Note: Neighbor discovery is handled by separate polling worker
# since it requires complex LLDP/CDP parsing
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(nil), 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
end