towerops/lib/towerops/security/cloudflare_client.ex

135 lines
4.1 KiB
Elixir

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.
"""
alias Towerops.HTTP
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 HTTP.post(__MODULE__, 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 HTTP.delete(__MODULE__, 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 HTTP.get(__MODULE__, 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