PF Perform rdr + nat together?

Reading the pf.conf man page, and comparing a bit with OpenBSD handbook's pf description, it seems to me that:

- FreeBSD either applies the rdr or the nat rule, but not both - from manpage:

Evaluation order of the translation rules is dependent on the type of the
translation rules and of the direction of a packet. binat rules are
always evaluated first. Then either the rdr rules are evaluated on an
inbound packet or the nat rules on an outbound packet. Rules of the same
type are evaluated in the same order in which they appear in the ruleset.
The first matching rule decides what action is taken.

- OpenBSD has a mechanism to apply both together (to remap both the destination address and the source address)

RDR-TO and NAT-TO Combination​

With an additional NAT rule on the internal interface, the lacking source address translation described above can be achieved.

Is there such a mechanism for FreeBSD pf?

Context: I have a (non-VNET) jail on a loopback interface (bastille0), and would like it to talk to a service on the host's lo0 interface (bound to 127.0.0.1). As I gather from https://github.com/curl/curl/issues/7160
  • the kernel intercepts the lookup and returns the jails own IP instead of 127.0.0.1
so accessing that bound service is not directly possible from the jail (as observation confirms). So what I do, I access a random IP $foo from the jail, and use rdr to direct $foo target to 127.0.0.1. Based on tcpdump, this seems to work, the only problem is that the bound service is (understandably) picky about connections, and forbids non-localhost sources. So, this is why I tried to NAT the request to a host-localhost IP too.

Postscript to context: of course this is silly, rather I should move that service into a jail or otherwise reconfigure it. But I'm interested if this can be done this way for pf experience.

 
Is there such a mechanism for FreeBSD pf?
A couple of features have been added to PF for FreeBSD 15.0 you might be interested in.

pf(4) now supports the OpenBSD style NAT syntax. It is possible to use "nat-to", "rdr-to" and "binat-to" on "pass" and "match" rules. The old "nat on …" syntax can still be used. e0fe26691fc9 (Sponsored by InnoGames GmbH)
 
This works perfectly fine:

Code:
nat on $ext_if from !self  to any -> $ext_if:0
rdr pass on $ext_if inet proto tcp from any to $ext_if port 61122 -> 172.16.110.22 port 22
rdr pass on $ext_if inet proto tcp from any to $ext_if port 9091 -> 192.168.2.1

Neither 172.16.110.22 nor 192.168.2.1 are on $ext_if.
 
the openbsd mechanism applies both by using separate rules and a trick that, in the F5 universe, would be called "SNAT Automap" (i don't think there's a generic term for this, but it means applying the source IP of the translating machine during translation, so return traffic comes back to the translator)
 
A couple of features have been added to PF for FreeBSD 15.0 you might be interested in.
Thank you, will look into it eventually! On 14.3 still. Very promising that the syntax gap is closing.

This works perfectly fine:

nat on $ext_if from !self to any -> $ext_if:0
rdr pass on $ext_if inet proto tcp from any to $ext_if port 61122 -> 172.16.110.22 port 22
rdr pass on $ext_if inet proto tcp from any to $ext_if port 9091 -> 192.168.2.1
Yours is a bit different setup, since a package either comes from your backends out through your $ext_if, and then you NAT them, or there's incoming packet through $ext_if and then you can redirect them to the respective backends.

To highlight the difference, in my setup, there are two interfaces, and I need to get a packet from one interface to the other, while simultaneously redirecting the target IP and SNAT-ing the source IP.

After quite some experimenting, I pieced together a solution that works (and it was truly for the sake of learning, as we'll find out in the end, that all was mostly in vain). Documenting for posterity:

Initial setup
Code:
$jail: ip of jail
$foo: a random ip we use instead of the host 127.0.0.1, which is not accessible from the jail directly (kernel rewrites it to $jail)

interface lo group:
    lo0 (127.0.0.1/8)
    bastille0 ($jail/32, $foo/32), clone of lo0

Keeping an eye on things:
Code:
sudo service pflog onestart
sudo tcpdump -n -e -ttt -i pflog

For direct TCP-dumping of bastille0 (a lo0-clone), note reddy's comment in https://forums.freebsd.org/threads/tcpdump-on-loopback-devices.38098/post-416870
I have found that while tcpdump won't indeed show any traffic for cloned loopback interfaces, the traffic of all the loopback interfaces (included the cloned ones) can be inspected by running tcpdump on lo0 (instead of trying to run it on lo1, lo2 etc...). You can apply filters in case you need to reduce the traffic being captured.

Solution pf.conf (fragment, hat tip to shurik and Andriy on the route-to example)
Code:
    # Example with RDR + NAT. Needs the follow-up route-to pass.
    # For a destination $foo:$theport accessed from bastille0, rewrites it to destination 127.0.0.1:$theport,
    # and SNAT-s it to come from 127.0.0.1:someport as well. But generally, this is an example
    # on how to map the destination and source in a somewhat arbitrary way.

    nat log on bastille0 inet proto tcp from $jail to any port = $theport -> 127.0.0.1
    rdr log on lo0 inet proto tcp from any to $foo port = $theport -> 127.0.0.1

    # From https://forums.freebsd.org/threads/route-to-example.94668/ . Apparently doesn't work as a single pass rule (syntax error).
    pass out log on bastille0 inet proto tcp from any to any port = $theport tag MAGIC ridentifier 444
    pass out log quick route-to lo0 tagged MAGIC ridentifier 455

This produces the following pflog:
Code:
nat out on bastille0
[ridentifier 455]: pass out on bastille0: 127.0.0.1... ->
rdr in on lo0: 127.0.0.1... -> ...
[ridentifier 101]: pass in on lo0: 127.0.0.1.... > 127.0.0.1....

Testing
Code:
bastille cmd thejail curl -ik --connect-to ::$foo:$theport https://127.0.0.1:$theport
#or just
curl --interface $jail_ip -ik --connect-to ::$foo:$theport https://127.0.0.1:$theport

The connect-to curl magic is only needed, since the service we want to reach checks the HTTP Host, and is not happy if we don't call it via 127.0.0.1. So we tell curl to use the latter value in the header/TLS SNI, but instruct it to in fact connect through our made-up $foo IP (which we have taken care pf-ing around).

Fun fact
I mentioned I could access 127.0.0.1 with a less involved pf setup (just a rdr, not nat), but I didn't stop there since I thought the service didn't like the fact that our source IP was non-localhost. But I was wrong there, it just barked on this Host header (because I was not using the connect-to mechanism before).

But at least this gave opportunity to figure how to do RDR + NAT across two interfaces at once, using the FreeBSD pf syntax.

Learning (and bonus exercises for the curious)
Let's take a step back and summarize something (in retrospect maybe trivial, but anyway) I learned how the pass/nat/rdr works in pf:
  • When delivering between two interfaces, the order of rules is (as visible in the pflog above):
    • on source interface: nat out; pass out
    • on target interface: rdr in; pass in
  • The packet is routed after leaving the source interface:
    • Normally routing would happen using the system route table (I assume): see netstat -nr
    • But you can use the route-to construct to specify an explicit interface
      • exercise: what if we added the $foo IP as an alias to lo0 instead of bastille0? Could we have relied on the system route table then?
  • If the source and target interface is the same, then you only get either rdr or nat, but not both.
    • exercise: which one?
Extra exercise inspired by DutchDaemon's setup: what if you wanted to RDR the incoming requests towards your backends, but at the same time also SNAT their source IPs, so the backends couldn't observe the external IPs. If you had two interfaces, a setup similar to outlined here could work. But what if you only have one interface, $ext_if?

Happy filtering!
 
Back
Top