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:
@@ -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
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user