Setting up fail2ban with Cloudflare custom list API

Hi all, hoping to get some help setting this up as I’d like to deploy a new vaultwarden instance for a friend behind CG-NAT so will be using CF tunnel on a free account. It’s my understanding the free account has limited WAF rules so found a blog post that recommended setting it up with a custom IP list and specifiying that in a single WAF rule.

My issue currently it getting the action to be detected by fail2ban.

Both vaultwarden and fail2ban are running in docker (crazymax/fail2ban:latest), logs and environment variables have been setup but fail2ban doesn’t see any actions.

Been at this for a couple of days trying a mix of different things but not having any luck (only just got the API working).

This is my current action.d (cloudflare.conf);

# data/action.d/cloudflare.conf

[Definition]
actionstart =
actionstop =

actionban = curl -s -o /dev/null -X POST \
    -H "Authorization: Bearer <CF_API_TOKEN>" \
    -H "Content-Type: application/json" \
    --data '[{"ip": "<ip>"}]' \
    "https://api.cloudflare.com/client/v4/accounts/<CF_ACCOUNT_ID>/rules/lists/<CF_LIST_ID>/items"

actionunban = curl -s -o /dev/null -X DELETE \
    -H "Authorization: Bearer <CF_API_TOKEN>" \
    -H "Content-Type: application/json" \
    --data "{\"items\":[{\"ip\":\"<ip>\"}]}" \
    "https://api.cloudflare.com/client/v4/accounts/<CF_ACCOUNT_ID>/rules/lists/<CF_LIST_ID>/items"

[Init]
CF_API_TOKEN = ${CF_API_TOKEN}
CF_ACCOUNT_ID = ${CF_ACCOUNT_ID}
CF_LIST_ID = ${CF_LIST_ID}

jail.d (vaultwarden.local)

# data/jail.d/vaultwarden.local

[vaultwarden]
enabled = true
filter = vaultwarden
action = cloudflare
logpath = /data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400

When running “docker exec -it fail2ban fail2ban-client get actionnames | grep cloudflare” nothing is returned.

Apparently when running;

docker exec -it fail2ban fail2ban-client status vaultwarden
Status for the jail: vaultwarden
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- File list:        /data/vaultwarden.log
`- Actions
   |- Currently banned: 0
   |- Total banned:     1
   `- Banned IP list:

It apparently should show “cloudflare” under actions.

fail2ban logs do show the files from the docker folder are being loaded and hardlinked to /etc/fail2ban/X

Initializing files and folders...
Setting Fail2ban configuration...
Checking for custom actions in /data/action.d...
  WARNING: cloudflare.conf already exists and will be overriden
  Add custom action cloudflare.conf...
  WARNING: entryPoint.py already exists and will be overriden
  Add custom action entryPoint.py...
  WARNING: modifyBanList.py already exists and will be overriden
  Add custom action modifyBanList.py...
Checking for custom filters in /data/filter.d...
  WARNING: vaultwarden-totp.local already exists and will be overriden
  Add custom filter vaultwarden-totp.local...
  WARNING: vaultwarden.local already exists and will be overriden
  Add custom filter vaultwarden.local...
