Two engineers given the same OpenBSD gateway will often hand you two different rule sets for reaching a remote network, and both can be defended. The choice between source translation and a return route is not a syntax question at all. It comes down to what the far side already knows about 192.168.88.0/24 and what you need to keep in your logs. Get that wrong and the nat-to line will look perfectly correct while replies vanish into the remote default gateway.

Routing and rewriting answer different questions

The routing table decides that 172.20.0.0/16 is reachable over tun0, and that decision happens before any pf translation is consulted. Source translation then changes the packet header so the remote end sees the gateway's tunnel address instead of a LAN host. Neither mechanism substitutes for the other: a nat rule cannot conjure a route, and a route cannot persuade a remote router that has never heard of your private subnet. When the remote side lacks a return path, replies are dropped or forwarded to its default gateway, and no amount of rule tuning fixes that.

Put plainly, forwarding answers where traffic goes and translation answers who it appears to come from. Keep them in separate mental boxes while you design the rule set.

Three conditions that decide it

Ask three questions in order and the design usually picks itself. Does the remote network have a static route pointing at the tunnel address for 192.168.88.0/24, or does its administrator control the routers on that path? If yes, let the LAN addresses travel unmodified and skip translation entirely. Do you need per-client visibility on the far side, either for access policy or for an audit trail? If yes, preserving the source is worth the routing work. Is the tunnel address stable enough to write into a remote route, or does it move between reconnects?

If any answer is no or unknown, nat-to is the pragmatic option, and it is also the reversible one. A translation rule comes out in a single line once the remote side adds a proper route. A return route that was never installed costs you a support thread every time someone asks why the tunnel only works in one direction.

Placing the fragment so pf actually evaluates it

The fragment below assumes the LAN is 192.168.88.0/24, the remote network is 172.20.0.0/16, and the tunnel interface is tun0. Nothing in it chooses a next hop. It only rewrites the source on packets that are already leaving through the tunnel.

match out on tun0 inet from 192.168.88.0/24 to 172.20.0.0/16 nat-to (tun0)
pass in on em0 inet from 192.168.88.0/24 to any keep state

Two details in that first line carry most of the weight. Parentheses around tun0 tell pf to read the interface address each time it builds a state entry, which matters when the tunnel gets renumbered after a reconnect. The word match means the rule rewrites and then falls through, so a separate pass rule still has to admit the flow. The interface named in the match line also has to be the one the routing table selects, or pf evaluates the rule on a different outbound path than the packet actually takes.

Do not forget the kernel side. IPv4 forwarding has to be enabled, and the stateful pass rules for tunnel and LAN have to exist before any of this is meaningful. The PF NAT section of the OpenBSD FAQ is the right reference for the keyword ordering here.

Verify the rule set before you trust the traffic

Check syntax with pfctl -nf /etc/pf.conf first, which parses the file without loading it. Then load the policy and read back what the kernel actually holds, because a rule that parsed cleanly can still be shadowed or attached to the wrong interface. pfctl -sn prints translation rules in their loaded form, and pfctl -sr prints filter rules in evaluation order.

Watch the wire next. On the gateway, tcpdump -ni tun0 'host 172.20.0.10' shows whether the source address leaving the tunnel is the tunnel address or the LAN host. A quick netstat -rn -f inet confirms which interface the destination resolves to. Once traffic flows, pfctl -ss lists state entries; if a flow produced no state, the packet never matched a rule that creates one.

If the tunnel side looks right and replies still never arrive, stop editing pf. The remaining failure sits on the remote router, where the reply is being sent toward a private address that side cannot reach.

When translation costs more than it saves

Translation hides clients, and sometimes that is exactly the wrong trade. Overlapping subnets are one case: if the remote side also uses 192.168.88.0/24 internally, presenting LAN hosts as the gateway makes the collision worse instead of better. IPsec policies that select traffic by address pair are another, because the security policy database is matched before translation and never sees your rewritten packet. Any deployment that bills, throttles, or audits per user also loses the identity it needs the moment the source collapses to one address.

Portable pf derivatives are a separate trap. Products that generate their own rule files expect NAT to be configured through their interface, and hand-edited fragments get overwritten on the next reload. Rule sets copied from guides written before the match keyword existed will not load on a current kernel, so port the syntax and read the error rather than guessing.

The decision rule that survives contact with real networks is short: install a return route when you can, translate when you cannot, and write down which of the three conditions made you choose. That note saves the next person an afternoon.

Artaigilean co-cheangailte