#!/usr/sbin/nft -f # {{ ansible_managed }} {% macro list(map) -%} {{ map | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | map('string') | unique | join(', ') }} {%- endmacro %} {% macro addr_set(name, type, map) -%} set {{ name }} { type {{ type }}; flags interval; auto-merge; {% if list(map) %} elements = { {{ list(map) }} } {% 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 %} {% set guest_match = 'iifname { "' ~ (guest_ifaces | join('", "')) ~ '" }' %} {% set dnat = firewall.dnat | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | list %} {% set masquerade = firewall.masquerade | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %} {% set scrapers = firewall.scrapers | default({}, true) %} {% set forward_guard = firewall.forward_guard | default({}, true) | dict2items | rejectattr('value', 'none') | list %} {% set blocklists = firewall.spamhaus or firewall.firehol %} {% macro port_values(map) -%} {{ map | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | map('string') | unique | join(' ') }} {%- endmacro %} {# Per-service limits apply only to ports the host serves: input for the public and geo ports, forward for DNATed traffic (port forwards of the host and Docker-published ports). #} {% set served_input = (port_values(firewall.public.tcp) ~ ' ' ~ port_values(firewall.geo.tcp)).split() %} {% set served_forward = (port_values(firewall.public.tcp) ~ ' ' ~ port_values(firewall.geo.tcp) ~ ' ' ~ (dnat | map(attribute='tcp') | map('default', {}, true) | map('dict2items') | sum(start=[]) | rejectattr('value', 'none') | map(attribute='value') | map('string') | join(' '))).split() %} {% set rl = firewall.ratelimit | default({}, true) %} {% set rl_factor = rl.range_factor | default({}, true) %} {% set rl_services = [] %} {% for svc in rl.services | default({}, true) | dict2items | rejectattr('value', 'none') %} {% set ports = [svc.value.ports] | flatten | map('string') | list %} {% set per = svc.value.per | default('minute', true) %} {% set burst = svc.value.burst | default(svc.value.rate * 2, true) %} {% set _ = rl_services.append({ 'name': svc.key | replace('-', '_'), 'ports': ports | join(', '), 'rate': svc.value.rate, 'burst': burst, 'per': per, 'f4': svc.value.range_factor_v4 | default(rl_factor.v4 | default(6), true), 'f6': svc.value.range_factor_v6 | default(rl_factor.v6 | default(3), true), 'input': (ports | select('in', served_input) | list | length) > 0, 'forward': (ports | select('in', served_forward) | list | length) > 0, }) %} {% endfor %} {% set rl_input = rl_services | selectattr('input') | list %} {% set rl_forward = rl_services | selectattr('forward') | list %} # Declare-then-delete makes every load one atomic transaction that replaces # only these tables; Cilium, Docker, NetBird and CrowdSec tables stay untouched. table inet host {} delete table inet host table ip host_nat {} delete table ip host_nat {% if firewall.geo_countries %} include "/etc/nftables.d/host-geo.nft" {% endif %} {% if firewall.spamhaus %} include "/etc/nftables.d/host-spamhaus.nft" {% endif %} {% if firewall.firehol %} include "/etc/nftables.d/host-firehol.nft" {% endif %} {% if firewall.scanners %} include "/etc/nftables.d/host-scanners.nft" {% endif %} table inet host { {{ addr_set('admin_v4', 'ipv4_addr', firewall.admin.v4) }} {{ addr_set('admin_v6', 'ipv6_addr', firewall.admin.v6) }} {{ addr_set('cluster_v4', 'ipv4_addr', cluster.v4) }} {{ 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; } set rl_{{ svc.name }}_v6 { type ipv6_addr; flags dynamic; timeout 10m; size 65535; } set rl_{{ svc.name }}_v4r { type ipv4_addr; flags dynamic; timeout 10m; size 16384; } 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 { type filter hook input priority filter; policy drop; {% if blocklists %} # Before the established rule: outbound is blocked too, so listed # addresses have no legitimate replies. {% if firewall.spamhaus %} ip saddr @spamhaus_v4 jump spamhaus_in ip6 saddr @spamhaus_v6 jump spamhaus_in {% endif %} {% if firewall.firehol %} 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 %} # After the established rule: only inbound connections of scanners drop. ip saddr @scanners_v4 jump scanners_in ip6 saddr @scanners_v6 jump scanners_in {% endif %} ct state invalid jump drop_log iif lo accept icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query } accept udp sport 67 udp dport 68 accept meta nfproto ipv6 udp sport 547 udp dport 546 accept {% if cluster_ifaces or list(cluster.v4) or list(cluster.v6) %} # ponytail: the cluster ring trusts every port between nodes and pods; # per-port lists (6443, 10250, 2379-2380, 4240, 8472, ...) are the upgrade path. {% for iface in cluster_ifaces %} iifname "{{ iface }}" accept {% endfor %} iifname "wt0" ip saddr @cluster_v4 accept iifname "wt0" ip6 saddr @cluster_v6 accept {% endif %} {% if internal_ifaces %} # ponytail: internal bridges are trusted on every port; per-port lists are the upgrade path. {% for iface in internal_ifaces %} iifname "{{ iface }}" accept {% endfor %} {% endif %} {% if guest_ifaces %} # Pods on k3s guests egress through the node IP, so guest bridges get # only DHCP and router solicitations beyond the public zone. {{ guest_match }} udp dport 67 accept {{ guest_match }} icmpv6 type nd-router-solicit accept {% endif %} # VPN peers get the vpn ports, then everything public. iifname "wt0" jump vpn jump public # Broadcasts and multicasts are never scans; they stay out of the log. meta pkttype { broadcast, multicast } drop jump drop_log } # A single rule makes the limit global: a flood logs at the limit and the # rest drops silently. The kernel log line has the iptables LOG fields that # the CrowdSec iptables-logs parser reads. chain drop_log { limit rate {{ firewall.log.in_rate }} log prefix "fw-in-drop: " drop } {% if rl_services | selectattr('input') | list or rl_forward %} # 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 # 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 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 %} iifname "{{ iface }}" return {% endfor %} ip saddr @admin_v4 return 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 }} { {{ rl_exempt() }} {% for svc in svcs %} meta l4proto tcp ct original proto-dst { {{ svc.ports }} } counter jump rl_{{ svc.name }} {% endfor %} } {% endfor %} {% 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 %} {% 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 %} # Own limits, so a listed scanner cannot use up the budget of fw-in-drop. # CrowdSec whitelists both prefixes: the lists already ban these networks. chain spamhaus_in { limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-spamhaus-in: " drop } chain spamhaus_out { limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-spamhaus-out: " reject } {% endif %} {% if firewall.firehol %} chain firehol_in { limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-firehol-in: " drop } chain firehol_out { limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-firehol-out: " reject } {% endif %} {% if firewall.scanners %} # Own limit and prefix, so scanners use up neither the fw-in-drop budget # nor the one of the blocklists. CrowdSec whitelists the prefix: scans of # these networks are known and need no decision. chain scanners_in { limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-scanners-in: " drop } {% endif %} {% if firewall.block_smtp_out or blocklists %} # Outbound mail goes through the mail host only. Loopback stays open for a # local MTA; the log lines carry OUT=, which CrowdSec does not count as a scan. chain output { type filter hook output priority filter; policy accept; {% if firewall.spamhaus %} ip daddr @spamhaus_v4 jump spamhaus_out ip6 daddr @spamhaus_v6 jump spamhaus_out {% endif %} {% if firewall.firehol %} ip daddr @firehol_v4 jump firehol_out {% endif %} {% if firewall.block_smtp_out %} oifname != "lo" tcp dport 25 limit rate {{ firewall.log.out_rate }} log prefix "fw-out-reject: " oifname != "lo" tcp dport 25 reject with tcp reset {% endif %} } {% endif %} chain vpn { {% if list(scrapers.tcp) %} {% if list(scrapers.v4) %} ip saddr @scrapers_v4 tcp dport { {{ list(scrapers.tcp) }} } accept {% endif %} {% if list(scrapers.v6) %} ip6 saddr @scrapers_v6 tcp dport { {{ list(scrapers.tcp) }} } accept {% endif %} {% endif %} {% if list(firewall.vpn.tcp) %} tcp dport { {{ list(firewall.vpn.tcp) }} } accept {% endif %} {% if list(firewall.vpn.udp) %} udp dport { {{ list(firewall.vpn.udp) }} } accept {% endif %} } chain public { {% if rl_input %} ct state new jump ratelimit_in {% endif %} {% if list(firewall.public.tcp) %} tcp dport { {{ list(firewall.public.tcp) }} } accept {% endif %} {% if list(firewall.public.udp) %} udp dport { {{ list(firewall.public.udp) }} } accept {% endif %} {% if list(firewall.geo.tcp) %} tcp dport { {{ list(firewall.geo.tcp) }} } jump geo {% endif %} {% if list(firewall.admin.tcp) %} tcp dport { {{ list(firewall.admin.tcp) }} } jump admin {% endif %} } {% if list(firewall.admin.tcp) %} chain admin { ip saddr @admin_v4 accept ip6 saddr @admin_v6 accept } {% endif %} chain geo { {% if guest_ifaces %} # Guest IPv6 addresses sit inside the geo sets. {{ guest_match }} return {% endif %} ip saddr @admin_v4 accept ip6 saddr @admin_v6 accept {% if firewall.geo_countries %} ip saddr @geo_v4 accept ip6 saddr @geo_v6 accept {% endif %} } {% if forward_guard or blocklists or firewall.scanners or rl_forward %} chain forward { type filter hook forward priority filter; policy accept; {% if rl_forward %} # 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 %} ip saddr @spamhaus_v4 jump spamhaus_in ip6 saddr @spamhaus_v6 jump spamhaus_in ip daddr @spamhaus_v4 jump spamhaus_out ip6 daddr @spamhaus_v6 jump spamhaus_out {% endif %} {% if firewall.firehol %} ip saddr @firehol_v4 jump firehol_in ip daddr @firehol_v4 jump firehol_out {% endif %} {% if firewall.scanners %} ct state new ip saddr @scanners_v4 jump scanners_in ct state new ip6 saddr @scanners_v6 jump scanners_in {% endif %} {% for guard in forward_guard %} # {{ guard.key }} {% set ifaces = guard.value.ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %} {% for fam, l3, icmp in [('v4', 'ip', 'icmp'), ('v6', 'ip6', 'icmpv6')] if list((guard.value[fam] | default({}, true)).daddr) %} {% set zone = guard.value[fam] %} {% set match = l3 ~ ' daddr { ' ~ list(zone.daddr) ~ ' }' %} {{ match }} ct state established,related accept {% if ifaces %} {{ match }} iifname { "{{ ifaces | join('", "') }}" } accept {% endif %} {% if list(zone.tcp) %} {{ match }} tcp dport { {{ list(zone.tcp) }} } accept {% endif %} {% if list(zone.icmp) %} {{ match }} {{ icmp }} type { {{ list(zone.icmp) }} } accept {% endif %} {{ match }} jump drop_log {% endfor %} {% endfor %} } {% endif %} } {% if dnat or masquerade %} table ip host_nat { chain prerouting { type nat hook prerouting priority dstnat; policy accept; {% for fwd in dnat %} {% for proto in ['tcp', 'udp'] if list(fwd[proto]) %} ip daddr {{ fwd.daddr }} {{ proto }} dport { {{ list(fwd[proto]) }} } dnat to {{ fwd.to }} {% endfor %} {% endfor %} } chain postrouting { type nat hook postrouting priority srcnat; policy accept; {% for match in masquerade %} {{ match }} masquerade {% endfor %} } } {% endif %}