defmodule Towerops.Security.CloudflareClient do @moduledoc """ Client for interacting with Cloudflare's API to manage IP Access Rules (WAF). Used to push permanent IP bans to Cloudflare's edge network for pre-application blocking of brute force scanners. """ require Logger @base_url "https://api.cloudflare.com/client/v4" @doc """ Blocks an IP address at the Cloudflare edge using WAF IP Access Rules. Returns `{:ok, response}` on success. Returns `{:error, reason}` on failure. """ def block_ip(ip_address) do with {:ok, zone_id} <- get_config(:cloudflare_zone_id), {:ok, api_token} <- get_config(:cloudflare_api_token) do body = %{ mode: "block", configuration: %{ target: "ip", value: ip_address }, notes: "Towerops brute force protection - permanent ban" } url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules" case Req.post(url, json: body, headers: [ {"authorization", "Bearer #{api_token}"}, {"content-type", "application/json"} ] ) do {:ok, %{status: status, body: response}} when status in 200..299 -> Logger.info("Successfully blocked IP at Cloudflare: #{ip_address}") {:ok, response} {:ok, %{status: status, body: body}} -> Logger.error("Cloudflare API error (#{status}): #{inspect(body)}") {:error, "API returned status #{status}"} {:error, reason} -> Logger.error("Failed to call Cloudflare API: #{inspect(reason)}") {:error, reason} end else {:error, :missing_config} -> Logger.warning("Cloudflare credentials not configured, skipping edge block for #{ip_address}") {:error, :not_configured} end end @doc """ Unblocks an IP address by finding and deleting the corresponding WAF rule. Note: This requires listing all rules to find the matching one, which may be slow. """ def unblock_ip(ip_address) do with {:ok, zone_id} <- get_config(:cloudflare_zone_id), {:ok, api_token} <- get_config(:cloudflare_api_token), {:ok, rule_id} <- find_rule_by_ip(zone_id, api_token, ip_address) do url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules/#{rule_id}" case Req.delete(url, headers: [ {"authorization", "Bearer #{api_token}"} ] ) do {:ok, %{status: status}} when status in 200..299 -> Logger.info("Successfully unblocked IP at Cloudflare: #{ip_address}") {:ok, :deleted} {:ok, %{status: status, body: body}} -> Logger.error("Cloudflare API error (#{status}): #{inspect(body)}") {:error, "API returned status #{status}"} {:error, reason} -> Logger.error("Failed to call Cloudflare API: #{inspect(reason)}") {:error, reason} end else {:error, :not_found} -> Logger.warning("No Cloudflare rule found for IP: #{ip_address}") {:error, :not_found} {:error, :missing_config} -> {:error, :not_configured} error -> error end end defp find_rule_by_ip(zone_id, api_token, ip_address) do url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules" case Req.get(url, headers: [ {"authorization", "Bearer #{api_token}"} ], params: [ {"configuration.value", ip_address}, {"mode", "block"} ] ) do {:ok, %{status: 200, body: %{"result" => [rule | _]}}} -> {:ok, rule["id"]} {:ok, %{status: 200, body: %{"result" => []}}} -> {:error, :not_found} {:ok, %{status: status, body: body}} -> Logger.error("Cloudflare API error (#{status}): #{inspect(body)}") {:error, "API returned status #{status}"} {:error, reason} -> {:error, reason} end end defp get_config(key) do case Application.get_env(:towerops, key) do nil -> {:error, :missing_config} value -> {:ok, value} end end end