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
+52 -9
View File
@@ -103,6 +103,12 @@ table inet host {
set rl_{{ svc.name }}_v6r {
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 %}
chain input {
@@ -119,6 +125,11 @@ table inet host {
ip saddr @firehol_v4 jump firehol_in
{% 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 %}
ct state established,related accept
{% 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
# 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.
# 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() %}
iifname "wt0" return
{% for iface in cluster_ifaces + internal_ifaces + guest_ifaces %}
@@ -204,19 +218,47 @@ table inet host {
}
{% endfor %}
{% for svc in rl_services if svc.input or svc.forward %}
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
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
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
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
{% for hook, svcs in [('in', rl_input), ('fwd', rl_forward)] if svcs %}
chain ratelimit_hs_{{ hook }} {
{{ rl_exempt() }}
{% for svc in svcs %}
meta l4proto tcp ct original proto-dst { {{ svc.ports }} } counter jump rl_hs_{{ svc.name }}
{% endfor %}
}
{% endfor %}
chain rl_drop {
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-ratelimit: "
{% for svc in rl_services if svc.input or svc.forward %}
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
}
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 %}
{% 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.
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 %}
{% if firewall.spamhaus %}