aprs.me/lib/aprsme_web/channels/mobile_user_socket.ex
Graham McIntire 09f92c6738
feat(mobile): rate-limit socket + batch historical packet delivery
- MobileUserSocket: cap new socket connections per IP at 30/min via
  Aprsme.RateLimiter. Denials log and return :error at handshake.
  Tests bypass the check (no real peer_data).

- MobileChannel: rate-limit the expensive client-initiated handlers
  (subscribe_bounds, subscribe_callsign, search_callsign) at
  30/minute per socket. Streaming packet delivery is unaffected.

- MobileChannel: historical packet loads now arrive as a batched
  `packets` event (100 per frame) instead of one `packet` frame per
  row. Live streaming still uses the single `packet` event.

- Updated docs/mobile-api.md with the `packets` event and rate-limit
  semantics; migration note preserves compatibility for clients that
  only handle live `packet` events.
2026-04-21 09:38:51 -05:00

63 lines
1.7 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
{:ok, assign(socket, :peer_ip, ip)}
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