nft config to make a local NATed FTP server public available

Solution 1:

TL;DR

# nft add ct helper ip my_nat ftp-incoming '{ type "ftp" protocol tcp; }'
# nft add chain ip my_nat my_helpers '{ type filter hook prerouting priority 10; }'
# nft add rule ip my_nat my_helpers iif eth0 ip daddr 192.168.1.2 tcp dport 21 ct helper set ftp-incoming
# modprobe nf_nat_ftp

with more details below...

Protocol helper modules for problematic protocols

FTP is an old protocol and is not very firewall-friendly: commands on the FTP command channel (21/TCP) negotiate an ephemeral port to use for the next transfer command. Because of this a stateful firewall has to snoop those commands and replies to temporarily pre-allow the adequate port about to be used.

On Linux this is provided by protocol-specific helper modules which are plugins to conntrack, the Netfilter subsystem tracking connections for NAT and stateful firewalling. With FTP, when a port negotiation (mostly PORT, EPRT, PASV or EPSV) for the next transfer has been seen on the FTP command port the helper adds a short-lived entry in a special conntrack table (the conntrack expectation table) which will wait the next related and expected data connection.

My answer uses the modern "secure" handling as described in this blog about iptables for generalities and in the nftables wiki and man nft for the handling in nftables which differs from iptables.

Secure use of helper and nftables rules

Linux kernel 4.7+ (so including 4.19) is using a secure approach by default: having the (here FTP) helper module loaded won't make it anymore snoop all packets having a TCP source or destination port 21, until specific nftables (or iptables) statements tell it in what (restricted) case it should snoop. This avoids unnecessary CPU usage and allows to change at any time the FTP port(s) to snoop just by changing a few rules (or sets).

The first part is to declare the flows that can trigger the snooping. It's handled differently in nftables than in iptables. Here it's declared using a ct helper stateful object, and filters to activate it must be done after conntrack (while iptables requires actions done before). man nft tells:

Unlike iptables, helper assignment needs to be performed after the conntrack lookup has completed, for example with the default 0 hook priority.

nft add ct helper ip my_nat ftp-incoming '{ type "ftp" protocol tcp; }'

nft add chain ip my_nat my_helpers '{ type filter hook prerouting priority 10; }'
nft add rule ip my_nat my_helpers iif eth0 ip daddr 192.168.1.2 tcp dport 21 ct helper set ftp-incoming

I chose the same table, but this could have been created in an other table, as long as the stateful object declaration and the rule referencing it are in the same table.

One can choose less restrictive rules of course. Replacing the last rule by this following rule would have the same effect as the legacy mode:

nft add rule ip my_nat my_helpers tcp dport 21 ct helper set ftp-incoming

For reference only, legacy mode, that shouldn't be used anymore, doesn't require the rules above but just this toggle (and the manual loading of the relevant kernel module):

sysctl -w net.netfilter.nf_conntrack_helper=1

Ensuring nf_nat_ftp is loaded

The kernel module nf_conntrack_ftp is automatically loaded with the dependency created by ct helper ... type "ftp". That's not the case for nf_nat_ftp, yet it is needed to also enable packet mangling in the command port when NAT is done on the data flow ports.

For example to have the module nf_nat_ftp pulled whenever nf_conntrack_ftp is loaded, the file /etc/modprobe.d/local-nat-ftp.conf could be added with this content:

install nf_conntrack_ftp /sbin/modprobe --ignore-install nf_conntrack_ftp; /sbin/modprobe --ignore-install nf_nat_ftp

or instead, simply add for example /etc/modules-load.d/local-nat-ftp.conf with:

nf_nat_ftp

Anyway right now this command should be done to ensure it's loaded:

modprobe nf_nat_ftp

About firewalling

Here's an additional note for firewalling. There can also be firewalling rules with some restrictions instead of allowing any new flow tagged as related by conntrack.

For example, although the FTP helper module handles both passive and active modes, if for some reason one wants to allow only passive mode (with data connection from client to server) and not "active" ftp (data connection from server source port 20 to client) one could use for example these rules in the firewall part of the ruleset, instead of the usual ct state established,related accept:

ct state established accept
ct state related ct helper "ftp" iif eth0 oif eth1 tcp sport 1024-65535 accept
ct state related ct helper "ftp" drop
ct state related accept 

Other kind of related flows not related to FTP are kept accepted (or could be further split separately)


Example of handling by the helper

Here (in a simulated environment) are two conntrack lists of events measured on the expectation table and the conntrack table with OP's rules + the additional rules above with an Internet client 203.0.113.101 doing FTP in passive mode with the router's public IP address 192.0.2.2 and using a LIST command after having logged in:

