Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions ansible/cloudflare/cloudflare_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env python3

"""
Cloudflare Rules Export / Import Script

Usage:
Export rules:
python cloudflare_rules.py export <API_TOKEN> <ZONE_ID>

Import rules:
python cloudflare_rules.py import <API_TOKEN> <ZONE_ID>

Alternatively, set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID
environment variables and run:
python cloudflare_rules.py export
"""

import json
import os
import sys

import requests


def usage():
print("Usage: python cloudflare_rules.py {export|import} [API_TOKEN] [ZONE_ID]")
print()
print("Arguments:")
print(" export/import Operation to perform")
print(" API_TOKEN Cloudflare API Token (optional if CLOUDFLARE_API_TOKEN env var is set)")
print(" ZONE_ID Cloudflare Zone ID (optional if CLOUDFLARE_ZONE_ID env var is set)")
sys.exit(1)


def call_cloudflare(method, url, api_token, output_file=None, data_file=None, description=""):
print(f"{description}...")

headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}

data = None
if data_file:
if not os.path.isfile(data_file):
print(f"Error: Data file {data_file} not found. Skipping {description}.")
return False
with open(data_file, "r") as f:
data = f.read()

response = requests.request(method, url, headers=headers, data=data)

if response.status_code < 200 or response.status_code > 299:
print(f"Error: {description} failed with HTTP status {response.status_code}")
if response.text:
print(f"Response: {response.text}")
return False

if output_file:
with open(output_file, "w") as f:
f.write(response.text)

return True


def export_rules(api_token, zone_id):
base_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets"
pagerules_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/pagerules"

failed = False
if not call_cloudflare("GET", f"{base_url}/phases/http_request_firewall_custom/entrypoint", api_token, output_file="waf_rules.json", description="Exporting WAF Custom Rules"):
failed = True
if not call_cloudflare("GET", f"{base_url}/phases/http_ratelimit/entrypoint", api_token, output_file="rate_limit_rules.json", description="Exporting Rate Limiting Rules"):
failed = True
if not call_cloudflare("GET", f"{base_url}/phases/http_request_cache_settings/entrypoint", api_token, output_file="cache_rules.json", description="Exporting Cache Rules"):
failed = True
if not call_cloudflare("GET", f"{base_url}/phases/http_request_dynamic_redirect/entrypoint", api_token, output_file="redirect_rules.json", description="Exporting Redirect Rules"):
failed = True
if not call_cloudflare("GET", pagerules_url, api_token, output_file="page_rules.json", description="Exporting Page Rules"):
failed = True

if not failed:
print("Export completed successfully. JSON files saved in current directory.")
else:
print("Export completed with errors.")
sys.exit(1)


def import_page_rules(api_token, zone_id):
pagerules_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/pagerules"

print("Importing Page Rules...")
if not os.path.isfile("page_rules.json"):
print("Error: Data file page_rules.json not found. Skipping Page Rules import.")
return False

with open("page_rules.json", "r") as f:
data = json.load(f)

rules = data.get("result", [])
if not rules:
print("No page rules found to import.")
return True

headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}

page_failed = False
for i, rule in enumerate(rules):
payload = {
"targets": rule["targets"],
"actions": rule["actions"],
"priority": rule.get("priority", i + 1),
"status": rule.get("status", "active"),
}

response = requests.post(pagerules_url, headers=headers, json=payload)

if response.status_code < 200 or response.status_code > 299:
print(f"Error: Importing Page Rule {i} failed with HTTP status {response.status_code}")
if response.text:
print(f"Response: {response.text}")
page_failed = True

if page_failed:
return False

print("Page Rules imported successfully.")
return True


def import_rules(api_token, zone_id):
base_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/rulesets"

failed = False
if not call_cloudflare("PUT", f"{base_url}/phases/http_request_firewall_custom/entrypoint", api_token, data_file="waf_rules.json", description="Importing WAF Custom Rules"):
failed = True
if not call_cloudflare("PUT", f"{base_url}/phases/http_ratelimit/entrypoint", api_token, data_file="rate_limit_rules.json", description="Importing Rate Limiting Rules"):
failed = True
if not call_cloudflare("PUT", f"{base_url}/phases/http_request_cache_settings/entrypoint", api_token, data_file="cache_rules.json", description="Importing Cache Rules"):
failed = True
if not call_cloudflare("PUT", f"{base_url}/phases/http_request_dynamic_redirect/entrypoint", api_token, data_file="redirect_rules.json", description="Importing Redirect Rules"):
failed = True
if not import_page_rules(api_token, zone_id):
failed = True

if not failed:
print("Import completed successfully.")
else:
print("Import completed with errors.")
sys.exit(1)


