defmodule BreakGlass.RateLimiter do
@moduledoc """
GenServer that owns the `:break_glass_rate_limit` ETS table and serialises
all write operations to prevent TOCTOU races.
Tracks failed authentication attempts per IP address. When the attempt count
reaches `:max_attempts`, the IP is locked out for `:lockout_seconds`.
## Configuration
- `:max_attempts` — number of failures before lockout (default: `5`)
- `:lockout_seconds` — lockout window duration in seconds (default: `900`)
## ETS Table
Table name: `:break_glass_rate_limit`
Record format: `{ip :: String.t(), attempt_count :: non_neg_integer(), locked_at :: integer() | nil}`
- `locked_at` is `nil` until the attempt count reaches `:max_attempts`.
- Once set, `locked_at` is never reset by subsequent failures — the lockout clock does not slide.
- `reset_attempts/1` deletes the row entirely, removing both the count and lockout.
Hot-path reads (`locked_out?/1`, `remaining_attempts/1`) query ETS directly
without a GenServer round-trip.
"""
use GenServer
require Logger
@table :break_glass_rate_limit
@default_max_attempts 5
@default_lockout_seconds 900
# Sweep every 60 seconds
@sweep_interval_ms 60_000
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@doc """
Starts the RateLimiter GenServer and registers it under `#{__MODULE__}`.
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Records a failed authentication attempt for `ip`.
If the resulting attempt count reaches `:max_attempts`, a lockout timestamp
is stored and a `Logger.warning` is emitted with the IP and count.
Returns `:ok`.
"""
@spec record_failed_attempt(ip :: String.t()) :: :ok
def record_failed_attempt(ip) do
GenServer.call(__MODULE__, {:record_failed_attempt, ip})
end
@doc """
Resets the failed attempt counter and any lockout for `ip` by deleting the
ETS row entirely.
Returns `:ok`.
"""
@spec reset_attempts(ip :: String.t()) :: :ok
def reset_attempts(ip) do
GenServer.call(__MODULE__, {:reset_attempts, ip})
end
@doc """
Returns `true` if `ip` is currently locked out (lockout window still active),
`false` otherwise.
Reads ETS directly — no GenServer round-trip.
"""
@spec locked_out?(ip :: String.t()) :: boolean()
def locked_out?(ip) do
lockout_seconds = fetch_lockout_seconds()
now = System.system_time(:second)
case :ets.lookup(@table, ip) do
[{^ip, _count, locked_at}] when is_integer(locked_at) ->
now < locked_at + lockout_seconds
_ ->
false
end
rescue
_ -> false
end
@doc """
Returns the number of remaining authentication attempts for `ip`.
Reads ETS directly — no GenServer round-trip.
Returns the configured `:max_attempts` (default `#{@default_max_attempts}`) when no
failures have been recorded for `ip`.
"""
@spec remaining_attempts(ip :: String.t()) :: non_neg_integer()
def remaining_attempts(ip) do
max = fetch_max_attempts()
case :ets.lookup(@table, ip) do
[{^ip, count, _locked_at}] ->
max(max - count, 0)
_ ->
max
end
rescue
_ -> @default_max_attempts
end
# ---------------------------------------------------------------------------
# GenServer callbacks
# ---------------------------------------------------------------------------
@impl true
def init(_opts) do
:ets.new(@table, [:set, :protected, :named_table, {:read_concurrency, true}])
schedule_sweep()
{:ok, %{}}
end
@impl true
def handle_call({:record_failed_attempt, ip}, _from, state) do
max_attempts = fetch_max_attempts()
new_count =
case :ets.lookup(@table, ip) do
[{^ip, count, _locked_at}] -> count + 1
[] -> 1
end
if new_count >= max_attempts do
locked_at = System.system_time(:second)
:ets.insert(@table, {ip, new_count, locked_at})
Logger.warning("[BreakGlass] IP #{ip} locked out after #{new_count} failed attempts")
else
:ets.insert(@table, {ip, new_count, nil})
end
{:reply, :ok, state}
end
@impl true
def handle_call({:reset_attempts, ip}, _from, state) do
:ets.delete(@table, ip)
{:reply, :ok, state}
end
@impl true
def handle_info(:sweep, state) do
lockout_seconds = fetch_lockout_seconds()
now = System.system_time(:second)
expired =
:ets.select(@table, [
{{:"$1", :"$2", :"$3"},
[
{:is_integer, :"$3"},
{:<, {:+, :"$3", lockout_seconds}, now}
], [:"$1"]}
])
Enum.each(expired, fn ip -> :ets.delete(@table, ip) end)
schedule_sweep()
{:noreply, state}
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp fetch_max_attempts do
Application.get_env(:break_glass_ex, :max_attempts, @default_max_attempts)
end
defp fetch_lockout_seconds do
Application.get_env(:break_glass_ex, :lockout_seconds, @default_lockout_seconds)
end
defp schedule_sweep do
Process.send_after(self(), :sweep, @sweep_interval_ms)
end
end