Skip to content

fail2ban-ipset-geoip-cloudflare

Modules

A NixOS module that backs fail2ban with kernel ipsets instead of the stock one-iptables-rule-per-ban action, seeds a whitelist from GeoIP country ranges and your CDN/reverse-proxy edge ranges, and can optionally mirror bans to the Cloudflare edge via the API.

The problem

fail2ban's default iptables action inserts one rule per banned IP. Under real internet scanning traffic that grows to thousands of rules, each traversed linearly, and — worse — the rules live in a chain that gets torn down and rebuilt on every firewall reload, so a nixos-rebuild switch can silently drop your active bans.

If you also sit behind a CDN or reverse proxy, two more things bite:

  • The connection the firewall sees comes from the edge, not the client. Ban the source IP naively and you can ban the CDN itself, blackholing every visitor.
  • Legitimate traffic from a trusted region or your own proxy can trip a jail and get banned, with no clean override.

The approach

Two ipsets and exactly one enforcing rule:

  • f2b-bannedhash:ip with a per-entry timeout. Jails add offenders here.
  • f2b-whitelisthash:net. Trusted networks.
iptables -I INPUT 1 -m set --match-set f2b-banned src \
                    -m set ! --match-set f2b-whitelist src -j REJECT

Why each piece matters:

  • O(1), unbounded bans. One rule matches a set membership regardless of how many IPs are banned. No rule-per-IP explosion.
  • Bans survive reloads. The rule is (re)created from networking.firewall.extraCommands, and the bans themselves live in the kernel ipset with timeouts — they outlive firewall restarts, jail reloads, and rebuilds. extraStopCommands removes only the rule, never the set.
  • Whitelist is an override, not a race. The single match is "banned AND NOT whitelisted". A trusted source is dropped from the ban path within the same rule evaluation, so there is no window where a ban rule fires before a separate allow rule. RFC1918, loopback, GeoIP countries, edge ranges, and your own hosts can never be banned even if a jail matches them.
  • CDN-aware. Whitelist your edge ranges so the edge is never banned. The optional Cloudflare action mirrors the ban to the edge firewall, where the real client actually connects — that is the only place a masked client IP can be blocked.

The trap (keep it)

ipset stores the entry timeout as a signed 32-bit int. The actionban caps <bantime> at 2147483 seconds (~24.8 days):

TIMEOUT=<bantime>; [ "$TIMEOUT" -gt 2147483 ] && TIMEOUT=2147483

