Add forward_guard to host_firewall for a forward chain that drops traffic to guarded guests outside the allowed interfaces, ports and ICMP types

This commit is contained in:
2026-10-08 08:48:40 +00:00
parent 9ded6814c3
commit b45a07da56
2 changed files with 32 additions and 0 deletions
+5
View File
@@ -31,6 +31,11 @@ firewall:
dnat: {} dnat: {}
# IPv4 source NAT: name -> nft match, e.g. 'oifname "vmbr0"'. # IPv4 source NAT: name -> nft match, e.g. 'oifname "vmbr0"'.
masquerade: {} 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" ssh_rate_limit: "10/minute burst 5 packets"
# EU-27 plus CH, NO, GB, IS, LI (ipdeny country codes). Empty leaves the geo # 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. # ports to the admin set; a load peaks at about 60 MB of kernel memory, too much for a 1 GB host.
+27
View File
@@ -18,6 +18,7 @@ set {{ name }} {
{% set guest_match = 'iifname { "' ~ (guest_ifaces | join('", "')) ~ '" }' %} {% set guest_match = 'iifname { "' ~ (guest_ifaces | join('", "')) ~ '" }' %}
{% set dnat = firewall.dnat | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | list %} {% 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 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 # Declare-then-delete makes every load one atomic transaction that replaces
# only these tables; Cilium, Docker, NetBird and CrowdSec tables stay untouched. # only these tables; Cilium, Docker, NetBird and CrowdSec tables stay untouched.
@@ -119,6 +120,32 @@ table inet host {
ip6 saddr @geo_v6 accept ip6 saddr @geo_v6 accept
{% endif %} {% 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 %} {% if dnat or masquerade %}