From 589f899bae37e8054693201d26fdd1190c40e5b6 Mon Sep 17 00:00:00 2001 From: Ruakij Date: Mon, 26 Oct 2026 13:02:42 +0000 Subject: [PATCH] host_firewall: public_ips of every inventory host are exempt from the rate-limit meters, ArgoCD fetches and mail submission from the cluster nodes hit the web and submission limits --- host_firewall/defaults/main.yml | 4 ++-- host_firewall/templates/host.nft.j2 | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/host_firewall/defaults/main.yml b/host_firewall/defaults/main.yml index 2a164db..f61c508 100644 --- a/host_firewall/defaults/main.yml +++ b/host_firewall/defaults/main.yml @@ -46,8 +46,8 @@ firewall: forward_guard: {} # Limits on new inbound TCP connections per service, applied to the public and geo ports the host serves (input) and to # DNATed traffic (forward: port forwards and Docker-published ports), after the admin, cluster, VPN, guest and internal - # sources are exempted. Each service has a tier per source (IPv4 /32, IPv6 /64) and a tier per range (IPv4 /24, IPv6 - # /48) at rate * range_factor. The excess drops with the log prefix fw-ratelimit: (log.ratelimit_rate). + # sources and the public_ips of every inventory host (IPv4 per address, IPv6 per /64) are exempted. Each service has a tier + # per source (IPv4 /32, IPv6 /64) and a tier per range (IPv4 /24, IPv6 /48) at rate * range_factor. The excess drops with the log prefix fw-ratelimit: (log.ratelimit_rate). # name -> {ports: port or list, rate: connections per `per`, per: second|minute (minute), burst: connections (2 * rate), # range_factor_v4, range_factor_v6: override the factors below}; null drops a service, a service is idle unless a port is served. # Values are 3x the highest legitimate rate seen, with a floor of 12 per minute (3x four connections per minute) where diff --git a/host_firewall/templates/host.nft.j2 b/host_firewall/templates/host.nft.j2 index 3b2b2a0..cba9e05 100644 --- a/host_firewall/templates/host.nft.j2 +++ b/host_firewall/templates/host.nft.j2 @@ -11,7 +11,19 @@ set {{ name }} { {% endif %} } {%- endmacro %} +{% macro addr_list_set(name, type, items) -%} +set {{ name }} { + type {{ type }}; flags interval; auto-merge; +{% if items %} + elements = { {{ items | join(', ') }} } +{% endif %} + } +{%- endmacro %} {% set cluster = firewall.cluster | default({}, true) %} +{# public_ips of every inventory host: IPv4 per address, IPv6 per /64 (a longer prefix is widened, nft masks the host bits) #} +{% set public_ips = groups['all'] | map('extract', hostvars, 'public_ips') | select('defined') | flatten | map('string') | unique | list %} +{% set exempt_v4 = public_ips | reject('search', ':') | map('regex_replace', '^([^/]+)$', '\\1/32') | list %} +{% set exempt_v6 = public_ips | select('search', ':') | map('regex_replace', '^([^/]+)$', '\\1/128') | map('regex_replace', '/(6[5-9]|[7-9][0-9]|1[01][0-9]|12[0-8])$', '/64') | list %} {% set cluster_ifaces = cluster.ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %} {% set internal_ifaces = firewall.internal_ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %} {% set guest_ifaces = firewall.guest_ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %} @@ -76,6 +88,8 @@ table inet host { {{ addr_set('cluster_v6', 'ipv6_addr', cluster.v6) }} {{ addr_set('scrapers_v4', 'ipv4_addr', scrapers.v4) }} {{ addr_set('scrapers_v6', 'ipv6_addr', scrapers.v6) }} + {{ addr_list_set('rl_exempt_v4', 'ipv4_addr', exempt_v4) }} + {{ addr_list_set('rl_exempt_v6', 'ipv6_addr', exempt_v6) }} {% for svc in rl_services if svc.input or svc.forward %} set rl_{{ svc.name }}_v4 { type ipv4_addr; flags dynamic; timeout 10m; size 65535; @@ -166,7 +180,9 @@ table inet host { # Per-service limits on new inbound TCP connections (firewall.ratelimit): per source address (IPv4 /32, IPv6 /64) and per # range (IPv4 /24, IPv6 /48; the per-source rate times range_factor). Admin, cluster, VPN, guest and internal sources - # are exempt. The excess drops with its own log prefix, which CrowdSec reads as a ratelimit event and not as a port scan. + # and the public addresses of all inventory hosts (rl_exempt_v4, rl_exempt_v6) are exempt: cluster nodes reach other hosts + # with their public address (ArgoCD git fetches, mail submission). The public set is checked only here, no other chain + # trusts it. The excess drops with its own log prefix, which CrowdSec reads as a ratelimit event and not as a port scan. {% macro rl_exempt() %} iifname "wt0" return {% for iface in cluster_ifaces + internal_ifaces + guest_ifaces %} @@ -176,6 +192,8 @@ table inet host { ip6 saddr @admin_v6 return ip saddr @cluster_v4 return ip6 saddr @cluster_v6 return + ip saddr @rl_exempt_v4 return + ip6 saddr @rl_exempt_v6 return {% endmacro %} {% for hook, svcs in [('in', rl_input), ('fwd', rl_forward)] if svcs %} chain ratelimit_{{ hook }} {