1

This is a question specifically about nftables chain types in the Linux kernel.

I don't understand how they're processed. I've been staring at the kernel code for a while, and it looks to me like an nftables "chain" is attached to a netns as a hook entry (in e.g. struct netns_nf.hooks_ipv4 for IPv4).

I don't see anything that discriminates on the "type" of the chain—filter, nat, or route—while creating or processing the chain. It looks like all chain types would simply get stuffed in as hook entries, and only the struct nf_hook_entry.hook function would be type-specific. For example, I think nf_hook_entry.hook would be the function nft_nat_do_chain for a type nat chain.

Looking at this table of which combinations of family, hook, and type exist, let's say I added two chains to the input hook, one with type filter and one with type nat. Let's further say that both chains are created with the same priority.

Questions:

  1. Is my hypothetical scenario even possible, two chains on the same hook, only varying by type? If not, where does the kernel prevent this?
  2. If it is possible, what will determine the order that these two chains run in? Is there something I'm missing that runs e.g. chains of type nat before chains of type filter? Or will it be down to whichever chain was added first vs. second (and maybe kernel version, etc.)?

There is an excellent related answer that's about chains with the same priority, but the specific case there is with two chains of the same type.

I am asking this question with the ultimate intent of understanding why nftables has a concept of "type" at all.

I know, for example, that the handler for type route chains may call ip_route_me_harder (not a joke!) if certain fields of a packet are changed by a chain, and this is unique to chains of type route. I know type nat has a few restrictions on its priority. I have also read that type nat chains are only called for the first packet of a connection, but I haven't been able to locate that exact restriction anywhere in the code (though maybe it's nf_nat_inet_fn in nf_nat_core.c?).

I appreciate any pointers you can give me to help me understand how and where type is handled for nftables chains in the kernel!

Edit: This answer seems to suggest that nftables "types" are nearly a stylistic choice, though it does point out the special behaviors of the route type. Another answer there further muddies my waters by saying that a NAT rule cannot be added to a chain of type filter, which (if true) is very confusing to me. Where is such a restriction implemented? (Only in userspace?)

0

1 Answer 1

1

TL;DR

When doing an experiment where a network namespace receives traffic and does NAT on it, one can see that whatever the priority given to the type nat hook prerouting chain, it doesn't matter with regard to the filter chains priorities: NAT always happen at exactly prerouting hook priority -100 aka NF_IP_PRI_NAT_DST. Priority between NAT chains themselves is preserved.

You looked at the .hook entries in definitions which are for actual actions during packet traversal, but overlooked the .ops_register/.ops_unregister entries defined only for NAT hooks which introduce a different behavior when the chain is registered.

Tests done with kernel 6.5.x and nftables 1.0.9, some links provided on https://elixir.bootlin.com/ with latest LTS kernel at this date without patch revision: 6.1 (not 6.1.x).

To summarize:

  • NAT acts at special hook priorities, and only these priorities (rather than the priority given when adding the chain) are relevant when comparing with other hook types such as filter or route: NAT chains register differently than other chains. Still the given priorities apply internally between different NAT chains hooking at the same place.

  • route follows normal priorities just like filter (no special registration).

  • don't use exact priorities such as NF_IP_PRI_NAT_DST (or various other NAT-related exact values) elsewhere because then the precise interaction between how nftables and NAT hook into Netfilter might be undefined (example: could change depending on order of creation, or behavior could change depending on kernel version) instead of deterministic. For example use -101 or less to be before DNAT or -99 or more to be after DNAT but don't ever use -100 to avoid undefined behavior.

  • the same warning applies for other special facilities' priorities, described for example there, such as NF_IP_PRI_CONNTRACK_DEFRAG or NF_IP_PRI_CONNTRACK etc. (and for iptables priorities when also interacting with iptables rules and needing a deterministic outcome).


Experiment

I left aside cases such as family inet: one can just check it will behave the same with an adequate ruleset and test case.

Example ruleset (to be loaded using nft -f ...):

table t         # for idempotence
delete table t  # for idempotence

table t {
    chain pf1 {
        type filter hook prerouting priority -250; policy accept;

    udp dport 5555 meta nftrace set 1 counter
    }

    chain pf2 {
        type filter hook prerouting priority -101; policy accept;

    udp dport 5555 counter accept
    udp dport 6666 counter accept
    }

    chain pf3 {
        type filter hook prerouting priority -99; policy accept;

    udp dport 5555 counter accept
    udp dport 6666 counter accept
    }

    chain pn1 {
        type nat hook prerouting priority -160; policy accept;

        counter
    }

    chain pn2 {
        type nat hook prerouting priority 180; policy accept;

        udp dport 5555 counter dnat to :6666
    }

    chain pn3 {
        type nat hook prerouting priority -190; policy accept;

        counter
    }

    chain pn4 {
        type nat hook prerouting priority 190; policy accept;

        udp dport 5555 counter dnat to :7777
        udp dport 6666 counter dnat to :7777
    }

}

This ruleset will change a received UDP port 5555 into port 6666 instead in pn2. pn1, pn3 and pn4 are here just for priority between NAT chains (pn4 also here to explain that NAT of a given type (DNAT, SNAT...) happens only once). There's a receiving application on UDP port 6666 (so the flow isn't deleted by an ICMP destination port unreachable), I used socat UDP4-LISTEN:6666,fork EXEC:date for this test and (interactively) sent two packets from a remote client using socat UDP4:192.0.2.2:5555 -.