A larger bantime overflows and ipset add fails — the ban silently doesn't happen. The cap is load-bearing; don't remove it. (You can still set long jail bantime values for fail2ban's own bookkeeping; the ipset entry is just re-added as the jail keeps matching.)

Usage

{
  imports = [ ./fail2ban-ipset-geoip-cloudflare ];

  services.fail2banIpset = {
    enable = true;

    # Trust whole countries (needs geoipCountrylistPackage below).
    whitelistCountries = [ "de" "at" ];

    # Your own proxy / monitoring / management ranges.
    whitelistIPv4 = [ "198.51.100.10" "203.0.113.0/24" ];
    whitelistIPv6 = [ "2001:db8::1" ];

    # nginx probe/scanner jail (defaults on when services.nginx.enable).
    nginx.enable = true;
    nginx.logPath = "/var/log/nginx/access.log";
  };
}

GeoIP country whitelisting

whitelistCountries needs geoipCountrylistPackage set to a package that lays out per-country aggregated prefix lists at:

<pkg>/share/geoip-country-lists/<cc>/ipv4-aggregated.txt
<pkg>/share/geoip-country-lists/<cc>/ipv6-aggregated.txt

one CIDR per line, # comments allowed. There is no such package in nixpkgs — supply your own overlay/derivation built from a public aggregated list such as herrbischoff/country-ip-blocks or the ipverse lists. When left null, country whitelisting is skipped.

Edge / CDN ranges

services.fail2banIpset.edgeRangesFile = "${inputs.cloudflare-ip-ranges}/lists/cloudflare_ips_raw.txt";

Any file with one IP/CIDR per line (IPv4 and IPv6 mixed, # comments ignored) works — vendor the Cloudflare list as a flake input, or generate your own. These ranges go into both the whitelist ipset and fail2ban's ignoreIP.

Mirror bans to Cloudflare (optional)

services.fail2banIpset.cloudflare = {
  enable = true;
  apiKeyFile = "/run/secrets/cloudflare-fail2ban-token"; # file with a Bearer token
  email = "you@example.com";
};

The token needs the firewall access-rules edit scope. The action POSTs a block rule to user/firewall/access_rules/rules on ban and deletes it on unban, treating a duplicate_of_existing as success. Provide apiKeyFile via your secret manager (agenix/sops/etc.) — never inline the token.

Note the scope: only the nginx-protect jail chains the cloudflare action onto custom-ipset. The kernel-sus-connections jail bans locally only, which is what you want — a kernel-level connection refusal is by definition traffic that reached this host directly, not through the edge.

apiKeyFile is typed str, not path, on purpose: a path would let a Nix path literal (./cf-token) copy the live token verbatim into the world-readable store. Pass a runtime path string.

Options

Option Default Purpose
enable false Turn the module on.
whitelistCountries [] Lowercase country codes whose CIDRs join the whitelist set. Requires geoipCountrylistPackage (an assertion fires otherwise).
geoipCountrylistPackage null Package providing the per-country prefix lists; null skips country whitelisting.
whitelistIPv4 [] Extra IPv4 addresses/CIDRs for the whitelist set and fail2ban's ignoreIP.
whitelistIPv6 [] Same for IPv6 (ignoreIP entry only when networking.enableIPv6).
edgeRangesFile null File of CDN/reverse-proxy edge ranges, one IP/CIDR per line.
banTime "96h" Default jail bantime. Both bundled jails override it with 168h; the ipset entry timeout is separately capped at ~24.8 days.
nginx.enable config.services.nginx.enable The nginx-protect access-log jail.
nginx.logPath /var/log/nginx/access.log Access log that jail reads.
kernelJail.enable true The kernel-sus-connections journal jail.
cloudflare.enable false Mirror nginx-protect bans to the Cloudflare edge.
cloudflare.apiKeyFile (required when enabled) Runtime path string to a file holding the Bearer token.
cloudflare.email noreply@example.com Exported as FAIL2BAN_CFUSER; the token is what actually authenticates.

Jails included

  • kernel-sus-connections — bans hosts the kernel logs as "refused connection". Needs firewall connection logging enabled (systemd/journald backend). Toggle with kernelJail.enable.
  • nginx-protect — bans obvious probes/scanners from the nginx access log (env dotfiles, cgi-bin, boaform, zgrab/CensysInspect user-agents, PHP/ASP scans, …). Edit the nginxRules list in default.nix to taste.

Both use exponential bantime-increment across all jails so repeat offenders climb quickly.

Caveats

  • IPv6 support keys off config.networking.enableIPv6; the module builds a parallel f2b-banned6 / f2b-whitelist6 and an ip6tables rule only then.
  • The enforcing rule is inserted at INPUT position 1. If you have other position-1 insertions, mind ordering — this one wants to be early.
  • 127.0.0.0/8 (not /16) is whitelisted so all loopback is covered.
  • The module owns the f2b-* ipset names; don't reuse them elsewhere.

nftables backend

This module works on both networking.firewall.backend = "iptables" (the default, as described above) and "nftables". nixpkgs' nftables firewall hard-asserts networking.firewall.extraCommands/extraStopCommands == "", so the nftables path can't use them for the ban/whitelist sets or the enforcing rule. Instead it keeps its own nftables table (family inet, name fail2ban-ipset) with equivalent f2b-banned/f2b-whitelist (and, with IPv6, f2b-banned6/f2b-whitelist6) sets and a single early-priority (filter - 10, ahead of the normal firewall) reject rule.

That table is deliberately not managed through networking.nftables.tables: NixOS deletes and fully recreates every table declared that way on every nftables.service reload — which happens on any nixos-rebuild switch that touches the ruleset — and doing that here would wipe live bans, defeating the entire point of this module (bans surviving reloads/switches). A dedicated fail2ban-ipset-nftables systemd unit builds/rebuilds the table with idempotent nft add table/set (create-if- missing, unlike nft create) instead, touching only its own enforcing chain on stop.

The custom-ipset[6].conf fail2ban actions switch to nft add/delete element inet fail2ban-ipset f2b-banned … in place of ipset add/del. They keep the same 2147483 second bantime cap: that number is specifically ipset's signed-32-bit timeout ceiling, and while nftables' set-element timeout has not been found to share it, that hasn't been verified against a real kernel either, so the cap is carried over out of caution rather than assumed safe to widen.

Source

modules/fail2ban-ipset-geoip-cloudflare/default.nix
# fail2ban-ipset-geoip-cloudflare
#
# Back fail2ban with kernel ipsets instead of one iptables rule per banned IP:
#
#   * f2b-banned     (hash:ip, timeout)  - the set jails add offenders to
#   * f2b-whitelist  (hash:net)          - trusted networks that can never be banned
#
# A single high-priority INPUT rule does the enforcement:
#
#   iptables -I INPUT 1 -m set --match-set f2b-banned src \
#                       -m set ! --match-set f2b-whitelist src -j REJECT
#
# Why this shape:
#   * O(1) match and tens of thousands of bans without a rule-per-IP explosion.
#   * The rule lives in networking.firewall.extraCommands, so it is re-created on
#     every firewall reload -- bans (held in the kernel ipset with per-entry
#     timeouts) survive `nixos-rebuild switch`, firewall restarts, and jail
#     reloads.
#   * The whitelist is an *override* baked into the same match ("banned AND NOT
#     whitelisted"), not a separate rule that could race the ban rule. A trusted
#     source is dropped from the ban path even if a jail matches it.
#   * Seed the whitelist from GeoIP country CIDRs + your reverse proxy / CDN edge
#     ranges. If you sit behind a CDN, the offender's real IP is masked behind the
#     edge; whitelisting the edge ranges stops fail2ban from banning the CDN
#     itself (which would blackhole *all* traffic). The optional Cloudflare API
#     action mirrors the ban to the edge, where the real client actually connects.
#
# Gotcha: ipset stores the timeout as a signed 32-bit int, so actionban caps
# <bantime> at 2147483s (~24.8 days). A larger bantime overflows and the `ipset
# add` fails -- the cap in the action file is load-bearing, keep it.
#
# This is a self-contained NixOS module. Import it and set
# `services.fail2banIpset.enable = true;`.

{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.services.fail2banIpset;

  # Cloudflare (or any CDN/reverse-proxy) edge ranges to whitelist, read from a
  # plain file with one IP/CIDR per line (comments starting with '#' ignored).
  # e.g. the `lists/cloudflare_ips_raw.txt` from
  # https://github.com/jimaek/cloudflare-ip-ranges, vendored as a flake input,
  # or any file you generate. When cfg.edgeRangesFile is null this is empty.
  edgeRanges =
    if cfg.edgeRangesFile == null then
      [ ]
    else
      builtins.filter (l: l != "" && !(lib.hasPrefix "#" l)) (
        lib.splitString "\n" (lib.removeSuffix "\n" (builtins.readFile cfg.edgeRangesFile))
      );
  edgeIPv4 = builtins.filter (ip: !(lib.hasInfix ":" ip)) edgeRanges;
  edgeIPv6 = builtins.filter (ip: lib.hasInfix ":" ip) edgeRanges;

  hasGeoip = cfg.geoipCountrylistPackage != null;

  # nixpkgs' nftables-based firewall (firewall-nftables.nix) hard-asserts
  # networking.firewall.extraCommands/extraStopCommands == "" -- this module
  # can't drive its enforcing rule through them on that backend. Instead it
  # keeps its own nftables table (family inet, name fail2ban-ipset) OUTSIDE
  # `networking.nftables.tables`: that option's declared tables are deleted
  # and fully recreated on every nftables.service reload/restart (including
  # every `nixos-rebuild switch` that touches the ruleset), which would wipe
  # the live f2b-banned/f2b-whitelist elements -- exactly the
  # survives-reload guarantee this module exists to provide. A separate,
  # self-managed table with idempotent `nft add table/set` (create-if-
  # missing, unlike `nft create`, which errors) is unaffected by that
  # teardown and only touches its own enforcing chain.
  nft = config.networking.firewall.backend == "nftables";
  nftBin = "${pkgs.nftables}/bin/nft";

  # RFC1918 + loopback, ported from the ipset seed list below.
  nftBaseWhitelistV4 = "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8";
  nftBaseWhitelistV6 = "::1/128, fe80::/10, fc00::/7";

  nftWhitelistGeoipCommands =
    family:
    lib.optionalString hasGeoip (
      lib.concatMapStrings (country: ''
        if [ -f "${cfg.geoipCountrylistPackage}/share/geoip-country-lists/${country}/${family}-aggregated.txt" ]; then
          ${pkgs.gnugrep}/bin/grep -v '^#' \
            "${cfg.geoipCountrylistPackage}/share/geoip-country-lists/${country}/${family}-aggregated.txt" \
            | while read -r cidr; do
                [ -n "$cidr" ] && $NFT add element inet fail2ban-ipset f2b-whitelist${
                  if family == "ipv6" then "6" else ""
                } "{ $cidr }"
              done
        fi
      '') cfg.whitelistCountries
    );

  # (Re)creates the table/sets/enforcing chain. Sets are only ever `add`ed
  # (idempotent, never flushed) except f2b-whitelist[6], which is fully
  # static/derived and safe to flush + rebuild every run. The enforcing
  # chain is deleted and re-added each run so `add rule` (which always
  # appends, never de-duplicates) can't pile up copies of the reject rule.
  nftSetupScript = pkgs.writeShellScript "fail2ban-ipset-nftables-setup" ''
    set -euo pipefail
    NFT=${nftBin}

    $NFT add table inet fail2ban-ipset
    $NFT add set inet fail2ban-ipset f2b-banned '{ type ipv4_addr; flags timeout; }'
    $NFT add set inet fail2ban-ipset f2b-whitelist '{ type ipv4_addr; flags interval; auto-merge; }'

    $NFT flush set inet fail2ban-ipset f2b-whitelist
    $NFT add element inet fail2ban-ipset f2b-whitelist "{ ${nftBaseWhitelistV4} }"

    ${nftWhitelistGeoipCommands "ipv4"}

    ${lib.concatMapStrings (ip: ''
      $NFT add element inet fail2ban-ipset f2b-whitelist "{ ${ip} }"
    '') edgeIPv4}

    ${lib.concatMapStrings (ip: ''
      $NFT add element inet fail2ban-ipset f2b-whitelist "{ ${ip} }"
    '') cfg.whitelistIPv4}

    $NFT delete chain inet fail2ban-ipset input 2>/dev/null || true
    $NFT add chain inet fail2ban-ipset input '{ type filter hook input priority filter - 10 ; }'
    $NFT add rule inet fail2ban-ipset input ip saddr @f2b-banned ip saddr != @f2b-whitelist reject with icmp type port-unreachable

    ${lib.optionalString config.networking.enableIPv6 ''
      $NFT add set inet fail2ban-ipset f2b-banned6 '{ type ipv6_addr; flags timeout; }'
      $NFT add set inet fail2ban-ipset f2b-whitelist6 '{ type ipv6_addr; flags interval; auto-merge; }'

      $NFT flush set inet fail2ban-ipset f2b-whitelist6
      $NFT add element inet fail2ban-ipset f2b-whitelist6 "{ ${nftBaseWhitelistV6} }"

      ${nftWhitelistGeoipCommands "ipv6"}

      ${lib.concatMapStrings (ip: ''
        $NFT add element inet fail2ban-ipset f2b-whitelist6 "{ ${ip} }"
      '') edgeIPv6}

      ${lib.concatMapStrings (ip: ''
        $NFT add element inet fail2ban-ipset f2b-whitelist6 "{ ${ip} }"
      '') cfg.whitelistIPv6}

      $NFT add rule inet fail2ban-ipset input ip6 saddr @f2b-banned6 ip6 saddr != @f2b-whitelist6 reject with icmpv6 type port-unreachable
    ''}
  '';

  # Removes only the enforcing chain/rule, exactly like extraStopCommands
  # does for the iptables rule -- the bans and whitelist sets are untouched.
  nftTeardownScript = pkgs.writeShellScript "fail2ban-ipset-nftables-teardown" ''
    ${nftBin} delete chain inet fail2ban-ipset input 2>/dev/null || true
  '';

  # nginx access-log patterns that mark obvious probes/scanners. Generic and
  # provider-neutral; tune to taste.
  nginxRules = builtins.concatStringsSep "|" [
    ''"GET /(\.|config|admin|backup|private|secret|\.env|\.well-known/matrix/server).* HTTP/.*" 404''
    ''"GET /cgi-bin/.*;.*\?form=.*&.*=.* HTTP/.*" 4[0-9]{2}''
    ''"POST /.* HTTP/.*" 4[0-9]{2}''
    ''"(POST|GET) /.*(ViewLog|admin|login|cgi-bin|boaform|cdn-cgi/trace).* HTTP/.*" 4[0-9]{2}''
    ''".*x.*" 400''
    ''"GET / HTTP/.*" [0-9]{3} ("Mozilla/5.0 zgrab/|"Expanse, a Palo Alto Networks company|"Mozilla/5.0 \(compatible; CensysInspect/).*"''
    ''"GET /.*\.(php|asp|aspx|jsp|cgi) HTTP/.*" [0-9]{3}''
  ];
in
{
  options.services.fail2banIpset = {
    enable = lib.mkEnableOption "ipset-backed fail2ban with GeoIP + edge-range whitelisting";

    whitelistCountries = lib.mkOption {
      description = ''
        Country codes (lowercase, e.g. "de") whose aggregated CIDR ranges are
        added to the f2b-whitelist ipset at firewall startup, keeping entire
        trusted regions out of the ban path. Requires `geoipCountrylistPackage`
        to be set; ignored otherwise.
      '';
      type = lib.types.listOf lib.types.str;
      default = [ ];
      example = [ "de" "at" ];
    };

    geoipCountrylistPackage = lib.mkOption {
      description = ''
        A package that provides per-country aggregated prefix lists at
          <pkg>/share/geoip-country-lists/<cc>/ipv4-aggregated.txt
          <pkg>/share/geoip-country-lists/<cc>/ipv6-aggregated.txt
        (one CIDR per line, '#' comments allowed). Set null to disable
        country-based whitelisting. There is no such package in nixpkgs by
        default -- supply your own overlay/derivation built from e.g. the
        herrbischoff/country-ip-blocks or ipverse aggregated lists.
      '';
      type = lib.types.nullOr lib.types.package;
      default = null;
    };

    whitelistIPv4 = lib.mkOption {
      description = ''
        Extra IPv4 addresses/CIDRs to add to the f2b-whitelist ipset and to
        fail2ban's ignoreIP. Put your own reverse proxy, monitoring hosts, and
        management ranges here so they can never be banned.
      '';
      type = lib.types.listOf lib.types.str;
      default = [ ];
      example = [ "198.51.100.10" "203.0.113.0/24" ];
    };

    whitelistIPv6 = lib.mkOption {
      description = "Extra IPv6 addresses/CIDRs to add to the f2b-whitelist6 ipset and ignoreIP.";
      type = lib.types.listOf lib.types.str;
      default = [ ];
      example = [ "2001:db8::1" ];
    };

    edgeRangesFile = lib.mkOption {
      description = ''
        Path to a file listing your CDN / reverse-proxy edge ranges, one IP or
        CIDR per line ('#' comments ignored, IPv4 and IPv6 mixed is fine). These
        are whitelisted so the edge itself is never banned when it fronts your
        traffic (banning it would blackhole every visitor). Set null if you have
        no fronting edge.
      '';
      type = lib.types.nullOr lib.types.path;
      default = null;
    };

    banTime = lib.mkOption {
      description = "Default jail bantime. Note the ipset timeout is separately capped at ~24.8 days.";
      type = lib.types.str;
      default = "96h";
    };

    nginx = {
      enable = lib.mkOption {
        description = "Enable the nginx access-log probe/scanner jail.";
        type = lib.types.bool;
        default = config.services.nginx.enable or false;
        defaultText = lib.literalExpression "config.services.nginx.enable";
      };
      logPath = lib.mkOption {
        description = "nginx access log the nginx-protect jail reads.";
        type = lib.types.str;
        default = "/var/log/nginx/access.log";
      };
    };

    kernelJail.enable = lib.mkOption {
      description = ''
        Enable the kernel-sus-connections jail: bans hosts the kernel logs as
        "refused connection" (needs firewall connection logging enabled).
      '';
      type = lib.types.bool;
      default = true;
    };

    cloudflare = {
      enable = lib.mkOption {
        description = ''
          Mirror bans to the Cloudflare edge via the API v4 IP access-rules
          endpoint. Useful when clients reach you through Cloudflare and their
          real IP is masked from the local firewall.
        '';
        type = lib.types.bool;
        default = false;
      };
      apiKeyFile = lib.mkOption {
        description = ''
          Runtime path to a file containing a Cloudflare API token (Bearer)
          with firewall access-rules edit scope. Provide this via a secret
          manager (agenix/sops/etc.) as a *string* path such as
          "/run/secrets/cloudflare-fail2ban-token". Never point it at a Nix
          path literal (e.g. ./cf-token): that copies the live token verbatim
          into the world-readable /nix/store. The type is `str` (not `path`)
          specifically to reject accidental path literals.
        '';
        type = lib.types.str;
        example = "/run/secrets/cloudflare-fail2ban-token";
      };
      email = lib.mkOption {
        description = "Account email exported as FAIL2BAN_CFUSER (some tooling expects it; the token is what authenticates).";
        type = lib.types.str;
        default = "noreply@example.com";
      };
    };
  };

  config = lib.mkIf cfg.enable {
    assertions = [
      {
        assertion = cfg.whitelistCountries == [ ] || hasGeoip;
        message = ''
          services.fail2banIpset.whitelistCountries is set but
          geoipCountrylistPackage is null -- country whitelisting would be
          silently skipped. Set geoipCountrylistPackage, or clear
          whitelistCountries.
        '';
      }
    ];

    systemd.services.fail2ban = {
      after = [ "fail2ban-ipset-nftables.service" ] ++ lib.optional config.services.nginx.enable "nginx.service";
      wants = lib.mkIf config.services.nginx.enable [ "nginx.service" ];
      serviceConfig = lib.mkIf cfg.cloudflare.enable {
        Environment = [
          "FAIL2BAN_CFUSER=${cfg.cloudflare.email}"
        ];
      };
    };

    # Self-managed nftables table -- see the `nft`/nftSetupScript comment
    # above for why this isn't done via networking.nftables.tables.
    systemd.services.fail2ban-ipset-nftables = lib.mkIf nft {
      description = "fail2ban-ipset nftables table (f2b-banned/whitelist sets + enforcing reject rule)";
      after = [ "network-pre.target" ];
      wants = [ "network-pre.target" ];
      wantedBy = [ "multi-user.target" ];
      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
        ExecStart = "${nftSetupScript}";
        ExecStop = "${nftTeardownScript}";
      };
    };

    boot.kernelModules = lib.optionals (!nft) [
      "iptable_nat"
      "iptable_filter"
      "xt_nat"
      "xt_mark"
      "xt_comment"
      "xt_multiport"
      "xt_set"
    ];
    environment.systemPackages = lib.optional (!nft) pkgs.ipset ++ lib.optional nft pkgs.nftables;

    # The ban/whitelist ipsets and the single enforcing INPUT rule are (re)built
    # here so they survive every firewall reload. (iptables backend only --
    # the nftables backend uses the fail2ban-ipset-nftables unit above.)
    networking.firewall.extraCommands = lib.optionalString (!nft) ''
      ${pkgs.ipset}/bin/ipset create -exist f2b-banned hash:ip timeout 345600 maxelem 65536

      ${pkgs.ipset}/bin/ipset create -exist f2b-whitelist hash:net maxelem 65536

      ${pkgs.ipset}/bin/ipset flush f2b-whitelist

      ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist 10.0.0.0/8
      ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist 172.16.0.0/12
      ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist 192.168.0.0/16
      ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist 127.0.0.0/8

      ${lib.optionalString hasGeoip (lib.concatMapStrings (country: ''
        if [ -f "${cfg.geoipCountrylistPackage}/share/geoip-country-lists/${country}/ipv4-aggregated.txt" ]; then
          ${pkgs.gnugrep}/bin/grep -v '^#' \
            "${cfg.geoipCountrylistPackage}/share/geoip-country-lists/${country}/ipv4-aggregated.txt" \
            | while read -r cidr; do
                [ -n "$cidr" ] && ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist "$cidr"
              done
        fi
      '') cfg.whitelistCountries)}

      ${lib.concatMapStrings (ip: ''
        ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist ${ip}
      '') edgeIPv4}

      ${lib.concatMapStrings (ip: ''
        ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist ${ip}
      '') cfg.whitelistIPv4}

      ${pkgs.iptables}/bin/iptables -D INPUT -m set --match-set f2b-banned src -m set ! --match-set f2b-whitelist src -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || true
      ${pkgs.iptables}/bin/iptables -I INPUT 1 -m set --match-set f2b-banned src -m set ! --match-set f2b-whitelist src -j REJECT --reject-with icmp-port-unreachable

      ${lib.optionalString config.networking.enableIPv6 ''
        ${pkgs.ipset}/bin/ipset create -exist f2b-banned6 hash:ip timeout 345600 family inet6 maxelem 65536
        ${pkgs.ipset}/bin/ipset create -exist f2b-whitelist6 hash:net family inet6 maxelem 65536

        ${pkgs.ipset}/bin/ipset flush f2b-whitelist6
        ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist6 ::1/128
        ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist6 fe80::/10
        ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist6 fc00::/7

        ${lib.optionalString hasGeoip (lib.concatMapStrings (country: ''
          if [ -f "${cfg.geoipCountrylistPackage}/share/geoip-country-lists/${country}/ipv6-aggregated.txt" ]; then
            ${pkgs.gnugrep}/bin/grep -v '^#' \
              "${cfg.geoipCountrylistPackage}/share/geoip-country-lists/${country}/ipv6-aggregated.txt" \
              | while read -r cidr; do
                  [ -n "$cidr" ] && ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist6 "$cidr"
                done
          fi
        '') cfg.whitelistCountries)}

        ${lib.concatMapStrings (ip: ''
          ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist6 ${ip}
        '') edgeIPv6}

        ${lib.concatMapStrings (ip: ''
          ${pkgs.ipset}/bin/ipset add -exist f2b-whitelist6 ${ip}
        '') cfg.whitelistIPv6}

        ${pkgs.iptables}/bin/ip6tables -D INPUT -m set --match-set f2b-banned6 src -m set ! --match-set f2b-whitelist6 src -j REJECT --reject-with icmp6-port-unreachable 2>/dev/null || true
        ${pkgs.iptables}/bin/ip6tables -I INPUT 1 -m set --match-set f2b-banned6 src -m set ! --match-set f2b-whitelist6 src -j REJECT --reject-with icmp6-port-unreachable
      ''}
    '';

    networking.firewall.extraStopCommands = lib.optionalString (!nft) ''
      ${pkgs.iptables}/bin/iptables -D INPUT -m set --match-set f2b-banned src -m set ! --match-set f2b-whitelist src -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || true

      ${lib.optionalString config.networking.enableIPv6 ''
        ${pkgs.iptables}/bin/ip6tables -D INPUT -m set --match-set f2b-banned6 src -m set ! --match-set f2b-whitelist6 src -j REJECT --reject-with icmp6-port-unreachable 2>/dev/null || true
      ''}
    '';

    services.fail2ban = {
      enable = true;
      ignoreIP = [
        "10.0.0.0/8"
        "172.16.0.0/12"
        "192.168.0.0/16"
        "127.0.0.0/8"
        "localhost"
      ]
      ++ cfg.whitelistIPv4
      ++ edgeIPv4
      ++ lib.optionals config.networking.enableIPv6 cfg.whitelistIPv6
      ++ lib.optionals config.networking.enableIPv6 edgeIPv6;
      banaction = "custom-ipset";
      banaction-allports = "custom-ipset";
      bantime = cfg.banTime;
      bantime-increment = {
        enable = true;
        formula = "ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor)";
        overalljails = true;
      };
      jails = {
        DEFAULT.settings = {
          loglevel = "WARNING";
        };
        kernel-sus-connections.settings = lib.mkIf cfg.kernelJail.enable {
          enabled = true;
          filter = "kernel-sus-connections";
          backend = "systemd";
          maxretry = 3;
          findtime = 3600;
          bantime = "168h";
          action = "custom-ipset";
        };
        nginx-protect.settings = lib.mkIf cfg.nginx.enable {
          enabled = true;
          filter = "nginx-protect";
          logpath = cfg.nginx.logPath;
          backend = "auto";
          maxretry = 3;
          findtime = 3600;
          bantime = "168h";
          action =
            if cfg.cloudflare.enable then
              ''
                custom-ipset[name=%(__name__)s]
                                    cloudflare''
            else
              "custom-ipset";
        };
      };
    };

    environment.etc = {
      # actionban caps <bantime> at 2147483s. That cap is specifically an
      # ipset signed-int32-timeout limit; nftables' set-element timeout is
      # not known to have the same ceiling, but since it's never been
      # verified against a real kernel in this environment, the nftables
      # actions below keep the identical cap out of caution rather than
      # assume a wider one is safe.
      "fail2ban/action.d/custom-ipset.conf".text =
        if nft then
          ''
            [Definition]
            actionstart =
            actionstop =
            actioncheck =

            actionban = TIMEOUT=<bantime>; \
                       [ "$TIMEOUT" -gt 2147483 ] && TIMEOUT=2147483; \
                       ${nftBin} add element inet fail2ban-ipset f2b-banned "{ <ip> timeout ''${TIMEOUT}s }" || \
                       (echo "Failed to ban <ip> in nftables set f2b-banned" >&2; exit 1)
            actionunban = ${nftBin} delete element inet fail2ban-ipset f2b-banned "{ <ip> }" || \
                         (echo "Failed to unban <ip> from nftables set f2b-banned (may already be unbanned)" >&2)

            [Init]
            bantime = 345600
          ''
        else
          ''

            [INCLUDES]
            before = iptables-common.conf

            [Definition]
            actionstart =
            actionstop =
            actioncheck =

            actionban = TIMEOUT=<bantime>; \
                       [ "$TIMEOUT" -gt 2147483 ] && TIMEOUT=2147483; \
                       ${pkgs.ipset}/bin/ipset -exist add f2b-banned <ip> timeout $TIMEOUT || \
                       (echo "Failed to ban <ip> in ipset f2b-banned" >&2; exit 1)
            actionunban = ${pkgs.ipset}/bin/ipset -exist del f2b-banned <ip> || \
                         (echo "Failed to unban <ip> from ipset f2b-banned (may already be unbanned)" >&2)

            [Init]
            bantime = 345600
          '';

      "fail2ban/action.d/custom-ipset6.conf".text =
        if nft then
          ''
            [Definition]
            actionstart =
            actionstop =
            actioncheck =

            actionban = TIMEOUT=<bantime>; \
                       [ "$TIMEOUT" -gt 2147483 ] && TIMEOUT=2147483; \
                       ${nftBin} add element inet fail2ban-ipset f2b-banned6 "{ <ip> timeout ''${TIMEOUT}s }" || \
                       (echo "Failed to ban <ip> in nftables set f2b-banned6" >&2; exit 1)
            actionunban = ${nftBin} delete element inet fail2ban-ipset f2b-banned6 "{ <ip> }" || \
                         (echo "Failed to unban <ip> from nftables set f2b-banned6 (may already be unbanned)" >&2)

            [Init]
            bantime = 345600
          ''
        else
          ''

            [INCLUDES]
            before = iptables-common.conf

            [Definition]
            actionstart =
            actionstop =
            actioncheck =

            actionban = TIMEOUT=<bantime>; \
                       [ "$TIMEOUT" -gt 2147483 ] && TIMEOUT=2147483; \
                       ${pkgs.ipset}/bin/ipset -exist add f2b-banned6 <ip> timeout $TIMEOUT || \
                       (echo "Failed to ban <ip> in ipset f2b-banned6" >&2; exit 1)
            actionunban = ${pkgs.ipset}/bin/ipset -exist del f2b-banned6 <ip> || \
                         (echo "Failed to unban <ip> from ipset f2b-banned6 (may already be unbanned)" >&2)

            [Init]
            bantime = 345600
          '';

      "fail2ban/filter.d/kernel-sus-connections.local".text = lib.mkDefault (
        lib.mkAfter ''
          [Definition]
          failregex = ^.*refused connection: .* SRC=<HOST> .*$
          journalmatch = _TRANSPORT=kernel
        ''
      );

      "fail2ban/filter.d/nginx-protect.local".text = lib.mkDefault (
        lib.mkAfter ''
          [Definition]
          failregex = <HOST> - - \[.*\] (${nginxRules})
        ''
      );
    }
    // lib.optionalAttrs cfg.cloudflare.enable {
      "fail2ban/action.d/cloudflare.local".text = ''
        [Definition]
        actionstart = echo "Cloudflare action started for <name>" | ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p info
        actionstop = echo "Cloudflare action stopped for <name>" | ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p info
        actioncheck =
        actionban = ${pkgs.bash}/bin/bash -c ' \
          API_KEY=$(cat ${cfg.cloudflare.apiKeyFile} | tr -d "\n\r ") && \
          ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p info \
            echo "Attempting to ban <ip> via Cloudflare API" && \
          RESPONSE=$(${pkgs.curl}/bin/curl -s -w "\nHTTP_CODE:%%{http_code}" \
            -X POST "https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules" \
            -H "Authorization: Bearer $API_KEY" \
            -H "Content-Type: application/json" \
            --data "{\"mode\":\"block\",\"configuration\":{\"target\":\"ip\",\"value\":\"<ip>\"},\"notes\":\"Fail2ban (<name>)\"}" \
            2>&1) && \
          HTTP_CODE=$(echo "$RESPONSE" | tail -n1 | cut -d: -f2) && \
          BODY=$(echo "$RESPONSE" | sed "$d") && \
          if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then \
            ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p info \
              echo "Successfully banned <ip> via Cloudflare"; \
          elif [ "$HTTP_CODE" = "400" ] && echo "$BODY" | grep -q "duplicate_of_existing"; then \
            ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p warning \
              echo "IP <ip> already banned in Cloudflare - skipping"; \
          else \
            ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p err \
              echo "Failed to ban <ip> - HTTP $HTTP_CODE - Response: $BODY"; \
            exit 1; \
          fi'
        actionunban = ${pkgs.bash}/bin/bash -c ' \
          API_KEY=$(cat ${cfg.cloudflare.apiKeyFile} | tr -d "\n\r ") && \
          ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p info \
            echo "Attempting to unban <ip> via Cloudflare API" && \
          RULE_ID=$(${pkgs.curl}/bin/curl -s \
            -X GET "https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules?mode=block&configuration.target=ip&configuration.value=<ip>&page=1&per_page=1" \
            -H "Authorization: Bearer $API_KEY" \
            -H "Content-Type: application/json" | \
            ${pkgs.jq}/bin/jq -r ".result[0].id // empty") && \
          if [ -n "$RULE_ID" ]; then \
            RESPONSE=$(${pkgs.curl}/bin/curl -s -w "\nHTTP_CODE:%%{http_code}" \
              -X DELETE "https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules/$RULE_ID" \
              -H "Authorization: Bearer $API_KEY" \
              -H "Content-Type: application/json" 2>&1) && \
            HTTP_CODE=$(echo "$RESPONSE" | tail -n1 | cut -d: -f2) && \
            if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then \
              ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p info \
                echo "Successfully unbanned <ip> from Cloudflare"; \
            else \
              BODY=$(echo "$RESPONSE" | sed "$d") && \
              ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p err \
                echo "Failed to unban <ip> - HTTP $HTTP_CODE - Response: $BODY"; \
              exit 1; \
            fi \
          else \
            ${pkgs.systemd}/bin/systemd-cat -t fail2ban-cloudflare -p warning \
              echo "No Cloudflare rule found for <ip> - possibly already unbanned"; \
          fi'
      '';
    };
  };
}