diff --git a/host_firewall/defaults/main.yml b/host_firewall/defaults/main.yml index f61c508..7ca9518 100644 --- a/host_firewall/defaults/main.yml +++ b/host_firewall/defaults/main.yml @@ -46,8 +46,12 @@ 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 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). + # sources and the public_ips of every inventory host (IPv4 per address, IPv6 per /64) are exempted. Each service has a SYN + # 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), # 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 @@ -67,12 +71,12 @@ firewall: web: ports: [80, 443] 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 # rate is one limit shared by all matching packets. log: 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" out_rate: "6/minute burst 5 packets" # Lets the kernel log the nft log statements of container network diff --git a/host_firewall/tasks/main.yml b/host_firewall/tasks/main.yml index 51cb7d7..f0fda78 100644 --- a/host_firewall/tasks/main.yml +++ b/host_firewall/tasks/main.yml @@ -38,6 +38,22 @@ reload: false 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 ansible.builtin.copy: src: "{{ item }}" diff --git a/host_firewall/templates/host.nft.j2 b/host_firewall/templates/host.nft.j2 index cba9e05..97e86f3 100644 --- a/host_firewall/templates/host.nft.j2 +++ b/host_firewall/templates/host.nft.j2 @@ -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 %}