towerops/lib/towerops_web/controllers/api/agent_controller.ex

386 lines
11 KiB
Elixir

defmodule ToweropsWeb.Api.AgentController do
@moduledoc """
API controller for remote agent communication.
Provides endpoints for agents to:
- Fetch polling configuration
- Submit metrics
- Send heartbeats
"""
use ToweropsWeb, :controller
alias Towerops.Agent.AgentConfig
alias Towerops.Agent.HeartbeatMetadata
alias Towerops.Agent.HeartbeatResponse
alias Towerops.Agent.MetricBatch
alias Towerops.Agents
alias Towerops.Snmp
@doc """
GET /api/v1/agent/config
Returns polling configuration for all equipment that should be polled by this agent.
This includes directly assigned equipment plus equipment that inherits the agent
from site or organization defaults.
Supports both JSON and Protocol Buffers response formats based on Accept header.
"""
def get_config(conn, _params) do
agent_token = conn.assigns.current_agent_token
equipment_list = Agents.list_agent_polling_targets(agent_token.id)
config = %{
version: "1.0",
poll_interval_seconds: 60,
equipment: Enum.map(equipment_list, &build_equipment_config/1)
}
# Check Accept header for protobuf support
case get_req_header(conn, "accept") do
["application/x-protobuf" | _] ->
# Convert to protobuf and send
proto_config = convert_config_to_proto(config)
binary = AgentConfig.encode(proto_config)
conn
|> put_resp_content_type("application/x-protobuf")
|> send_resp(200, binary)
_ ->
# Default to JSON
json(conn, config)
end
end
@doc """
POST /api/v1/agent/metrics
Accepts a batch of metrics from the agent and processes them asynchronously.
Supports both JSON (legacy) and Protocol Buffers (efficient).
"""
def submit_metrics(conn, params) do
agent_token = conn.assigns.current_agent_token
metrics =
case get_req_header(conn, "content-type") do
["application/x-protobuf" | _] ->
# Decode protobuf
{:ok, body, _conn} = Plug.Conn.read_body(conn)
batch = MetricBatch.decode(body)
convert_protobuf_metrics(batch.metrics)
_ ->
# JSON format (fallback)
Map.get(params, "metrics", [])
end
_ =
Task.start(fn ->
process_metrics(agent_token, metrics)
end)
json(conn, %{status: "accepted", received: length(metrics)})
end
@doc """
POST /api/v1/agent/heartbeat
Updates the agent's last_seen_at timestamp and metadata.
Supports both JSON and Protocol Buffers formats.
"""
def heartbeat(conn, params) do
require Logger
agent_token = conn.assigns.current_agent_token
ip = get_client_ip(conn)
# Log heartbeat request for debugging intermittent 502 errors
Logger.debug(
"Heartbeat received from agent",
agent_token_id: agent_token.id,
client_ip: ip,
content_type: List.first(get_req_header(conn, "content-type")),
request_id: conn.assigns[:request_id]
)
# Parse metadata based on content type
metadata =
case get_req_header(conn, "content-type") do
["application/x-protobuf" | _] ->
# Decode protobuf
case Plug.Conn.read_body(conn) do
{:ok, body, _conn} ->
try do
proto_metadata = HeartbeatMetadata.decode(body)
%{
"version" => proto_metadata.version,
"hostname" => proto_metadata.hostname,
"uptime_seconds" => proto_metadata.uptime_seconds
}
rescue
e ->
require Logger
Logger.error("Failed to decode protobuf heartbeat: #{inspect(e)}")
# Return empty metadata on decode error
%{}
end
{:error, reason} ->
require Logger
Logger.error("Failed to read heartbeat body: #{inspect(reason)}")
%{}
end
_ ->
# JSON format (fallback)
%{
"version" => Map.get(params, "version"),
"hostname" => Map.get(params, "hostname"),
"uptime_seconds" => Map.get(params, "uptime_seconds")
}
end
# In test environment, update synchronously to avoid DB ownership issues
# In production, update asynchronously for better performance
if Application.get_env(:towerops, :env) == :test do
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
else
Task.start(fn ->
try do
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
rescue
e ->
Logger.error(
"Failed to update agent heartbeat",
agent_token_id: agent_token.id,
error: inspect(e),
stacktrace: Exception.format_stacktrace(__STACKTRACE__)
)
end
end)
end
# Return response in appropriate format
case get_req_header(conn, "content-type") do
["application/x-protobuf" | _] ->
proto_response = %HeartbeatResponse{status: "ok"}
binary = HeartbeatResponse.encode(proto_response)
conn
|> put_resp_content_type("application/x-protobuf")
|> send_resp(200, binary)
_ ->
json(conn, %{status: "ok"})
end
end
# Private helpers
defp build_equipment_config(equipment) do
device = equipment.snmp_device
%{
id: equipment.id,
name: equipment.name,
ip_address: equipment.ip_address,
snmp: %{
enabled: equipment.snmp_enabled,
version: equipment.snmp_version,
community: equipment.snmp_community,
port: equipment.snmp_port || 161
},
poll_interval_seconds: equipment.check_interval_seconds || 60,
sensors: build_sensor_config(device),
interfaces: build_interface_config(device)
}
end
defp build_sensor_config(nil), do: []
defp build_sensor_config(device) do
Enum.map(device.sensors, fn sensor ->
%{
id: sensor.id,
type: sensor.sensor_type,
oid: sensor.sensor_oid,
divisor: sensor.sensor_divisor,
unit: sensor.sensor_unit,
metadata: sensor.metadata
}
end)
end
defp build_interface_config(nil), do: []
defp build_interface_config(device) do
Enum.map(device.interfaces, fn interface ->
%{
id: interface.id,
if_index: interface.if_index,
if_name: interface.if_name
}
end)
end
defp process_metrics(_agent_token, metrics) do
Enum.each(metrics, fn metric ->
case metric do
%{"type" => "sensor_reading"} = m ->
Snmp.create_sensor_reading(%{
sensor_id: m["sensor_id"],
value: m["value"],
status: m["status"] || "ok",
checked_at: parse_timestamp(m["timestamp"])
})
%{"type" => "interface_stat"} = m ->
Snmp.create_interface_stat(%{
interface_id: m["interface_id"],
if_in_octets: m["if_in_octets"],
if_out_octets: m["if_out_octets"],
if_in_errors: m["if_in_errors"],
if_out_errors: m["if_out_errors"],
if_in_discards: m["if_in_discards"],
if_out_discards: m["if_out_discards"],
checked_at: parse_timestamp(m["timestamp"])
})
_ ->
:ok
end
end)
end
defp convert_protobuf_metrics(metrics) do
metrics
|> Enum.map(fn metric ->
case metric.metric_type do
{:sensor_reading, sr} ->
%{
"type" => "sensor_reading",
"sensor_id" => sr.sensor_id,
"value" => sr.value,
"status" => sr.status,
"timestamp" => sr.timestamp
}
{:interface_stat, is} ->
%{
"type" => "interface_stat",
"interface_id" => is.interface_id,
"if_in_octets" => is.if_in_octets,
"if_out_octets" => is.if_out_octets,
"if_in_errors" => is.if_in_errors,
"if_out_errors" => is.if_out_errors,
"if_in_discards" => is.if_in_discards,
"if_out_discards" => is.if_out_discards,
"timestamp" => is.timestamp
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp parse_timestamp(nil), do: DateTime.truncate(DateTime.utc_now(), :second)
defp parse_timestamp(ts) when is_integer(ts) do
ts
|> DateTime.from_unix!(:second)
|> DateTime.truncate(:second)
end
defp parse_timestamp(ts) when is_binary(ts) do
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> DateTime.truncate(dt, :second)
_ -> DateTime.truncate(DateTime.utc_now(), :second)
end
end
# Convert internal config format to protobuf
defp convert_config_to_proto(config) do
%AgentConfig{
version: config.version,
poll_interval_seconds: config.poll_interval_seconds,
equipment: Enum.map(config.equipment, &convert_equipment_to_proto/1)
}
end
defp convert_equipment_to_proto(eq) do
%Towerops.Agent.Equipment{
id: eq.id,
name: eq.name,
ip_address: eq.ip_address,
snmp: convert_snmp_to_proto(eq.snmp),
poll_interval_seconds: eq.poll_interval_seconds,
sensors: Enum.map(eq.sensors, &convert_sensor_to_proto/1),
interfaces: Enum.map(eq.interfaces, &convert_interface_to_proto/1)
}
end
defp convert_snmp_to_proto(snmp) do
%Towerops.Agent.SnmpConfig{
enabled: snmp.enabled,
version: snmp.version,
community: snmp.community,
port: snmp.port
}
end
defp convert_sensor_to_proto(sensor) do
%Towerops.Agent.Sensor{
id: sensor.id,
type: sensor.type,
oid: sensor.oid,
divisor: sensor.divisor || 0.0,
unit: sensor.unit || "",
metadata: convert_metadata_to_proto(sensor.metadata)
}
end
defp convert_interface_to_proto(interface) do
%Towerops.Agent.Interface{
id: interface.id,
if_index: interface.if_index,
if_name: interface.if_name
}
end
defp convert_metadata_to_proto(nil), do: %{}
defp convert_metadata_to_proto(metadata) when is_map(metadata) do
Map.new(metadata, fn {k, v} -> {to_string(k), to_string(v)} end)
end
# Get the real client IP, checking forwarded headers first
defp get_client_ip(conn) do
# Check X-Forwarded-For header first (set by load balancers/proxies)
case get_req_header(conn, "x-forwarded-for") do
[forwarded | _] ->
# X-Forwarded-For can have multiple IPs (client, proxy1, proxy2...)
# Take the first one (original client)
forwarded
|> String.split(",")
|> List.first()
|> String.trim()
[] ->
# Fall back to X-Real-IP
case get_req_header(conn, "x-real-ip") do
[real_ip | _] ->
String.trim(real_ip)
[] ->
# Fall back to conn.remote_ip
to_string(:inet_parse.ntoa(conn.remote_ip))
end
end
end
end