towerops/lib/towerops_web/plugs/agent_auth.ex
Graham McIntire 31b906b0fa
Fix agent IP detection to use real client IP from proxy headers
The agent's last_seen IP was showing the Docker container IP (10.244.x.x)
because conn.remote_ip returns the proxy/load balancer IP.

Changes:
- Added get_client_ip/1 helper that checks X-Forwarded-For first
- Falls back to X-Real-IP if X-Forwarded-For not present
- Falls back to conn.remote_ip as last resort
- Applied to both AgentAuth plug and AgentController heartbeat endpoint

Now correctly shows the actual external IP of the remote agent.
2026-01-14 17:04:59 -06:00

106 lines
2.9 KiB
Elixir

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