Replace the nftables role with host_firewall, one atomic inet host input table with zones, geo sets and lockout rollback

This commit is contained in:
2026-10-07 07:29:29 +00:00
parent 27e9d4a1ab
commit cfe87002c5
19 changed files with 417 additions and 227 deletions
+118
View File
@@ -0,0 +1,118 @@
#!/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 %}
{% set cluster = firewall.cluster | default({}, true) %}
{% 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('", "')) ~ '" }' %}
# Declare-then-delete makes every load one atomic transaction that replaces
# only this table; Cilium, Docker, NetBird and CrowdSec tables stay untouched.
table inet host {}
delete table inet host
{% if firewall.geo_countries %}
include "/etc/nftables.d/host-geo.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) }}
set ssh_meter_v4 {
type ipv4_addr; flags dynamic; timeout 10m; size 65535;
}
set ssh_meter_v6 {
type ipv6_addr; flags dynamic; timeout 10m; size 65535;
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
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
}
chain vpn {
{% 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 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 %}
}
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
tcp dport 22 ct state new update @ssh_meter_v4 { ip saddr limit rate over {{ firewall.ssh_rate_limit }} } drop
# One IPv6 client usually holds a whole /64.
tcp dport 22 ct state new update @ssh_meter_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ firewall.ssh_rate_limit }} } drop
{% if firewall.geo_countries %}
ip saddr @geo_v4 accept
ip6 saddr @geo_v6 accept
{% endif %}
}
}
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# {{ ansible_managed }}
# Rebuilds the geo sets of table inet host from ipdeny. Any failed download or
# check exits before the swap, so the previous file stays in place.
set -eu
out=/etc/nftables.d/host-geo.nft
countries="{{ firewall.geo_countries | join(' ') }}"
prefix='^[0-9a-f.:]+/[0-9]+$'
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
fetch() {
curl -fsS --retry 3 --max-time 120 -o "$2" "$1"
grep -Eq "$prefix" "$2" || { echo "no prefixes in $1" >&2; exit 1; }
}
for cc in $countries; do
fetch "https://www.ipdeny.com/ipblocks/data/aggregated/$cc-aggregated.zone" "$tmp/$cc.v4"
fetch "https://www.ipdeny.com/ipv6/ipaddresses/aggregated/$cc-aggregated.zone" "$tmp/$cc.v6"
done
elements() {
cat "$@" | grep -E "$prefix" | sed 's/$/,/'
}
{
echo "# Generated by $0, do not edit."
echo 'table inet host {'
echo ' set geo_v4 { type ipv4_addr; flags interval; auto-merge; elements = {'
elements "$tmp"/*.v4
echo ' } }'
echo ' set geo_v6 { type ipv6_addr; flags interval; auto-merge; elements = {'
elements "$tmp"/*.v6
echo ' } }'
echo '}'
} >"$tmp/host-geo.nft"
nft -c -f "$tmp/host-geo.nft"
install -m 0644 "$tmp/host-geo.nft" "$out.new"
mv -f "$out.new" "$out"
if systemctl -q is-active nft-host.service; then
systemctl reload nft-host.service
fi