Files
Ansible-roles/host_firewall/templates/host.nft.j2
T

450 lines
17 KiB
Django/Jinja

#!/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,
'syn_rate': svc.value.rate * 2,
'syn_burst': burst * 2,
'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 handshake meters carry the service limit (rate and burst of firewall.ratelimit): they count the packet that
# completes a handshake (the second original-direction packet of an established connection), which a spoofed source cannot
# send, so a real client above the limit is logged with fw-rl-hs: and reset. Only the address tier exists there; ranges are
# left to CrowdSec. The SYN meters are a looser flood cap at twice the rate and burst: they 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, so they are
# evidence only. Their cap sits above the service limit so that a real client reaches the handshake meter first.
{% 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.syn_rate }}/{{ svc.per }} burst {{ svc.syn_burst }} packets } counter jump rl_drop_addr
update @rl_{{ svc.name }}_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ svc.syn_rate }}/{{ svc.per }} burst {{ svc.syn_burst }} packets } counter jump rl_drop_addr
update @rl_{{ svc.name }}_v4r { ip saddr & 255.255.255.0 limit rate over {{ svc.syn_rate * svc.f4 }}/{{ svc.per }} burst {{ svc.syn_burst * svc.f4 }} packets } counter jump rl_drop_range
update @rl_{{ svc.name }}_v6r { ip6 saddr & ffff:ffff:ffff:: limit rate over {{ svc.syn_rate * svc.f6 }}/{{ svc.per }} burst {{ svc.syn_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 %}