From b45a07da564f085a4bffc76f0a4052d7290efa81 Mon Sep 17 00:00:00 2001 From: Ruakij Date: Thu, 8 Oct 2026 08:48:40 +0000 Subject: [PATCH] Add forward_guard to host_firewall for a forward chain that drops traffic to guarded guests outside the allowed interfaces, ports and ICMP types --- host_firewall/defaults/main.yml | 5 +++++ host_firewall/templates/host.nft.j2 | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/host_firewall/defaults/main.yml b/host_firewall/defaults/main.yml index cb446f8..6196d8d 100644 --- a/host_firewall/defaults/main.yml +++ b/host_firewall/defaults/main.yml @@ -31,6 +31,11 @@ firewall: dnat: {} # IPv4 source NAT: name -> nft match, e.g. 'oifname "vmbr0"'. masquerade: {} + # Forwarded traffic to guarded guests: name -> {ifaces, v4, v6}, where v4 and + # v6 are {daddr, tcp: ports, icmp: icmp or icmpv6 types}. Replies and traffic + # in on ifaces pass, the listed ports and types pass, the rest to daddr drops; + # traffic to other addresses is not filtered. + forward_guard: {} ssh_rate_limit: "10/minute burst 5 packets" # EU-27 plus CH, NO, GB, IS, LI (ipdeny country codes). Empty leaves the geo # ports to the admin set; a load peaks at about 60 MB of kernel memory, too much for a 1 GB host. diff --git a/host_firewall/templates/host.nft.j2 b/host_firewall/templates/host.nft.j2 index bdb716f..f012c19 100644 --- a/host_firewall/templates/host.nft.j2 +++ b/host_firewall/templates/host.nft.j2 @@ -18,6 +18,7 @@ set {{ name }} { {% 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 forward_guard = firewall.forward_guard | default({}, true) | dict2items | rejectattr('value', 'none') | list %} # Declare-then-delete makes every load one atomic transaction that replaces # only these tables; Cilium, Docker, NetBird and CrowdSec tables stay untouched. @@ -119,6 +120,32 @@ table inet host { ip6 saddr @geo_v6 accept {% endif %} } +{% if forward_guard %} + + chain forward { + type filter hook forward priority filter; policy accept; +{% 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 }} drop +{% endfor %} +{% endfor %} + } +{% endif %} } {% if dnat or masquerade %}