def main():
if len(sys.argv) < 2:
usage()

operation = sys.argv[1]
api_token = sys.argv[2] if len(sys.argv) > 2 else os.environ.get("CLOUDFLARE_API_TOKEN", "")
zone_id = sys.argv[3] if len(sys.argv) > 3 else os.environ.get("CLOUDFLARE_ZONE_ID", "")

if not api_token or not zone_id:
print("Error: API_TOKEN and ZONE_ID must be provided either as arguments or environment variables.")
usage()

if operation == "export":
export_rules(api_token, zone_id)
elif operation == "import":
import_rules(api_token, zone_id)
else:
usage()


if __name__ == "__main__":
main()
10 changes: 10 additions & 0 deletions ansible/cloudflare/cloudflare_rules/cache_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"result": null,
"success": false,
"errors": [
{
"message": "request is not authorized"
}
],
"messages": []
}
1 change: 1 addition & 0 deletions ansible/cloudflare/cloudflare_rules/page_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"result":[{"id":"380581670412fcfb02a75e0f049f84fe","targets":[{"target":"url","constraint":{"operator":"matches","value":"*doaj.org\/oai*"}}],"actions":[{"id":"browser_check","value":"off"},{"id":"security_level","value":"essentially_off"},{"id":"cache_level","value":"bypass"}],"priority":3,"status":"active","created_on":"2019-04-03T06:14:26.000000Z","modified_on":"2022-07-14T12:07:36.000000Z"},{"id":"56386c243bba2ac9c67cb21173da335e","targets":[{"target":"url","constraint":{"operator":"matches","value":"*doaj.org\/api*"}}],"actions":[{"id":"browser_check","value":"off"},{"id":"security_level","value":"essentially_off"},{"id":"cache_level","value":"bypass"}],"priority":2,"status":"active","created_on":"2019-02-26T15:20:15.000000Z","modified_on":"2022-07-15T09:47:38.000000Z"},{"id":"8ed1bac74f92fef5bada7e4091897453","targets":[{"target":"url","constraint":{"operator":"matches","value":"*doaj.org\/query*"}}],"actions":[{"id":"browser_cache_ttl","value":1800},{"id":"cache_level","value":"cache_everything"}],"priority":1,"status":"active","created_on":"2019-02-26T15:21:39.000000Z","modified_on":"2019-02-27T22:27:21.000000Z"}],"success":true,"errors":[],"messages":[]}
36 changes: 36 additions & 0 deletions ansible/cloudflare/cloudflare_rules/rate_limit_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"result": {
"description": "",
"id": "ffbc4d7fcdae4cd3a36aaaa9ac921483",
"kind": "zone",
"last_updated": "2025-07-09T09:18:41.981517Z",
"name": "default",
"phase": "http_ratelimit",
"rules": [
{
"action": "block",
"description": "Leaked credential check",
"enabled": true,
"expression": "(cf.waf.credential_check.password_leaked)",
"id": "e254b99c19e746ce9b0d9e33eaff8992",
"last_updated": "2025-07-09T09:18:41.981517Z",
"ratelimit": {
"characteristics": [
"ip.src",
"cf.colo.id"
],
"mitigation_timeout": 3600,
"period": 10,
"requests_per_period": 5
},
"ref": "e254b99c19e746ce9b0d9e33eaff8992",
"version": "1"
}
],
"source": "rate_limit",
"version": "1"
},
"success": true,
"errors": [],
"messages": []
}
10 changes: 10 additions & 0 deletions ansible/cloudflare/cloudflare_rules/redirect_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"result": null,
"success": false,
"errors": [
{
"message": "phase \"http_request_redirect\" not allowed at zone level"
}
],
"messages": []
}
35 changes: 35 additions & 0 deletions ansible/cloudflare/cloudflare_rules/waf_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"result": {
"description": "",
"id": "3d79da9812e44c449dde54903717be8a",
"kind": "zone",
"last_updated": "2025-05-20T10:09:27.585167Z",
"name": "default",
"phase": "http_request_firewall_custom",
"rules": [
{
"action": "skip",
"action_parameters": {
"phases": [
"http_request_firewall_managed"
]
},
"description": "Allow Blog uploads",
"enabled": true,
"expression": "(http.request.full_uri contains \"blog.doaj.org/wp-admin/\")",
"id": "b47819cd21af43f09d0d9b972755b749",
"last_updated": "2025-05-20T10:09:27.585167Z",
"logging": {
"enabled": true
},
"ref": "b47819cd21af43f09d0d9b972755b749",
"version": "1"
}
],
"source": "firewall_custom",
"version": "1"
},
"success": true,
"errors": [],
"messages": []
}