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.
106 lines
2.9 KiB
Elixir
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
|