One would believe that the NAT chain pn2 with priority 180 performing a DNAT would happen after filter chain pf3 with priority -99. But that's not what happens between type nat and other types: NAT is special. Using nft monitor trace like below:

# nft monitor trace
trace id 4ab9ba62 ip t pf1 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pf1 rule udp dport 5555 meta nftrace set 1 counter packets 0 bytes 0 (verdict continue)
trace id 4ab9ba62 ip t pf1 verdict continue
trace id 4ab9ba62 ip t pf1 policy accept
trace id 4ab9ba62 ip t pf2 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pf2 rule udp dport 5555 counter packets 0 bytes 0 accept (verdict accept)
trace id 4ab9ba62 ip t pn3 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pn3 rule counter packets 0 bytes 0 (verdict continue)
trace id 4ab9ba62 ip t pn3 verdict continue
trace id 4ab9ba62 ip t pn3 policy accept
trace id 4ab9ba62 ip t pn1 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pn1 rule counter packets 0 bytes 0 (verdict continue)
trace id 4ab9ba62 ip t pn1 verdict continue
trace id 4ab9ba62 ip t pn1 policy accept
trace id 4ab9ba62 ip t pn2 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pn2 rule udp dport 5555 counter packets 0 bytes 0 dnat to :6666 (verdict accept)
trace id 4ab9ba62 ip t pf3 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49393 ip length 30 udp sport 58201 udp dport 6666 udp length 10 @th,64,16 0x610a
trace id 4ab9ba62 ip t pf3 rule udp dport 6666 counter packets 0 bytes 0 accept (verdict accept)

trace id 46ad0497 ip t pf1 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49394 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x620a
trace id 46ad0497 ip t pf1 rule udp dport 5555 meta nftrace set 1 counter packets 0 bytes 0 (verdict continue)
trace id 46ad0497 ip t pf1 verdict continue
trace id 46ad0497 ip t pf1 policy accept
trace id 46ad0497 ip t pf2 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49394 ip length 30 udp sport 58201 udp dport 5555 udp length 10 @th,64,16 0x620a
trace id 46ad0497 ip t pf2 rule udp dport 5555 counter packets 0 bytes 0 accept (verdict accept)
trace id 46ad0497 ip t pf3 packet: iif "lan0" ether saddr 8e:3e:82:1a:dc:87 ether daddr fa:2f:7e:2d:f1:03 ip saddr 192.0.2.1 ip daddr 192.0.2.2 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 49394 ip length 30 udp sport 58201 udp dport 6666 udp length 10 @th,64,16 0x620a
trace id 46ad0497 ip t pf3 rule udp dport 6666 counter packets 0 bytes 0 accept (verdict accept)
^C

one can see that all prerouting NAT hooks are happening between pf2 and pf3 ie between priorities -101 and -99: at priority -100 which is NF_IP_PRI_NAT_DST as used in Netfilter's own structures static const struct nf_hook_ops nf_nat_ipv4_ops[]. Chain ip t pf3 sees port 6666 and not 5555.

If a NAT statement has been applied, following rules (in the same hook) are skipped by Netfilter so pn4 never gets a chance here to be traversed at all in the example above (with only 2 packets of the same flow initially to port 5555) and never appears: this behavior also differs from type filter where the next hook is still traversed (eg: pf3 is still traversed after pf2).

As usual, the next packet in the flow doesn't trigger any NAT chain anymore since only packet creating a new flow (conntrack state NEW) are sent to NAT chains, so the next packet doesn't even display traversing pnX chains anymore. Priorities between the four prerouting NAT chains are honored: priority order is pn3 (-190) , pn1 (-160), pn2 (180) (and then there would be pn4 (190) but it doesn't get the chance).

Note: the fact that the packets/bytes counters don't appear increased in the same run of nft monitor trace looks like a bug or a missing feature to me (they are incremented when checking nft list ruleset).

type nat hooks use a different registering function than default for other nftables hooks so they can be handled differently:

.ops_register = nf_nat_ipv4_register_fn,
.ops_unregister = nf_nat_ipv4_unregister_fn,

It's to be handled by NAT (which is managed by Netfilter) and in hook NF_INET_PRE_ROUTING (still provided by Netfilter to nftables) this will be done at priority NF_IP_PRI_NAT_DST.

This is not done for type filter (nor route) which will then use a common nftables method rather than the specified one.

2
  • 1
    I was hoping you'd answer this question :) Thank you for this comprehensive answer. You're right that I missed ops_register. Reading through that code for a while, I think what I see is that registering a type nat chain actually causes nftables to put a standard set of "ops" (e.g. nf_nat_ipv4_ops) into Netfilter with one of the NF_IP_PRI_NAT_* priorities, and the actual rules appear to be stuffed into the private pointer of the ops. That leads nicely into nf_nat_inet_fn...
    – dsedivec
    Commented Nov 26, 2023 at 23:55
  • ...where you can see the "NAT rules are only run once for the start of a connection" bits. Thank you again for this comprehensive answer! Your worked example was especially illuminating.
    – dsedivec
    Commented Nov 26, 2023 at 23:56

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .