host_firewall: handshake meters per source reject over-limit connections with fw-rl-hs:, SYN meters log fw-rl-addr: and fw-rl-range: as evidence, conntrack accounting on

This commit is contained in:
2026-10-26 14:35:28 +00:00
parent 4fbc4ad317
commit 5318997b73
3 changed files with 76 additions and 13 deletions
+8 -4
View File
@@ -46,8 +46,12 @@ firewall:
forward_guard: {} forward_guard: {}
# Limits on new inbound TCP connections per service, applied to the public and geo ports the host serves (input) and to # 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 # DNATed traffic (forward: port forwards and Docker-published ports), after the admin, cluster, VPN, guest and internal
# sources and the public_ips of every inventory host (IPv4 per address, IPv6 per /64) are exempted. Each service has a tier # sources and the public_ips of every inventory host (IPv4 per address, IPv6 per /64) are exempted. Each service has a SYN
# 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). # meter with a tier per source (IPv4 /32, IPv6 /64) and a tier per range (IPv4 /24, IPv6 /48) at rate * range_factor. Its
# excess drops with the log prefix fw-rl-syn-addr: (source tier) or fw-rl-syn-range: (range tier); a spoofed source can trigger
# both, so they are evidence only. A handshake meter per source (same rate and burst, no range tier) counts the packet
# that completes a TCP handshake, which a spoofed source cannot send; its excess is logged with fw-rl-hs: and reset.
# Log limit: log.ratelimit_rate.
# name -> {ports: port or list, rate: connections per `per`, per: second|minute (minute), burst: connections (2 * 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. # 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 # Values are 3x the highest legitimate rate seen, with a floor of 12 per minute (3x four connections per minute) where
@@ -67,12 +71,12 @@ firewall:
web: web:
ports: [80, 443] ports: [80, 443]
rate: 180 rate: 180
# Kernel log of dropped inbound packets (fw-in-drop: ), rate-limited connections (fw-ratelimit: ) and of rejected # Kernel log of dropped inbound packets (fw-in-drop: ), rate-limited connections (fw-rl-syn-addr: , fw-rl-syn-range: , fw-rl-hs: ) and of rejected
# outbound mail (fw-out-reject: ), read from the journal by CrowdSec. Each # outbound mail (fw-out-reject: ), read from the journal by CrowdSec. Each
# rate is one limit shared by all matching packets. # rate is one limit shared by all matching packets.
log: log:
in_rate: "5/second burst 30 packets" in_rate: "5/second burst 30 packets"
# Shared by all rate-limited connections (fw-ratelimit: ), one limit. # One limit each for fw-rl-syn-addr: , fw-rl-syn-range: and fw-rl-hs: , shared by all services.
ratelimit_rate: "5/second burst 30 packets" ratelimit_rate: "5/second burst 30 packets"
out_rate: "6/minute burst 5 packets" out_rate: "6/minute burst 5 packets"
# Lets the kernel log the nft log statements of container network # Lets the kernel log the nft log statements of container network
+16
View File
@@ -38,6 +38,22 @@
reload: false reload: false
when: firewall.log.all_netns when: firewall.log.all_netns
# The handshake meters match `ct original packets 2`, which counts only with conntrack accounting (off by default).
# The module is loaded at boot so that systemd-sysctl finds the key.
- name: Load nf_conntrack at boot
ansible.builtin.copy:
dest: /etc/modules-load.d/nf_conntrack.conf
content: "nf_conntrack\n"
mode: "0644"
- name: Count packets per connection in conntrack
ansible.posix.sysctl:
name: net.netfilter.nf_conntrack_acct
value: "1"
sysctl_file: /etc/sysctl.d/90-nf-conntrack-acct.conf
sysctl_set: true
reload: false
- name: Install the systemd units - name: Install the systemd units
ansible.builtin.copy: ansible.builtin.copy:
src: "{{ item }}" src: "{{ item }}"
+52 -9
View File
@@ -103,6 +103,12 @@ table inet host {
set rl_{{ svc.name }}_v6r { set rl_{{ svc.name }}_v6r {
type ipv6_addr; flags dynamic; timeout 10m; size 16384; type ipv6_addr; flags dynamic; timeout 10m; size 16384;
} }
set rl_hs_{{ svc.name }}_v4 {
type ipv4_addr; flags dynamic; timeout 10m; size 65535;
}
set rl_hs_{{ svc.name }}_v6 {
type ipv6_addr; flags dynamic; timeout 10m; size 65535;
}
{% endfor %} {% endfor %}
chain input { chain input {
@@ -119,6 +125,11 @@ table inet host {
ip saddr @firehol_v4 jump firehol_in ip saddr @firehol_v4 jump firehol_in
{% endif %} {% endif %}
{% endif %}
{% if rl_input %}
# Before the established rule: the packet that completes a handshake is the second original-direction packet
# (needs net.netfilter.nf_conntrack_acct).
meta l4proto tcp ct state established ct direction original ct original packets 2 jump ratelimit_hs_in
{% endif %} {% endif %}
ct state established,related accept ct state established,related accept
{% if firewall.scanners %} {% if firewall.scanners %}
@@ -182,7 +193,10 @@ table inet host {
# range (IPv4 /24, IPv6 /48; the per-source rate times range_factor). Admin, cluster, VPN, guest and internal sources # range (IPv4 /24, IPv6 /48; the per-source rate times range_factor). Admin, cluster, VPN, guest and internal sources
# and the public addresses of all inventory hosts (rl_exempt_v4, rl_exempt_v6) are exempt: cluster nodes reach other hosts # 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 # 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. # trusts it. The SYN meters drop the excess with the log prefixes fw-rl-syn-addr: (per address) and fw-rl-syn-range: (per range),
# which a spoofed source can trigger: evidence only. The handshake meters count the packet that completes a handshake
# (the second original-direction packet of an established connection), which a spoofed source cannot send: the excess is
# logged with fw-rl-hs: and reset. Only the address tier exists there; ranges are left to CrowdSec.
{% macro rl_exempt() %} {% macro rl_exempt() %}
iifname "wt0" return iifname "wt0" return
{% for iface in cluster_ifaces + internal_ifaces + guest_ifaces %} {% for iface in cluster_ifaces + internal_ifaces + guest_ifaces %}
@@ -204,19 +218,47 @@ table inet host {
} }
{% endfor %} {% endfor %}
{% for svc in rl_services if svc.input or svc.forward %} {% for hook, svcs in [('in', rl_input), ('fwd', rl_forward)] if svcs %}
chain rl_{{ svc.name }} { chain ratelimit_hs_{{ hook }} {
update @rl_{{ svc.name }}_v4 { ip saddr limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_drop {{ rl_exempt() }}
update @rl_{{ svc.name }}_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_drop {% for svc in svcs %}
update @rl_{{ svc.name }}_v4r { ip saddr & 255.255.255.0 limit rate over {{ svc.rate * svc.f4 }}/{{ svc.per }} burst {{ svc.burst * svc.f4 }} packets } counter jump rl_drop meta l4proto tcp ct original proto-dst { {{ svc.ports }} } counter jump rl_hs_{{ svc.name }}
update @rl_{{ svc.name }}_v6r { ip6 saddr & ffff:ffff:ffff:: limit rate over {{ svc.rate * svc.f6 }}/{{ svc.per }} burst {{ svc.burst * svc.f6 }} packets } counter jump rl_drop {% endfor %}
} }
{% endfor %} {% endfor %}
chain rl_drop { {% for svc in rl_services if svc.input or svc.forward %}
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-ratelimit: " chain rl_{{ svc.name }} {
update @rl_{{ svc.name }}_v4 { ip saddr limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_drop_addr
update @rl_{{ svc.name }}_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_drop_addr
update @rl_{{ svc.name }}_v4r { ip saddr & 255.255.255.0 limit rate over {{ svc.rate * svc.f4 }}/{{ svc.per }} burst {{ svc.burst * svc.f4 }} packets } counter jump rl_drop_range
update @rl_{{ svc.name }}_v6r { ip6 saddr & ffff:ffff:ffff:: limit rate over {{ svc.rate * svc.f6 }}/{{ svc.per }} burst {{ svc.burst * svc.f6 }} packets } counter jump rl_drop_range
}
{% endfor %}
{% for svc in rl_services if svc.input or svc.forward %}
chain rl_hs_{{ svc.name }} {
update @rl_hs_{{ svc.name }}_v4 { ip saddr limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_hs_reject
update @rl_hs_{{ svc.name }}_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_hs_reject
}
{% endfor %}
# One log limit per prefix, so a flood of spoofed SYNs cannot use up the budget of the handshake evidence.
chain rl_drop_addr {
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-rl-syn-addr: "
drop drop
} }
chain rl_drop_range {
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-rl-syn-range: "
drop
}
# The handshake is complete, so the connection has to be ended actively.
chain rl_hs_reject {
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-rl-hs: "
reject with tcp reset
}
{% endif %} {% endif %}
{% if firewall.spamhaus %} {% if firewall.spamhaus %}
@@ -339,6 +381,7 @@ table inet host {
# Docker-published ports and port forwards arrive here after DNAT; pod and guest egress is not DNATed. # Docker-published ports and port forwards arrive here after DNAT; pod and guest egress is not DNATed.
ct status dnat ct state new jump ratelimit_fwd ct status dnat ct state new jump ratelimit_fwd
meta l4proto tcp ct status dnat ct state established ct direction original ct original packets 2 jump ratelimit_hs_fwd
{% endif %} {% endif %}
{% if firewall.spamhaus %} {% if firewall.spamhaus %}