WARNING: iptables-nft is not supported by the host, falling back to iptables-legacy
iptables v1.8.10 (legacy)
nftables v1.1.1 (Commodore Bullmoose #2)
650 fail2ban.configreader   [1]: INFO    Loading configs for fail2ban under /etc/fail2ban 
651 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/fail2ban.conf']
651 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/fail2ban.conf']
651 fail2ban                [1]: INFO    Using socket file /var/run/fail2ban/fail2ban.sock
651 fail2ban                [1]: INFO    Using pid file /var/run/fail2ban/fail2ban.pid, [INFO] logging to /data/fail2ban.log
653 fail2ban.configreader   [1]: INFO    Loading configs for jail under /etc/fail2ban 
654 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/jail.conf']
657 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/paths-debian.conf']
658 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/paths-common.conf']
659 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/paths-overrides.local']
659 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/jail.d/vaultwarden-totp.local']
659 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/jail.d/vaultwarden.local']
659 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/paths-common.conf', '/etc/fail2ban/paths-debian.conf', '/etc/fail2ban/jail.conf', '/etc/fail2ban/jail.d/vaultwarden-totp.local', '/etc/fail2ban/jail.d/vaultwarden.local']
663 fail2ban.configreader   [1]: INFO    Loading configs for filter.d/vaultwarden-totp under /etc/fail2ban 
663 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/filter.d/vaultwarden-totp.local']
664 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/filter.d/common.conf']
664 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/filter.d/common.local']
664 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/filter.d/common.conf', '/etc/fail2ban/filter.d/vaultwarden-totp.local']
665 fail2ban.configreader   [1]: INFO    Loading configs for action.d/cloudflare under /etc/fail2ban 
665 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/action.d/cloudflare.conf']
665 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/action.d/cloudflare.conf']
666 fail2ban.configreader   [1]: INFO    Loading configs for filter.d/vaultwarden under /etc/fail2ban 
666 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/filter.d/vaultwarden.local']
2666 fail2ban.configparserinc[1]: INFO      Loading files: ['/etc/fail2ban/filter.d/common.conf', '/etc/fail2ban/filter.d/vaultwarden.local']
Server ready

Any pointers?

I’ve gotten a little bit further with this.

Bans are now working but I can’t add the variables to action.d/cloudflare.conf as fail2ban won’t parse the file (which is why I wasn’t seeing any actions previously). Not a huge deal but would prefer if they weren’t stored directly in cloudflare.conf

actionunban needs refinement as Cloudflare wants an item id (or UUID) for the list item before it’s removed so I need to work out how to get a matching list item for it to then be removed.

EDIT: Got it working, even it is crude. Would love to fine-tune it though if possible.

[Definition]
# Action configuration for adding/removing IPs to a Cloudflare custom IP list

actionstart =
actionstop =

actionban = curl -s -o /dev/null -X POST \
    -H "Authorization: Bearer <cf_api_token>" \
    -H "Content-Type: application/json" \
    --data '[{"ip": "<ip>"}]' \
    "https://api.cloudflare.com/client/v4/accounts/<cf_account_id>/rules/lists/<cf_list_id>/items"

actionunban = python /data/action.d/entryPoint.py <ip> del

[Init]
# Use environment variables for credentials (preferred)
# Or replace these placeholders manually if you prefer
cf_api_token = XXXXX
cf_account_id = XXXXX
cf_list_id = XXXXX

entryPoint.py & modifyBanList.py are referenced here: Using Fail2Ban with Cloudflare on a free account but I’ve modified modifyBanList.py to use the ‘Authorization’: f’Bearer {apiToken}’ header instead of ‘X-Auth-Email’: f’{email}‘, ‘X-Auth-Key’: f’{apiKey}’ - Environment variables do work for this one.

#!/usr/bin/python3

import os
import sys
import requests
import json
import ipaddress

def getIPList(apiEndpoint: str, headers: dict) -> json:
    response = requests.get(apiEndpoint, headers=headers)
    if response.status_code == 200:
        return response.json()
    else:
        print(f"Failed to fetch existing IP list. Status code: {response.status_code}")
        print(response.text)
        sys.exit(1)

def addIPtoList(ipAddr: str, apiEndpoint: str, headers: dict) -> requests.Response:
    payload = [{"ip": ipAddr}]
    response = requests.post(apiEndpoint, headers=headers, data=json.dumps(payload))
    return response

def removeIPFromList(ipId: str, apiEndpoint: str, headers: dict) -> requests.Response:
    payload = {"items": [{"id": ipId}]}
    response = requests.delete(apiEndpoint, headers=headers, data=json.dumps(payload))
    return response

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: ./modifyBanList.py <ip> <add|del>")
        sys.exit(1)

    ipAddr = sys.argv[1]

    try:
        addr = ipaddress.IPv6Address(ipAddr)
        first_64_bits = str(addr.exploded).split(':')[:4]
        ipAddr = ':'.join(first_64_bits) + '::/64'
    except ipaddress.AddressValueError:
        # Not IPv6, leave ipAddr unchanged
        pass

    action = sys.argv[2]

    listId = os.getenv('CF_LIST_ID')
    accountId = os.getenv('CF_ACCOUNT_ID')
    apiToken = os.getenv('CF_API_TOKEN')

    if not listId or not accountId or not apiToken:
        print("ERROR: CF_LIST_ID, CF_ACCOUNT_ID, and CF_API_TOKEN environment variables must be set")
        sys.exit(1)

    apiEndpoint = f'https://api.cloudflare.com/client/v4/accounts/{accountId}/rules/lists/{listId}/items'

    headers = {
        'Authorization': f'Bearer {apiToken}',
        'Content-Type': 'application/json'
    }

    existingIpList = getIPList(apiEndpoint, headers)
    print(existingIpList)
    response = None

    if action == "del":
        ipId = None
        for item in existingIpList['result']:
            if item['ip'] == ipAddr:
                ipId = item['id']
                break

        if ipId is not None:
            response = removeIPFromList(ipId, apiEndpoint, headers)
    elif action == "add":
        if not any(item['ip'] == ipAddr for item in existingIpList['result']):
            response = addIPtoList(ipAddr, apiEndpoint, headers)

    if response is not None and response.status_code == 200:
        print(f"IP address {ipAddr} {action}ed to the custom IP list successfully.")
    else:
        print(f"Failed to {action} IP address {ipAddr} to the custom IP list.")