From 31b906b0fa28cf1abeb29f1521c09ea36a5f0c53 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 14 Jan 2026 17:04:59 -0600 Subject: [PATCH] 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. --- .../controllers/api/agent_controller.ex | 27 ++++++++++++++++++- lib/towerops_web/plugs/agent_auth.ex | 27 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/towerops_web/controllers/api/agent_controller.ex b/lib/towerops_web/controllers/api/agent_controller.ex index f220acdd..75a76fa0 100644 --- a/lib/towerops_web/controllers/api/agent_controller.ex +++ b/lib/towerops_web/controllers/api/agent_controller.ex @@ -91,7 +91,7 @@ defmodule ToweropsWeb.Api.AgentController do """ def heartbeat(conn, params) do agent_token = conn.assigns.current_agent_token - ip = to_string(:inet_parse.ntoa(conn.remote_ip)) + ip = get_client_ip(conn) # Parse metadata based on content type metadata = @@ -320,4 +320,29 @@ defmodule ToweropsWeb.Api.AgentController 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 diff --git a/lib/towerops_web/plugs/agent_auth.ex b/lib/towerops_web/plugs/agent_auth.ex index e0657144..3e952642 100644 --- a/lib/towerops_web/plugs/agent_auth.ex +++ b/lib/towerops_web/plugs/agent_auth.ex @@ -42,7 +42,7 @@ defmodule ToweropsWeb.Plugs.AgentAuth do end defp update_heartbeat(conn, agent_token) do - ip = to_string(:inet_parse.ntoa(conn.remote_ip)) + ip = get_client_ip(conn) # In test environment, update synchronously to avoid DB ownership issues # In production, update asynchronously for better performance @@ -55,6 +55,31 @@ defmodule ToweropsWeb.Plugs.AgentAuth do 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()