CentOS 8 as NAT router with nft and firewalld - how to get it to pass TFTP?

Reason it's not working

It appears firewalld might be geared to handle firewalling local services, rather than routed services.

So the tftp settings will add in the end these nft rules when firewalld has been configured (on CentOS 8) with the zones files in OP (just showing the rules, not the whole ruleset here):

table inet firewalld {
    chain filter_IN_external_allow {
        udp dport 69 ct helper set "helper-tftp-udp"
    }
    chain filter_IN_internal_allow {
        udp dport 69 ct helper set "helper-tftp-udp"
        udp dport 69 ct state { new, untracked } accept
    }
}

Those rules will never match and are thus useless: they are in the input path, not in the forward path.

With the running firewall, these (blindly copied) rules added at the right place: in the forward path, will make TFTP work:

nft insert rule inet firewalld filter_FWDI_internal_allow udp dport 69 ct helper set "helper-tftp-udp"
nft add rule inet firewalld filter_FWDI_internal_allow index 0 udp dport 69 ct state '{ new, untracked }' accept

So in the end a so-called direct option would still be an option so everything is stored in firewalld's configuration. Alas the documentation is a bit misleading:

Warning: Direct rules behavior is different depending on the value of FirewallBackend. See CAVEATS in firewalld.direct(5).

Not reading carefully one would think with FirewallBackend=nftables that it would behave differently by accepting nftables rules, but that's not the case:

# firewall-cmd --version
0.8.0

# firewall-cmd --direct --add-rule inet firewalld filter_FWDI_internal_allow 0 'udp dport 69 ct helper set "helper-tftp-udp" ct state new accept'
Error: INVALID_IPV: invalid argument: inet (choose from 'ipv4', 'ipv6', 'eb')

No need to test much more, this "feature" is documented there:

https://bugzilla.redhat.com/show_bug.cgi?id=1692964

and there:

https://github.com/firewalld/firewalld/issues/555

Direct rules still use iptables with the nftables backend. The CAVEAT is about the order of rules evaluation.

Handle this in an other table

I don't see the point anymore of doing this with firewall-cmd, which will add iptables rules along nftables rules. It just becomes cleaner to add an independent table. It'll just be in the ip family since filters for the specific IPv4 networks will also be added (inet would also be fine).

handletftp.nft (to be loaded with nft -f handletftp.nft):

table ip handletftp
delete table ip handletftp

table ip handletftp {
    ct helper helper-tftp {
        type "tftp" protocol udp
    }

    chain sethelper {
        type filter hook forward priority 0; policy accept;
        ip saddr 192.168.1.0/24 ip daddr 10.0.10.10 udp dport 69 ct helper set "helper-tftp"
    }
}

As the table is different and the ruleset is never flushed, but instead the specific table is (atomically) deleted and recreated, this doesn't affect firewalld nor firewalld will affect it.

The priority doesn't matter much: that this chain is traversed before or after firewalld's chains won't change the fate of the packet (still in the hands of firewalld). Whatever the order, if the packet is accepted by firewalld it will also have activated the helper for this flow.

If you choose to use the nftables service to load this table, you'll have to edit it (eg: systemctl edit --full nftables), because beside loading some probably inadequate default rules, it will flush all rules on stop or reload, disrupting firewalld.

Now, a TFTP transfer will work and activate the specific helper, as can be checked by running two conntrack commands during the transfer:

# conntrack -E & conntrack -E expect
[1] 3635
    [NEW] 300 proto=17 src=10.0.10.10 dst=10.0.10.11 sport=0 dport=56597 mask-src=255.255.255.255 mask-dst=255.255.255.255 sport=0 dport=65535 master-src=192.168.1.2 master-dst=10.0.10.10 sport=56597 dport=69 class=0 helper=tftp
    [NEW] udp      17 29 src=192.168.1.2 dst=10.0.10.10 sport=56597 dport=69 [UNREPLIED] src=10.0.10.10 dst=10.0.10.11 sport=69 dport=56597 helper=tftp
[DESTROY] 299 proto=17 src=10.0.10.10 dst=10.0.10.11 sport=0 dport=56597 mask-src=255.255.255.255 mask-dst=255.255.255.255 sport=0 dport=65535 master-src=192.168.1.2 master-dst=10.0.10.10 sport=56597 dport=69 class=0 helper=tftp
    [NEW] udp      17 30 src=10.0.10.10 dst=10.0.10.11 sport=42032 dport=56597 [UNREPLIED] src=192.168.1.2 dst=10.0.10.10 sport=56597 dport=42032
 [UPDATE] udp      17 30 src=10.0.10.10 dst=10.0.10.11 sport=42032 dport=56597 src=192.168.1.2 dst=10.0.10.10 sport=56597 dport=42032

The 3rd NEW entry in the example above is actually tagged as RELATED (that's the whole role of the tftp helper: expect a certain type of packet to get it seen as related) which will be accepted by the firewall.