135 lines
4.1 KiB
Elixir
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
|