# conntrack -E expect
    [NEW] 300 proto=6 src=203.0.113.101 dst=192.0.2.2 sport=0 dport=37157 mask-src=0.0.0.0 mask-dst=0.0.0.0 sport=0 dport=65535 master-src=203.0.113.101 master-dst=192.0.2.2 sport=50774 dport=21 class=0 helper=ftp
[DESTROY] 300 proto=6 src=203.0.113.101 dst=192.0.2.2 sport=0 dport=37157 mask-src=0.0.0.0 mask-dst=0.0.0.0 sport=0 dport=65535 master-src=203.0.113.101 master-dst=192.0.2.2 sport=50774 dport=21 class=0 helper=ftp

simultaneously:

# conntrack -E
    [NEW] tcp      6 120 SYN_SENT src=203.0.113.101 dst=192.0.2.2 sport=50774 dport=21 [UNREPLIED] src=192.168.1.2 dst=192.168.1.1 sport=21 dport=50774 helper=ftp
 [UPDATE] tcp      6 60 SYN_RECV src=203.0.113.101 dst=192.0.2.2 sport=50774 dport=21 src=192.168.1.2 dst=192.168.1.1 sport=21 dport=50774 helper=ftp
 [UPDATE] tcp      6 432000 ESTABLISHED src=203.0.113.101 dst=192.0.2.2 sport=50774 dport=21 src=192.168.1.2 dst=192.168.1.1 sport=21 dport=50774 [ASSURED] helper=ftp
    [NEW] tcp      6 120 SYN_SENT src=203.0.113.101 dst=192.0.2.2 sport=55835 dport=37157 [UNREPLIED] src=192.168.1.2 dst=192.168.1.1 sport=37157 dport=55835
 [UPDATE] tcp      6 60 SYN_RECV src=203.0.113.101 dst=192.0.2.2 sport=55835 dport=37157 src=192.168.1.2 dst=192.168.1.1 sport=37157 dport=55835
 [UPDATE] tcp      6 432000 ESTABLISHED src=203.0.113.101 dst=192.0.2.2 sport=55835 dport=37157 src=192.168.1.2 dst=192.168.1.1 sport=37157 dport=55835 [ASSURED]
 [UPDATE] tcp      6 120 FIN_WAIT src=203.0.113.101 dst=192.0.2.2 sport=55835 dport=37157 src=192.168.1.2 dst=192.168.1.1 sport=37157 dport=55835 [ASSURED]
 [UPDATE] tcp      6 30 LAST_ACK src=203.0.113.101 dst=192.0.2.2 sport=55835 dport=37157 src=192.168.1.2 dst=192.168.1.1 sport=37157 dport=55835 [ASSURED]
 [UPDATE] tcp      6 120 TIME_WAIT src=203.0.113.101 dst=192.0.2.2 sport=55835 dport=37157 src=192.168.1.2 dst=192.168.1.1 sport=37157 dport=55835 [ASSURED]

The expectation's start proto=6 src=203.0.113.101 dst=192.0.2.2 sport=0 dport=37157 tells that the next TCP connection from 203.0.113.101:* to 192.0.2.2:37157 will be associated (state related) with the FTP connection. Not visible directly, but since the NAT FTP helper module is also loaded, the actual data sent by the server in response to the PASV/EPSV command was intercepted and translated so the client connects to 192.0.2.2 instead of 192.168.1.2 which would have of course failed on the client.

Despite the second flow (second NEW line) having no explicit rule in my_prerouting, it was succesfully DNATed to the server behind the router.


Notes

  • if the FTP control port is encrypted (AUTH TLS ...), then the helper module can't snoop the negotiated port anymore and this can't work. One have to resort to configuring a reserved range of ports both on the FTP server configuration and the firewall/NAT router, and configure the server so it sends the correct (public) IP address when negotiating instead of its own. And active FTP mode can't be used if the server has no route to Internet (as appears to be the case here).

  • nitpicking: the prerouting priority of 10 ensures that even for kernels < 4.18, NAT already happened for new flows (OP chose hook prerouting priority 0 instead of the usual -100 as this rarely matters in nftables), so the use of daddr 192.168.1.2 is possible. If the priority was 0 (or lower than 0), it's perhaps possible (not verified) the rule would see the first packet still unNATed with a public IP destination address, but would catch the following packets of the same flow, since they are handled directly by conntrack at priority -200. Better stay safe and use 10. Actually this is not relevant since kernel 4.18 (see commit reference in this pending patch) where NAT priority is only relevant for comparing between multiple nat chains (and allows mixing NAT in iptables legacy along nftables).