From 9e2e6cbb99ccdd63be7edee9eccd5510fffee44b Mon Sep 17 00:00:00 2001 From: Ruakij Date: Wed, 7 Oct 2026 21:10:35 +0000 Subject: [PATCH] Add IPv4 port forwards and masquerade to host_firewall in an ip host_nat table --- host_firewall/defaults/main.yml | 5 +++++ host_firewall/files/nft-host.service | 2 +- host_firewall/templates/host.nft.j2 | 26 +++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/host_firewall/defaults/main.yml b/host_firewall/defaults/main.yml index 5f396e5..cb446f8 100644 --- a/host_firewall/defaults/main.yml +++ b/host_firewall/defaults/main.yml @@ -26,6 +26,11 @@ firewall: ifaces: {} v4: {} v6: {} + # IPv4 port forwards: name -> {daddr, to, tcp: ports, udp: ports}, ports as in + # the maps above. Forwarded traffic passes the forward hook, not input. + dnat: {} + # IPv4 source NAT: name -> nft match, e.g. 'oifname "vmbr0"'. + masquerade: {} 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/files/nft-host.service b/host_firewall/files/nft-host.service index 6d63ef2..a8f2373 100644 --- a/host_firewall/files/nft-host.service +++ b/host_firewall/files/nft-host.service @@ -1,5 +1,5 @@ [Unit] -Description=Host input firewall (nftables table inet host) +Description=Host input firewall and NAT (nftables tables inet host and ip host_nat) # Same early-boot placement as nftables.service. Its "flush ruleset" on start, # reload and stop would wipe this table, so load after it and follow all three. DefaultDependencies=no diff --git a/host_firewall/templates/host.nft.j2 b/host_firewall/templates/host.nft.j2 index 1e43a4d..bdb716f 100644 --- a/host_firewall/templates/host.nft.j2 +++ b/host_firewall/templates/host.nft.j2 @@ -16,11 +16,15 @@ set {{ name }} { {% 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 %} # Declare-then-delete makes every load one atomic transaction that replaces -# only this table; Cilium, Docker, NetBird and CrowdSec tables stay untouched. +# 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" @@ -116,3 +120,23 @@ table inet host { {% 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 %}