towerops/lib/towerops_web/live/admin/security_live/index.ex
Graham McIntire 4d73e77f3a
Add 404-based brute force protection system
Implements automated detection and blocking of IPs exhibiting scanning behavior (5+ unique 404s within 1 minute).

Key features:
- Progressive ban escalation (5 min → 1 hour → permanent)
- CIDR range and exact IP whitelisting
- Redis-backed 404 path tracking with 60s TTL
- Cloudflare WAF integration for permanent bans
- Admin UI for whitelist and blocked IP management
- Oban cron jobs for cleanup (expired bans, stale violations)
- ETS caching for whitelist performance

Components:
- Plug: ToweropsWeb.Plugs.BruteForceProtection
- Context: Towerops.Security.BruteForce
- Schemas: IpBlock, IpWhitelist
- Workers: CloudflareBanWorker, ExpiredBanCleanupWorker, StaleViolationCleanupWorker
- Admin UI: /admin/security (superuser only)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 16:31:48 -06:00

129 lines
3.7 KiB
Elixir

defmodule ToweropsWeb.Admin.SecurityLive.Index do
@moduledoc """
Admin page for managing IP-based brute force protection.
Provides two main functions:
1. Whitelist management - Add/remove IPs and CIDR ranges that bypass protection
2. Block management - View and manually unblock banned IPs
"""
use ToweropsWeb, :live_view
alias Towerops.Security.BruteForce
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "security:whitelist")
Phoenix.PubSub.subscribe(Towerops.PubSub, "security:blocks")
end
{:ok,
socket
|> assign(:page_title, "Security - Brute Force Protection")
|> assign(:current_tab, "whitelist")}
end
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "whitelist")
filter = Map.get(params, "filter", "all")
{:noreply,
socket
|> assign(:current_tab, tab)
|> assign(:filter, filter)
|> load_data()}
end
@impl true
def handle_event("change_tab", %{"tab" => tab}, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/security?tab=#{tab}")}
end
def handle_event("change_filter", %{"filter" => filter}, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/security?tab=#{socket.assigns.current_tab}&filter=#{filter}")}
end
def handle_event("show_whitelist_form", _params, socket) do
{:noreply,
socket
|> assign(:show_whitelist_form, true)
|> assign(:whitelist_form, to_form(%{}))}
end
def handle_event("hide_whitelist_form", _params, socket) do
{:noreply, assign(socket, :show_whitelist_form, false)}
end
def handle_event("add_whitelist", %{"whitelist" => params}, socket) do
case BruteForce.add_to_whitelist(
params["ip_or_cidr"],
params["description"],
socket.assigns.current_user
) do
{:ok, _entry} ->
{:noreply,
socket
|> put_flash(:info, "IP/CIDR added to whitelist")
|> assign(:show_whitelist_form, false)
|> load_data()}
{:error, changeset} ->
{:noreply,
socket
|> assign(:whitelist_form, to_form(Map.put(changeset, :action, :validate)))
|> put_flash(:error, "Failed to add to whitelist")}
end
end
def handle_event("remove_whitelist", %{"id" => id}, socket) do
case BruteForce.remove_from_whitelist(id) do
:ok ->
{:noreply,
socket
|> put_flash(:info, "Removed from whitelist")
|> load_data()}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to remove from whitelist")}
end
end
def handle_event("unblock_ip", %{"ip" => ip_address}, socket) do
case BruteForce.manually_unblock(ip_address) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "IP #{ip_address} unblocked")
|> load_data()}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to unblock IP")}
end
end
@impl true
def handle_info(:whitelist_updated, socket) do
{:noreply, load_data(socket)}
end
def handle_info(:blocks_updated, socket) do
{:noreply, load_data(socket)}
end
defp load_data(socket) do
whitelist = BruteForce.list_whitelist()
blocked_ips =
case socket.assigns[:filter] || "all" do
"permanent" -> BruteForce.list_blocked_ips(:permanent)
"temporary" -> BruteForce.list_blocked_ips(:temporary)
_ -> BruteForce.list_blocked_ips(:all)
end
socket
|> assign(:whitelist, whitelist)
|> assign(:blocked_ips, blocked_ips)
|> assign(:show_whitelist_form, Map.get(socket.assigns, :show_whitelist_form, false))
end
end