aprs.me/lib/aprsme_web/channels/mobile_user_socket.ex

78 lines
2 KiB
Elixir

defmodule AprsmeWeb.MobileUserSocket do
@moduledoc """
Socket for mobile applications (iOS/Android) to receive real-time APRS packets.
Connections are anonymous but rate-limited per peer IP to prevent a single
client from opening unbounded connections and exhausting server resources.
"""
use Phoenix.Socket
require Logger
# Channels
channel "mobile:packets", AprsmeWeb.MobileChannel
# 30 new connections per IP per minute — well above normal reconnect churn
# but tight enough to stop a script from floodting the socket layer.
@connect_limit 30
@connect_scale 60_000
@impl true
def connect(_params, socket, connect_info) do
ip = peer_ip(connect_info)
if test_env?() or allow_connect?(ip) do
ua = connect_info[:user_agent] || "unknown"
socket =
socket
|> assign(:peer_ip, ip)
|> assign(:user_agent, ua)
# Track connection for analytics (fire-and-forget)
Aprsme.PlausibleAnalytics.track(
%{user_agent: ua, remote_ip: ip, request_path: "/mobile/websocket"},
"ws_connect"
)
Aprsme.ApiMetrics.track(%{}, "ws_connect")
{:ok, socket}
else
Logger.warning("Mobile socket connect rate-limited for ip=#{ip}")
:error
end
end
defp allow_connect?(ip) do
case Aprsme.RateLimiter.hit("mobile_socket_connect:#{ip}", @connect_scale, @connect_limit) do
{:allow, _count} -> true
{:deny, _retry_after} -> false
end
end
defp test_env?, do: Application.get_env(:aprsme, :env) == :test
@impl true
def id(_socket), do: nil
defp peer_ip(%{x_headers: headers}) when is_list(headers) do
headers
|> Enum.find_value(fn
{"cf-connecting-ip", v} -> v
{"x-forwarded-for", v} -> v |> String.split(",") |> List.first() |> String.trim()
{"x-real-ip", v} -> v
_ -> nil
end)
|> case do
nil -> "unknown"
ip -> ip
end
end
defp peer_ip(%{peer_data: %{address: address}}) when not is_nil(address) do
address |> :inet.ntoa() |> to_string()
end
defp peer_ip(_), do: "unknown"
end