defmodule ToweropsWeb.Plugs.AgentAuth do @moduledoc """ Plug for authenticating remote agent API requests. Validates Bearer tokens in the Authorization header and assigns the agent token to the connection. Also updates the agent's last_seen_at timestamp asynchronously. """ import Phoenix.Controller import Plug.Conn alias Ecto.Adapters.SQL.Sandbox alias Towerops.Agents @doc """ Initializes the plug with options. """ def init(opts), do: opts @doc """ Validates the agent token from the Authorization header. If valid, assigns :current_agent_token to the connection and updates heartbeat. If invalid or missing, returns 401 Unauthorized and halts the connection. """ def call(conn, _opts) do case get_req_header(conn, "authorization") do ["Bearer " <> token] -> case Agents.verify_agent_token(token) do {:ok, agent_token} -> conn |> assign(:current_agent_token, agent_token) |> update_heartbeat(agent_token) {:error, _} -> unauthorized(conn) end _ -> unauthorized(conn) end end defp update_heartbeat(conn, agent_token) do ip = get_client_ip(conn) # 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) else update_heartbeat_async(agent_token.id, ip) end conn 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 defp update_heartbeat_async(agent_token_id, ip) do parent = self() Task.start(fn -> # Allow this task to use the test database sandbox (shouldn't happen in prod) allow_sandbox(parent) Agents.update_agent_token_heartbeat(agent_token_id, ip) end) end defp allow_sandbox(parent) do if Application.get_env(:towerops, :sql_sandbox) do Sandbox.allow(Towerops.Repo, parent, self()) end end defp unauthorized(conn) do conn |> put_status(:unauthorized) |> put_view(json: ToweropsWeb.ErrorJSON) |> render(:"401") |> halt() end end