git: e0fe26691fc9 - main - pf: Add modern NAT syntax

From: Kajetan Staszkiewicz <ks_at_FreeBSD.org>
Date: Mon, 28 Apr 2025 20:07:50 UTC
The branch main has been updated by ks:

URL: https://cgit.FreeBSD.org/src/commit/?id=e0fe26691fc98a16cdda9d4f4beea9c5698ac64a

commit e0fe26691fc98a16cdda9d4f4beea9c5698ac64a
Author:     Kajetan Staszkiewicz <ks@FreeBSD.org>
AuthorDate: 2025-03-03 16:57:52 +0000
Commit:     Kajetan Staszkiewicz <ks@FreeBSD.org>
CommitDate: 2025-04-28 20:06:08 +0000

    pf: Add modern NAT syntax
    
    Now that pfctl has separate functions for parsing redirection pools and
    ports, we can finally add support for nat-to and rdr-to filter_opts.
    NAT and RDR actions are marked by having the respective pools filled in.
    
    Function pf_rule_apply_nat() is responsible for both NAT/RDR and af-to
    address translations. It is called both for match rules and the final
    pass rule.
    
    Use FreeBSD's original address translation code by splitting it into
    pf_translate_compat(). Call this function for old-style NAT ruleset
    and for modern NAT rules via pf_rule_apply_nat().
    
    Initialize pfctl_rule's redirection pools on rule allocation, also for
    code paths not using expand_rule(), so that they can be safely checked
    for being empty in filter_consistent().
    
    Move map-e NAT test to nat.sh for convenience, duplicate critical NAT
    tests into _compat (for old-style NAT ruleset) and _pass (for match/
    pass) variants.
    
    Reviewed by:            kp
    Approved by:            kp (mentor)
    Sponsored by:           InnoGames GmbH
    Differential Revision:  https://reviews.freebsd.org/D49221
---
 sbin/pfctl/parse.y                | 292 +++++++++++++++++++----
 sbin/pfctl/pfctl.c                |   1 -
 sbin/pfctl/pfctl_parser.c         |  35 +--
 sbin/pfctl/tests/files/pf0016.in  |   6 +-
 sbin/pfctl/tests/files/pf0016.ok  |   4 +
 sbin/pfctl/tests/files/pf0018.in  |  18 +-
 sbin/pfctl/tests/files/pf0018.ok  |  19 ++
 sbin/pfctl/tests/files/pf0019.in  |   4 +-
 sbin/pfctl/tests/files/pf0019.ok  |   9 +
 sbin/pfctl/tests/files/pf0020.in  |   4 +-
 sbin/pfctl/tests/files/pf0020.ok  |  12 +
 sbin/pfctl/tests/files/pf0048.in  |  12 +-
 sbin/pfctl/tests/files/pf0048.ok  |   8 +
 sbin/pfctl/tests/files/pf0069.in  |   3 +-
 sbin/pfctl/tests/files/pf0069.ok  |   1 +
 sbin/pfctl/tests/files/pf0070.in  |   3 +-
 sbin/pfctl/tests/files/pf0070.ok  |   1 +
 sbin/pfctl/tests/files/pf0071.in  |   3 +-
 sbin/pfctl/tests/files/pf0071.ok  |   1 +
 sbin/pfctl/tests/files/pf0072.in  |   3 +-
 sbin/pfctl/tests/files/pf0072.ok  |   2 +
 sbin/pfctl/tests/files/pf0084.in  |  12 +-
 sbin/pfctl/tests/files/pf0084.ok  |   3 +
 sbin/pfctl/tests/files/pf0098.in  |   3 +-
 sbin/pfctl/tests/files/pf0098.ok  |   1 +
 share/man/man5/pf.conf.5          | 285 +++++++++++++----------
 sys/net/pfvar.h                   |   7 +
 sys/netpfil/pf/pf.c               | 473 ++++++++++++++++++++++----------------
 sys/netpfil/pf/pf_ioctl.c         |  32 ++-
 sys/netpfil/pf/pf_lb.c            | 116 ++++++----
 tests/sys/netpfil/pf/Makefile     |   1 -
 tests/sys/netpfil/pf/map_e.sh     |  90 --------
 tests/sys/netpfil/pf/nat.sh       | 374 ++++++++++++++++++++++++++++--
 tests/sys/netpfil/pf/rdr.sh       | 109 +++++++--
 tests/sys/netpfil/pf/src_track.sh | 110 ++++++++-
 35 files changed, 1451 insertions(+), 606 deletions(-)

diff --git a/sbin/pfctl/parse.y b/sbin/pfctl/parse.y
index 804d80b04152..f1ed5444cadd 100644
--- a/sbin/pfctl/parse.y
+++ b/sbin/pfctl/parse.y
@@ -245,6 +245,7 @@ struct redirspec {
 	struct range		 rport;
 	struct pool_opts	 pool_opts;
 	int			 af;
+	bool			 binat;
 };
 
 static struct filter_opts {
@@ -381,7 +382,11 @@ void		 expand_eth_rule(struct pfctl_eth_rule *,
 int		 apply_rdr_ports(struct pfctl_rule *r, struct pfctl_pool *, struct redirspec *);
 int		 apply_nat_ports(struct pfctl_pool *, struct redirspec *);
 int		 apply_redirspec(struct pfctl_pool *, struct redirspec *);
-void		 expand_rule(struct pfctl_rule *, struct node_if *,
+int		 check_binat_redirspec(struct node_host *, struct pfctl_rule *, int);
+void		 add_binat_rdr_rule(struct pfctl_rule *, struct redirspec *,
+		 struct node_host *, struct pfctl_rule *, struct redirspec **,
+		 struct node_host **);
+void		 expand_rule(struct pfctl_rule *, bool, struct node_if *,
 		    struct redirspec *, struct redirspec *, struct redirspec *,
 		    struct node_proto *, struct node_os *, struct node_host *,
 		    struct node_port *, struct node_host *, struct node_port *,
@@ -525,7 +530,8 @@ int	parseport(char *, struct range *r, int);
 %token	STICKYADDRESS ENDPI MAXSRCSTATES MAXSRCNODES SOURCETRACK GLOBAL RULE
 %token	MAXSRCCONN MAXSRCCONNRATE OVERLOAD FLUSH SLOPPY PFLOW ALLOW_RELATED
 %token	TAGGED TAG IFBOUND FLOATING STATEPOLICY STATEDEFAULTS ROUTE SETTOS
-%token	DIVERTTO DIVERTREPLY BRIDGE_TO RECEIVEDON NE LE GE AFTO
+%token	DIVERTTO DIVERTREPLY BRIDGE_TO RECEIVEDON NE LE GE AFTO NATTO RDRTO
+%token	BINATTO
 %token	<v.string>		STRING
 %token	<v.number>		NUMBER
 %token	<v.i>			PORTBINARY
@@ -1080,7 +1086,7 @@ anchorrule	: ANCHOR anchorname dir quick interface af proto fromto
 			decide_address_family($8.src.host, &r.af);
 			decide_address_family($8.dst.host, &r.af);
 
-			expand_rule(&r, $5, NULL, NULL, NULL,
+			expand_rule(&r, false, $5, NULL, NULL, NULL,
 			    $7, $8.src_os, $8.src.host, $8.src.port, $8.dst.host,
 			    $8.dst.port, $9.uid, $9.gid, $9.rcv, $9.icmpspec,
 			    pf->astack[pf->asd + 1] ? pf->alast->name : $2);
@@ -1104,7 +1110,7 @@ anchorrule	: ANCHOR anchorname dir quick interface af proto fromto
 			decide_address_family($6.src.host, &r.af);
 			decide_address_family($6.dst.host, &r.af);
 
-			expand_rule(&r, $3, NULL, NULL, NULL,
+			expand_rule(&r, false, $3, NULL, NULL, NULL,
 			    $5, $6.src_os, $6.src.host, $6.src.port, $6.dst.host,
 			    $6.dst.port, 0, 0, 0, 0, $2);
 			free($2);
@@ -1147,7 +1153,7 @@ anchorrule	: ANCHOR anchorname dir quick interface af proto fromto
 				r.dst.port_op = $6.dst.port->op;
 			}
 
-			expand_rule(&r, $3, NULL, NULL, NULL,
+			expand_rule(&r, false, $3, NULL, NULL, NULL,
 			    $5, $6.src_os, $6.src.host, $6.src.port, $6.dst.host,
 			    $6.dst.port, 0, 0, 0, 0, $2);
 			free($2);
@@ -1471,7 +1477,7 @@ scrubrule	: scrubaction dir logquick interface af proto fromto scrub_opts
 			r.match_tag_not = $8.match_tag_not;
 			r.rtableid = $8.rtableid;
 
-			expand_rule(&r, $4, NULL, NULL, NULL,
+			expand_rule(&r, false, $4, NULL, NULL, NULL,
 			    $6, $7.src_os, $7.src.host, $7.src.port, $7.dst.host,
 			    $7.dst.port, NULL, NULL, NULL, NULL, "");
 		}
@@ -1636,9 +1642,9 @@ antispoof	: ANTISPOOF logquick antispoof_ifspc af antispoof_opts {
 				}
 
 				if (h != NULL)
-					expand_rule(&r, j, NULL, NULL, NULL,
-					    NULL, NULL, h, NULL, NULL, NULL,
-					    NULL, NULL, NULL, NULL, "");
+					expand_rule(&r, false, j, NULL, NULL,
+					    NULL, NULL, NULL, h, NULL, NULL,
+					    NULL, NULL, NULL, NULL, NULL, "");
 
 				if ((i->ifa_flags & IFF_LOOPBACK) == 0) {
 					bzero(&r, sizeof(r));
@@ -1658,10 +1664,10 @@ antispoof	: ANTISPOOF logquick antispoof_ifspc af antispoof_opts {
 					else
 						h = ifa_lookup(i->ifname, 0);
 					if (h != NULL)
-						expand_rule(&r, NULL, NULL,
-						    NULL, NULL, NULL, NULL, h,
+						expand_rule(&r, false, NULL,
 						    NULL, NULL, NULL, NULL,
-						    NULL, NULL, NULL, "");
+						    NULL, h, NULL, NULL, NULL,
+						    NULL, NULL, NULL, NULL, "");
 				} else
 					free(hh);
 			}
@@ -2797,9 +2803,36 @@ pfrule		: action dir logquick interface route af proto fromto
 
 			if ($9.marker & FOM_AFTO) {
 				r.naf = $9.nat->af;
+			} else {
+				if ($9.nat) {
+					if (!r.af && ! $9.nat->host->ifindex)
+						r.af = $9.nat->host->af;
+					remove_invalid_hosts(&($9.nat->host), &r.af);
+					if (invalid_redirect($9.nat->host, r.af))
+						YYERROR;
+					if ($9.nat->host->addr.type == PF_ADDR_DYNIFTL) {
+						if (($9.nat->host = gen_dynnode($9.nat->host, r.af)) == NULL)
+							err(1, "calloc");
+					}
+					if (check_netmask($9.nat->host, r.af))
+						YYERROR;
+				}
+				if ($9.rdr) {
+					if (!r.af && ! $9.rdr->host->ifindex)
+						r.af = $9.rdr->host->af;
+					remove_invalid_hosts(&($9.rdr->host), &r.af);
+					if (invalid_redirect($9.rdr->host, r.af))
+						YYERROR;
+					if ($9.rdr->host->addr.type == PF_ADDR_DYNIFTL) {
+						if (($9.rdr->host = gen_dynnode($9.rdr->host, r.af)) == NULL)
+							err(1, "calloc");
+					}
+					if (check_netmask($9.rdr->host, r.af))
+						YYERROR;
+				}
 			}
 
-			expand_rule(&r, $4, $9.nat, $9.rdr, $5.redirspec,
+			expand_rule(&r, false, $4, $9.nat, $9.rdr, $5.redirspec,
 			    $7, $8.src_os, $8.src.host, $8.src.port, $8.dst.host,
 			    $8.dst.port, $9.uid, $9.gid, $9.rcv, $9.icmpspec, "");
 		}
@@ -3016,6 +3049,29 @@ filter_opt	: USER uids {
 				filter_opts.marker |= FOM_SCRUB_TCP;
 			filter_opts.marker |= $3.marker;
 		}
+		| NATTO port_redirspec {
+			if (filter_opts.nat) {
+				yyerror("cannot respecify nat-to/binat-to");
+				YYERROR;
+			}
+			filter_opts.nat = $2;
+		}
+		| RDRTO port_redirspec {
+			if (filter_opts.rdr) {
+				yyerror("cannot respecify rdr-to");
+				YYERROR;
+			}
+			filter_opts.rdr = $2;
+		}
+		| BINATTO port_redirspec {
+			if (filter_opts.nat) {
+				yyerror("cannot respecify nat-to/binat-to");
+				YYERROR;
+			}
+			filter_opts.nat = $2;
+			filter_opts.nat->binat = 1;
+			filter_opts.nat->pool_opts.staticport = 1;
+		}
 		| AFTO af FROM port_redirspec {
 			if (filter_opts.nat) {
 				yyerror("cannot respecify af-to");
@@ -4859,7 +4915,7 @@ natrule		: nataction interface af proto fromto tag tagged rtable
 				o = o->next;
 			}
 
-			expand_rule(&r, $2, NULL, $9, NULL, $4,
+			expand_rule(&r, false, $2, NULL, $9, NULL, $4,
 			    $5.src_os, $5.src.host, $5.src.port, $5.dst.host,
 			    $5.dst.port, 0, 0, 0, 0, "");
 		}
@@ -5028,8 +5084,6 @@ binatrule	: no BINAT natpasslog interface af proto FROM ipspec toipspec tag
 					YYERROR;
 				}
 
-				TAILQ_INIT(&binat.rdr.list);
-				TAILQ_INIT(&binat.nat.list);
 				pa = calloc(1, sizeof(struct pf_pooladdr));
 				if (pa == NULL)
 					err(1, "binat: calloc");
@@ -5389,9 +5443,18 @@ filter_consistent(struct pfctl_rule *r, int anchor_call)
 		break;
 	default:;
 	}
-	if (r->rdr.opts & PF_POOL_STICKYADDR && !r->keep_state) {
+	if (!TAILQ_EMPTY(&(r->nat.list)) || !TAILQ_EMPTY(&(r->rdr.list))) {
+		if (r->action != PF_MATCH && !r->keep_state) {
+			yyerror("nat-to and rdr-to require keep state");
+			problems++;
+		}
+		if (r->direction == PF_INOUT) {
+			yyerror("nat-to and rdr-to require a direction");
+			problems++;
+		}
+	}
+	if (r->route.opts & PF_POOL_STICKYADDR && !r->keep_state) {
 		yyerror("'sticky-address' requires 'keep state'");
-		problems++;
 	}
 	return (-problems);
 }
@@ -6135,7 +6198,6 @@ apply_redirspec(struct pfctl_pool *rpool, struct redirspec *rs)
 		memcpy(&(rpool->key), rs->pool_opts.key,
 		    sizeof(struct pf_poolhashkey));
 
-	TAILQ_INIT(&(rpool->list));
 	for (h = rs->host; h != NULL; h = h->next) {
 		pa = calloc(1, sizeof(struct pf_pooladdr));
 		if (pa == NULL)
@@ -6153,8 +6215,115 @@ apply_redirspec(struct pfctl_pool *rpool, struct redirspec *rs)
 	return 0;
 }
 
+int
+check_binat_redirspec(struct node_host *src_host, struct pfctl_rule *r, int af)
+{
+	struct pf_pooladdr	*nat_pool = TAILQ_FIRST(&(r->nat.list));
+	int			error = 0;
+
+	/* XXX: FreeBSD allows syntax like "{ host1 host2 }" for redirection
+	 * pools but does not covert them to tables automatically, because
+	 * syntax "{ (iface1 host1), (iface2 iface2) }" is allowed for route-to
+	 * redirection. Add a FreeBSD-specific guard against using multiple
+	 * hosts for source and redirection.
+	 */
+	if (src_host->next) {
+		yyerror("invalid use of table as the source address "
+		    "of a binat-to rule");
+		error++;
+	}
+	if (TAILQ_NEXT(nat_pool, entries)) {
+		yyerror ("tables cannot be used as the redirect "
+		    "address of a binat-to rule");
+		error++;
+	}
+
+	if (disallow_table(src_host, "invalid use of table "
+	    "<%s> as the source address of a binat-to rule") ||
+	    disallow_alias(src_host, "invalid use of interface "
+	    "(%s) as the source address of a binat-to rule")) {
+		error++;
+	} else if ((r->src.addr.type != PF_ADDR_ADDRMASK &&
+	    r->src.addr.type != PF_ADDR_DYNIFTL) ||
+	    (nat_pool->addr.type != PF_ADDR_ADDRMASK &&
+	    nat_pool->addr.type != PF_ADDR_DYNIFTL)) {
+		yyerror("binat-to requires a specified "
+		    "source and redirect address");
+		error++;
+	}
+	if (DYNIF_MULTIADDR(r->src.addr) ||
+	    DYNIF_MULTIADDR(nat_pool->addr)) {
+		yyerror ("dynamic interfaces must be "
+		    "used with:0 in a binat-to rule");
+		error++;
+	}
+	if (PF_AZERO(&r->src.addr.v.a.mask, af) ||
+	    PF_AZERO(&(nat_pool->addr.v.a.mask), af)) {
+		yyerror ("source and redir addresess must have "
+		    "a matching network mask in binat-rule");
+		error++;
+	}
+	if (nat_pool->addr.type == PF_ADDR_TABLE) {
+		yyerror ("tables cannot be used as the redirect "
+		    "address of a binat-to rule");
+		error++;
+	}
+	if (r->direction != PF_INOUT) {
+		yyerror("binat-to cannot be specified "
+		    "with a direction");
+		error++;
+	}
+
+	/* first specify outbound NAT rule */
+	r->direction = PF_OUT;
+
+	return (error);
+}
+
+void
+add_binat_rdr_rule(
+    struct pfctl_rule *binat_rule,
+    struct redirspec *binat_nat_redirspec, struct node_host *binat_src_host,
+    struct pfctl_rule *rdr_rule, struct redirspec **rdr_redirspec,
+    struct node_host **rdr_dst_host)
+{
+	struct node_host	*rdr_src_host;
+
+	/*
+	 * We're copying the whole rule, but we must re-init redir pools.
+	 * FreeBSD uses lists of pf_pooladdr, we can't just overwrite them.
+	 */
+	bcopy(binat_rule, rdr_rule, sizeof(struct pfctl_rule));
+	TAILQ_INIT(&(rdr_rule->rdr.list));
+	TAILQ_INIT(&(rdr_rule->nat.list));
+
+	/* now specify inbound rdr rule */
+	rdr_rule->direction = PF_IN;
+
+	if ((rdr_src_host = calloc(1, sizeof(*rdr_src_host))) == NULL)
+		err(1, "%s", __func__);
+	bcopy(binat_src_host, rdr_src_host, sizeof(*rdr_src_host));
+	rdr_src_host->ifname = NULL;
+	rdr_src_host->next = NULL;
+	rdr_src_host->tail = NULL;
+
+	if (((*rdr_dst_host) = calloc(1, sizeof(**rdr_dst_host))) == NULL)
+		err(1, "%s", __func__);
+	bcopy(&(binat_nat_redirspec->host->addr), &((*rdr_dst_host)->addr),
+	    sizeof((*rdr_dst_host)->addr));
+	(*rdr_dst_host)->ifname = NULL;
+	(*rdr_dst_host)->next = NULL;
+	(*rdr_dst_host)->tail = NULL;
+
+	if (((*rdr_redirspec) = calloc(1, sizeof(**rdr_redirspec))) == NULL)
+		err(1, "%s", __func__);
+	bcopy(binat_nat_redirspec, (*rdr_redirspec), sizeof(**rdr_redirspec));
+	(*rdr_redirspec)->pool_opts.staticport = 0;
+	(*rdr_redirspec)->host = rdr_src_host;
+}
+
 void
-expand_rule(struct pfctl_rule *r,
+expand_rule(struct pfctl_rule *r, bool keeprule,
     struct node_if *interfaces, struct redirspec *nat,
     struct redirspec *rdr, struct redirspec *route,
     struct node_proto *protos,
@@ -6308,17 +6477,25 @@ expand_rule(struct pfctl_rule *r,
 		}
 
 		if (r->action == PF_RDR) {
+			/* Pre-FreeBSD 15 "rdr" rule */
 			error += apply_rdr_ports(r, &(r->rdr), rdr);
+			error += apply_redirspec(&(r->rdr), rdr);
 		} else if (r->action == PF_NAT) {
+			/* Pre-FreeBSD 15 "nat" rule */
 			error += apply_nat_ports(&(r->rdr), rdr);
-		}
+			error += apply_redirspec(&(r->rdr), rdr);
+		} else {
+			/* Modern rule with optional NAT, BINAT, RDR or ROUTE*/
+			error += apply_redirspec(&(r->route), route);
 
-		error += apply_redirspec(&(r->nat), nat);
-		error += apply_redirspec(&(r->rdr), rdr);
-		error += apply_redirspec(&(r->route), route);
+			error += apply_nat_ports(&(r->nat), nat);
+			error += apply_redirspec(&(r->nat), nat);
+			error += apply_rdr_ports(r, &(r->rdr), rdr);
+			error += apply_redirspec(&(r->rdr), rdr);
 
-		r->nat.proxy_port[0] = PF_NAT_PROXY_PORT_LOW;
-		r->nat.proxy_port[1] = PF_NAT_PROXY_PORT_HIGH;
+			if (nat && nat->binat)
+				error += check_binat_redirspec(src_host, r, af);
+		}
 
 		if (rule_consistent(r, anchor_call[0]) < 0 || error)
 			yyerror("skipping rule due to errors");
@@ -6328,6 +6505,22 @@ expand_rule(struct pfctl_rule *r,
 			added++;
 		}
 
+		/* Generate binat's matching inbound rule */
+		if (!error && nat && nat->binat) {
+			struct pfctl_rule	rdr_rule;
+			struct redirspec	*rdr_redirspec;
+			struct node_host	*rdr_dst_host;
+
+			add_binat_rdr_rule(
+			    r, nat, src_hosts,
+			    &rdr_rule, &rdr_redirspec, &rdr_dst_host);
+
+			expand_rule(&rdr_rule, true, interface, NULL, rdr_redirspec,
+			    NULL, proto, src_os, dst_host, dst_port,
+			    rdr_dst_host, src_port, uid, gid, rcv, icmp_type,
+			    "");
+		}
+
 		if (osrch && src_host->addr.type == PF_ADDR_DYNIFTL) {
 			free(src_host);
 			src_host = osrch;
@@ -6339,27 +6532,29 @@ expand_rule(struct pfctl_rule *r,
 
 	))))))))));
 
-	FREE_LIST(struct node_if, interfaces);
-	FREE_LIST(struct node_proto, protos);
-	FREE_LIST(struct node_host, src_hosts);
-	FREE_LIST(struct node_port, src_ports);
-	FREE_LIST(struct node_os, src_oses);
-	FREE_LIST(struct node_host, dst_hosts);
-	FREE_LIST(struct node_port, dst_ports);
-	FREE_LIST(struct node_uid, uids);
-	FREE_LIST(struct node_gid, gids);
-	FREE_LIST(struct node_icmp, icmp_types);
-	if (nat) {
-		FREE_LIST(struct node_host, nat->host);
-		free(nat);
-	}
-	if (rdr) {
-		FREE_LIST(struct node_host, rdr->host);
-		free(rdr);
-	}
-	if (route) {
-		FREE_LIST(struct node_host, route->host);
-		free(route);
+	if (!keeprule) {
+		FREE_LIST(struct node_if, interfaces);
+		FREE_LIST(struct node_proto, protos);
+		FREE_LIST(struct node_host, src_hosts);
+		FREE_LIST(struct node_port, src_ports);
+		FREE_LIST(struct node_os, src_oses);
+		FREE_LIST(struct node_host, dst_hosts);
+		FREE_LIST(struct node_port, dst_ports);
+		FREE_LIST(struct node_uid, uids);
+		FREE_LIST(struct node_gid, gids);
+		FREE_LIST(struct node_icmp, icmp_types);
+		if (nat) {
+			FREE_LIST(struct node_host, nat->host);
+			free(nat);
+		}
+		if (rdr) {
+			FREE_LIST(struct node_host, rdr->host);
+			free(rdr);
+		}
+		if (route) {
+			FREE_LIST(struct node_host, route->host);
+			free(route);
+		}
 	}
 
 	if (!added)
@@ -6445,6 +6640,7 @@ lookup(char *s)
 		{ "bandwidth",		BANDWIDTH},
 		{ "binat",		BINAT},
 		{ "binat-anchor",	BINATANCHOR},
+		{ "binat-to",		BINATTO},
 		{ "bitmask",		BITMASK},
 		{ "block",		BLOCK},
 		{ "block-policy",	BLOCKPOLICY},
@@ -6508,6 +6704,7 @@ lookup(char *s)
 		{ "modulate",		MODULATE},
 		{ "nat",		NAT},
 		{ "nat-anchor",		NATANCHOR},
+		{ "nat-to",		NATTO},
 		{ "no",			NO},
 		{ "no-df",		NODF},
 		{ "no-route",		NOROUTE},
@@ -6532,6 +6729,7 @@ lookup(char *s)
 		{ "random-id",		RANDOMID},
 		{ "rdr",		RDR},
 		{ "rdr-anchor",		RDRANCHOR},
+		{ "rdr-to",		RDRTO},
 		{ "realtime",		REALTIME},
 		{ "reassemble",		REASSEMBLE},
 		{ "received-on",	RECEIVEDON},
diff --git a/sbin/pfctl/pfctl.c b/sbin/pfctl/pfctl.c
index a9fc33525dd6..6e0be926eff0 100644
--- a/sbin/pfctl/pfctl.c
+++ b/sbin/pfctl/pfctl.c
@@ -1739,7 +1739,6 @@ pfctl_add_pool(struct pfctl *pf, struct pfctl_pool *p, sa_family_t af, int which
 void
 pfctl_init_rule(struct pfctl_rule *r)
 {
-
 	memset(r, 0, sizeof(struct pfctl_rule));
 	TAILQ_INIT(&(r->rdr.list));
 	TAILQ_INIT(&(r->nat.list));
diff --git a/sbin/pfctl/pfctl_parser.c b/sbin/pfctl/pfctl_parser.c
index eb3a0826578e..8a64578b136d 100644
--- a/sbin/pfctl/pfctl_parser.c
+++ b/sbin/pfctl/pfctl_parser.c
@@ -1240,25 +1240,34 @@ print_rule(struct pfctl_rule *r, const char *anchor_call, int verbose, int numer
 		}
 #endif
 	}
-	if (!anchor_call[0] && ! TAILQ_EMPTY(&r->nat.list) &&
-	    r->rule_flag & PFRULE_AFTO) {
-		printf(" af-to %s from ", r->naf == AF_INET ? "inet" : "inet6");
-		print_pool(&r->nat, r->nat.proxy_port[0], r->nat.proxy_port[1],
-		    r->naf ? r->naf : r->af, PF_NAT);
+	if (anchor_call[0])
+		return;
+	if (r->action == PF_NAT || r->action == PF_BINAT || r->action == PF_RDR) {
+		printf(" -> ");
+		print_pool(&r->rdr, r->rdr.proxy_port[0],
+		    r->rdr.proxy_port[1], r->af, r->action);
+	} else {
+		if (!TAILQ_EMPTY(&r->nat.list)) {
+			if (r->rule_flag & PFRULE_AFTO) {
+				printf(" af-to %s from ", r->naf == AF_INET ? "inet" : "inet6");
+			} else {
+				printf(" nat-to ");
+			}
+			print_pool(&r->nat, r->nat.proxy_port[0],
+			    r->nat.proxy_port[1], r->naf ? r->naf : r->af,
+			    PF_NAT);
+		}
 		if (!TAILQ_EMPTY(&r->rdr.list)) {
-			printf(" to ");
+			if (r->rule_flag & PFRULE_AFTO) {
+				printf(" to ");
+			} else {
+				printf(" rdr-to ");
+			}
 			print_pool(&r->rdr, r->rdr.proxy_port[0],
 			    r->rdr.proxy_port[1], r->naf ? r->naf : r->af,
 			    PF_RDR);
 		}
 	}
-	if (!anchor_call[0] &&
-	    (r->action == PF_NAT || r->action == PF_BINAT ||
-		r->action == PF_RDR)) {
-		printf(" -> ");
-		print_pool(&r->rdr, r->rdr.proxy_port[0],
-		    r->rdr.proxy_port[1], r->af, r->action);
-	}
 }
 
 void
diff --git a/sbin/pfctl/tests/files/pf0016.in b/sbin/pfctl/tests/files/pf0016.in
index 738bfb664395..7dbc53aa6a21 100644
--- a/sbin/pfctl/tests/files/pf0016.in
+++ b/sbin/pfctl/tests/files/pf0016.in
@@ -1,5 +1,5 @@
 # Test rule order processing: should fail unless nat -> filter
-#match out on lo0 from 192.168.1.1 to any nat-to 10.0.0.1
-#match in on lo0 proto tcp from any to 1.2.3.4/32 port 2222 rdr-to 10.0.0.10 port 22   
-#match on lo0 from 192.168.1.1 to any binat-to 10.0.0.1
+match out on lo0 from 192.168.1.1 to any nat-to 10.0.0.1
+match in on lo0 proto tcp from any to 1.2.3.4/32 port 2222 rdr-to 10.0.0.10 port 22
+match on lo0 from 192.168.1.1 to any binat-to 10.0.0.1
 pass in on lo1000000 from any to any no state
diff --git a/sbin/pfctl/tests/files/pf0016.ok b/sbin/pfctl/tests/files/pf0016.ok
index 6f0c211a5b8a..d65374a16475 100644
--- a/sbin/pfctl/tests/files/pf0016.ok
+++ b/sbin/pfctl/tests/files/pf0016.ok
@@ -1 +1,5 @@
+match out on lo0 inet from 192.168.1.1 to any nat-to 10.0.0.1
+match in on lo0 inet proto tcp from any to 1.2.3.4 port = 2222 rdr-to 10.0.0.10 port 22
+match out on lo0 inet from 192.168.1.1 to any nat-to 10.0.0.1 static-port
+match in on lo0 inet from any to 10.0.0.1 rdr-to 192.168.1.1
 pass in on lo1000000 all no state
diff --git a/sbin/pfctl/tests/files/pf0018.in b/sbin/pfctl/tests/files/pf0018.in
index 46606b476d79..ab3c81f86c5f 100644
--- a/sbin/pfctl/tests/files/pf0018.in
+++ b/sbin/pfctl/tests/files/pf0018.in
@@ -3,17 +3,17 @@
 TEST_LIST1 = "{ 192.168.1.5, 192.168.1.6, 192.168.1.7 }"
 TEST_LIST2 = "{ 172.6.1.1, 172.14.1.2/32, 172.16.2.0/24 }"
 
-#match out on lo0 from 192.168.1.1 to any nat-to 10.0.0.1
-#match out on lo0 proto tcp from 192.168.1.2 to any nat-to 10.0.0.2
-#match out on lo0 proto udp from 192.168.1.3 to any nat-to 10.0.0.3
-#match out on lo0 proto icmp from 192.168.1.4 to any nat-to 10.0.0.4
+match out on lo0 from 192.168.1.1 to any nat-to 10.0.0.1
+match out on lo0 proto tcp from 192.168.1.2 to any nat-to 10.0.0.2
+match out on lo0 proto udp from 192.168.1.3 to any nat-to 10.0.0.3
+match out on lo0 proto icmp from 192.168.1.4 to any nat-to 10.0.0.4
 
-#match out on lo0 inet from $TEST_LIST1 to $TEST_LIST2 nat-to lo0
+match out on lo0 inet from $TEST_LIST1 to $TEST_LIST2 nat-to lo0
 
-#match out on lo0 inet from 192.168.0.1/24 to any nat-to (lo0)
+match out on lo0 inet from 192.168.0.1/24 to any nat-to (lo0)
 
-#match out on lo0 from 192.168.1.8 to ! 172.17.0.0/16 nat-to 10.0.0.8
+match out on lo0 from 192.168.1.8 to ! 172.17.0.0/16 nat-to 10.0.0.8
 
-#match out on ! lo0 proto { udp, tcp } from any to any nat-to 10.0.0.8 static-port
+match out on ! lo0 proto { udp, tcp } from any to any nat-to 10.0.0.8 static-port
 
-#match out on { lo0, tun1000000 } from any to any nat-to 10.0.0.8
+match out on { lo0, tun1000000 } from any to any nat-to 10.0.0.8
diff --git a/sbin/pfctl/tests/files/pf0018.ok b/sbin/pfctl/tests/files/pf0018.ok
index c19ead6da1f0..6ba137ae84f8 100644
--- a/sbin/pfctl/tests/files/pf0018.ok
+++ b/sbin/pfctl/tests/files/pf0018.ok
@@ -1,2 +1,21 @@
 TEST_LIST1 = "{ 192.168.1.5, 192.168.1.6, 192.168.1.7 }"
 TEST_LIST2 = "{ 172.6.1.1, 172.14.1.2/32, 172.16.2.0/24 }"
+match out on lo0 inet from 192.168.1.1 to any nat-to 10.0.0.1
+match out on lo0 inet proto tcp from 192.168.1.2 to any nat-to 10.0.0.2
+match out on lo0 inet proto udp from 192.168.1.3 to any nat-to 10.0.0.3
+match out on lo0 inet proto icmp from 192.168.1.4 to any nat-to 10.0.0.4
+match out on lo0 inet from 192.168.1.5 to 172.6.1.1 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.5 to 172.14.1.2 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.5 to 172.16.2.0/24 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.6 to 172.6.1.1 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.6 to 172.14.1.2 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.6 to 172.16.2.0/24 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.7 to 172.6.1.1 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.7 to 172.14.1.2 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.1.7 to 172.16.2.0/24 nat-to 127.0.0.1
+match out on lo0 inet from 192.168.0.0/24 to any nat-to (lo0) round-robin
+match out on lo0 inet from 192.168.1.8 to ! 172.17.0.0/16 nat-to 10.0.0.8
+match out on ! lo0 inet proto udp all nat-to 10.0.0.8 static-port
+match out on ! lo0 inet proto tcp all nat-to 10.0.0.8 static-port
+match out on lo0 inet all nat-to 10.0.0.8
+match out on tun1000000 inet all nat-to 10.0.0.8
diff --git a/sbin/pfctl/tests/files/pf0019.in b/sbin/pfctl/tests/files/pf0019.in
index 0b1456e6fd03..e2bedbb64bd0 100644
--- a/sbin/pfctl/tests/files/pf0019.in
+++ b/sbin/pfctl/tests/files/pf0019.in
@@ -3,7 +3,7 @@ GOOD = "{ lo0, lo1000000 }"
 GOOD_NET = "{ 127.0.0.0/24, 10.0.1.0/24 }"
 DEST_NET = "{ 1.2.3.4/25, 2.4.6.8/30 }"
 
-#match in on lo0 proto tcp from any to 1.2.3.4/32 port 2222 rdr-to 10.0.0.10 port 22   
+match in on lo0 proto tcp from any to 1.2.3.4/32 port 2222 rdr-to 10.0.0.10 port 22
 
 # Test list processing
-#match in on $GOOD proto tcp from $GOOD_NET to $DEST_NET port 21 rdr-to 127.0.0.1 port 8021
+match in on $GOOD proto tcp from $GOOD_NET to $DEST_NET port 21 rdr-to 127.0.0.1 port 8021
diff --git a/sbin/pfctl/tests/files/pf0019.ok b/sbin/pfctl/tests/files/pf0019.ok
index 16c845aa2cd6..a5afc374d19f 100644
--- a/sbin/pfctl/tests/files/pf0019.ok
+++ b/sbin/pfctl/tests/files/pf0019.ok
@@ -2,3 +2,12 @@ EVIL = "lo0"
 GOOD = "{ lo0, lo1000000 }"
 GOOD_NET = "{ 127.0.0.0/24, 10.0.1.0/24 }"
 DEST_NET = "{ 1.2.3.4/25, 2.4.6.8/30 }"
+match in on lo0 inet proto tcp from any to 1.2.3.4 port = 2222 rdr-to 10.0.0.10 port 22
+match in on lo0 inet proto tcp from 127.0.0.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo0 inet proto tcp from 127.0.0.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo0 inet proto tcp from 10.0.1.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo0 inet proto tcp from 10.0.1.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 127.0.0.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 127.0.0.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 10.0.1.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 10.0.1.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
diff --git a/sbin/pfctl/tests/files/pf0020.in b/sbin/pfctl/tests/files/pf0020.in
index b00125bbcdb8..c973785bc9c5 100644
--- a/sbin/pfctl/tests/files/pf0020.in
+++ b/sbin/pfctl/tests/files/pf0020.in
@@ -5,5 +5,5 @@ GOOD = "{ lo0, lo1000000 }"
 GOOD_NET = "{ 127.0.0.0/24, 10.0.1.0/24 }"
 DEST_NET = "{ 1.2.3.4/25, 2.4.6.8/30 }"
 
-#match out on $EVIL inet from $GOOD_NET to $DEST_NET nat-to $EVIL
-#match in on $GOOD proto tcp from $GOOD_NET to $DEST_NET port 21 rdr-to 127.0.0.1 port 8021
+match out on $EVIL inet from $GOOD_NET to $DEST_NET nat-to $EVIL
+match in on $GOOD proto tcp from $GOOD_NET to $DEST_NET port 21 rdr-to 127.0.0.1 port 8021
diff --git a/sbin/pfctl/tests/files/pf0020.ok b/sbin/pfctl/tests/files/pf0020.ok
index 16c845aa2cd6..bd2c6cf2055d 100644
--- a/sbin/pfctl/tests/files/pf0020.ok
+++ b/sbin/pfctl/tests/files/pf0020.ok
@@ -2,3 +2,15 @@ EVIL = "lo0"
 GOOD = "{ lo0, lo1000000 }"
 GOOD_NET = "{ 127.0.0.0/24, 10.0.1.0/24 }"
 DEST_NET = "{ 1.2.3.4/25, 2.4.6.8/30 }"
+match out on lo0 inet from 127.0.0.0/24 to 1.2.3.0/25 nat-to 127.0.0.1
+match out on lo0 inet from 127.0.0.0/24 to 2.4.6.8/30 nat-to 127.0.0.1
+match out on lo0 inet from 10.0.1.0/24 to 1.2.3.0/25 nat-to 127.0.0.1
+match out on lo0 inet from 10.0.1.0/24 to 2.4.6.8/30 nat-to 127.0.0.1
+match in on lo0 inet proto tcp from 127.0.0.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo0 inet proto tcp from 127.0.0.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo0 inet proto tcp from 10.0.1.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo0 inet proto tcp from 10.0.1.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 127.0.0.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 127.0.0.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 10.0.1.0/24 to 1.2.3.0/25 port = ftp rdr-to 127.0.0.1 port 8021
+match in on lo1000000 inet proto tcp from 10.0.1.0/24 to 2.4.6.8/30 port = ftp rdr-to 127.0.0.1 port 8021
diff --git a/sbin/pfctl/tests/files/pf0048.in b/sbin/pfctl/tests/files/pf0048.in
index e97a819de945..a0dd143c8dd2 100644
--- a/sbin/pfctl/tests/files/pf0048.in
+++ b/sbin/pfctl/tests/files/pf0048.in
@@ -1,12 +1,12 @@
 table < regress > { 1.2.3.4 !5.6.7.8 10/8 lo0 }
 table <regress.1> const { ::1 fe80::/64 }
 table <regress.a> { 1.2.3.4 !5.6.7.8 } { ::1 ::2 ::3 } file "/dev/null" const { 4.3.2.1 }
-#match out on lo0 inet from < regress.1> to <regress.2> nat-to lo0:0
-#match out on !lo0 inet from !<regress.1 > to <regress.2> nat-to lo0:0
-#match in on lo0 inet6 from <regress.1> to <regress.2> rdr-to lo0:0
-#match in on !lo0 inet6 from !< regress.1 > to <regress.2> rdr-to lo0:0
-#match in from { <regress.1> !<regress.2> } to any
-#match out from any to { !<regress.1>, <regress.2> }
+match out on lo0 inet from < regress.1> to <regress.2> nat-to lo0:0
+match out on !lo0 inet from !<regress.1 > to <regress.2> nat-to lo0:0
+match in on lo0 inet6 from <regress.1> to <regress.2> rdr-to lo0:0
+match in on !lo0 inet6 from !< regress.1 > to <regress.2> rdr-to lo0:0
+match in from { <regress.1> !<regress.2> } to any
+match out from any to { !<regress.1>, <regress.2> }
 pass in from <regress> to any
 pass out from any to <regress >
 pass in from { <regress.1> <regress.2> } to any
diff --git a/sbin/pfctl/tests/files/pf0048.ok b/sbin/pfctl/tests/files/pf0048.ok
index f3536f566d35..89569fb4f8ba 100644
--- a/sbin/pfctl/tests/files/pf0048.ok
+++ b/sbin/pfctl/tests/files/pf0048.ok
@@ -1,6 +1,14 @@
 table <regress> { 1.2.3.4 !5.6.7.8 10.0.0.0/8 ::1 fe80::1 127.0.0.1 }
 table <regress.1> const { ::1 fe80::/64 }
 table <regress.a> const { 1.2.3.4 !5.6.7.8 ::1 ::2 ::3 } file "/dev/null" { 4.3.2.1 }
+match out on lo0 inet from <regress.1> to <regress.2> nat-to 127.0.0.1
+match out on ! lo0 inet from ! <regress.1> to <regress.2> nat-to 127.0.0.1
+match in on lo0 inet6 from <regress.1> to <regress.2> rdr-to ::1
+match in on ! lo0 inet6 from ! <regress.1> to <regress.2> rdr-to ::1
+match in from <regress.1> to any
+match in from ! <regress.2> to any
+match out from any to ! <regress.1>
+match out from any to <regress.2>
 pass in from <regress> to any flags S/SA keep state
 pass out from any to <regress> flags S/SA keep state
 pass in from <regress.1> to any flags S/SA keep state
diff --git a/sbin/pfctl/tests/files/pf0069.in b/sbin/pfctl/tests/files/pf0069.in
index 1298954bbeda..85847b9bd6b2 100644
--- a/sbin/pfctl/tests/files/pf0069.in
+++ b/sbin/pfctl/tests/files/pf0069.in
@@ -1,3 +1,2 @@
-#match out on lo0 inet all tag regress nat-to lo0
+match out on lo0 inet all tag regress nat-to lo0
 pass out quick on lo0 keep state tagged regress
-
diff --git a/sbin/pfctl/tests/files/pf0069.ok b/sbin/pfctl/tests/files/pf0069.ok
index 33e0519645fc..2bf34c04baa7 100644
--- a/sbin/pfctl/tests/files/pf0069.ok
+++ b/sbin/pfctl/tests/files/pf0069.ok
@@ -1 +1,2 @@
+match out on lo0 inet all tag regress nat-to 127.0.0.1
 pass out quick on lo0 all flags S/SA keep state tagged regress
diff --git a/sbin/pfctl/tests/files/pf0070.in b/sbin/pfctl/tests/files/pf0070.in
index 8d5e34a13ff8..1ccec9302436 100644
--- a/sbin/pfctl/tests/files/pf0070.in
+++ b/sbin/pfctl/tests/files/pf0070.in
@@ -1,3 +1,2 @@
-#match out on lo0 from 10.0.0.0/8 to any nat-to lo0
+match out on lo0 from 10.0.0.0/8 to any nat-to lo0
 block out on lo0 tagged regress
-
diff --git a/sbin/pfctl/tests/files/pf0070.ok b/sbin/pfctl/tests/files/pf0070.ok
index d30b70ff3e5a..cf79485b40c1 100644
--- a/sbin/pfctl/tests/files/pf0070.ok
+++ b/sbin/pfctl/tests/files/pf0070.ok
@@ -1 +1,2 @@
+match out on lo0 inet from 10.0.0.0/8 to any nat-to 127.0.0.1
 block drop out on lo0 all tagged regress
diff --git a/sbin/pfctl/tests/files/pf0071.in b/sbin/pfctl/tests/files/pf0071.in
index 48976b61ed3d..8975a8ebc943 100644
--- a/sbin/pfctl/tests/files/pf0071.in
+++ b/sbin/pfctl/tests/files/pf0071.in
@@ -1,3 +1,2 @@
-#match in on lo0 proto tcp from 10.0.0.0/8 to port 80 rdr-to lo0
+match in on lo0 proto tcp from 10.0.0.0/8 to port 80 rdr-to lo0
 block out on lo0 tagged regress
-
diff --git a/sbin/pfctl/tests/files/pf0071.ok b/sbin/pfctl/tests/files/pf0071.ok
index d30b70ff3e5a..2bae94fc8fac 100644
--- a/sbin/pfctl/tests/files/pf0071.ok
+++ b/sbin/pfctl/tests/files/pf0071.ok
@@ -1 +1,2 @@
+match in on lo0 inet proto tcp from 10.0.0.0/8 to any port = http rdr-to 127.0.0.1
 block drop out on lo0 all tagged regress
diff --git a/sbin/pfctl/tests/files/pf0072.in b/sbin/pfctl/tests/files/pf0072.in
index fd037f31ef27..d23843b799d5 100644
--- a/sbin/pfctl/tests/files/pf0072.in
+++ b/sbin/pfctl/tests/files/pf0072.in
@@ -1,4 +1,3 @@
 # test binat tagging
-#match on lo0 from 192.168.1.1 to any tag regress binat-to 10.0.0.1
+match on lo0 from 192.168.1.1 to any tag regress binat-to 10.0.0.1
 block out on lo0 tagged regress
-
diff --git a/sbin/pfctl/tests/files/pf0072.ok b/sbin/pfctl/tests/files/pf0072.ok
index d30b70ff3e5a..02e676dadc06 100644
--- a/sbin/pfctl/tests/files/pf0072.ok
+++ b/sbin/pfctl/tests/files/pf0072.ok
@@ -1 +1,3 @@
+match out on lo0 inet from 192.168.1.1 to any tag regress nat-to 10.0.0.1 static-port
+match in on lo0 inet from any to 10.0.0.1 tag regress rdr-to 192.168.1.1
 block drop out on lo0 all tagged regress
diff --git a/sbin/pfctl/tests/files/pf0084.in b/sbin/pfctl/tests/files/pf0084.in
index c0390df889e3..17140a786d73 100644
--- a/sbin/pfctl/tests/files/pf0084.in
+++ b/sbin/pfctl/tests/files/pf0084.in
@@ -1,9 +1,9 @@
-#match out on tun1000000 from 10.0.0.0/24 to any \
-#	nat-to { 10.0.1.1, 10.0.1.2 } round-robin sticky-address
-#match in on tun1000000 from any to 10.0.1.1 \
-#	rdr-to { 10.0.0.0/24 } sticky-address random
-#match in on tun1000000 from any to 10.0.1.2 \
-#	rdr-to { 10.0.0.1, 10.0.0.2 } sticky-address
+match out on tun1000000 from 10.0.0.0/24 to any \
+	nat-to { 10.0.1.1, 10.0.1.2 } round-robin sticky-address
+match in on tun1000000 from any to 10.0.1.1 \
+	rdr-to { 10.0.0.0/24 } sticky-address random
+match in on tun1000000 from any to 10.0.1.2 \
+	rdr-to { 10.0.0.1, 10.0.0.2 } sticky-address
 
 pass in proto tcp from any to any port 22 \
 	keep state (source-track)
diff --git a/sbin/pfctl/tests/files/pf0084.ok b/sbin/pfctl/tests/files/pf0084.ok
index 272fd6052023..1ca89e515a3d 100644
--- a/sbin/pfctl/tests/files/pf0084.ok
+++ b/sbin/pfctl/tests/files/pf0084.ok
@@ -1,3 +1,6 @@
+match out on tun1000000 inet from 10.0.0.0/24 to any nat-to { 10.0.1.1, 10.0.1.2 } round-robin sticky-address
+match in on tun1000000 inet from any to 10.0.1.1 rdr-to 10.0.0.0/24 random sticky-address
+match in on tun1000000 inet from any to 10.0.1.2 rdr-to { 10.0.0.1, 10.0.0.2 } round-robin sticky-address
 pass in proto tcp from any to any port = ssh flags S/SA keep state (source-track global)
 pass in proto tcp from any to any port = smtp flags S/SA keep state (source-track global)
 pass in proto tcp from any to any port = http flags S/SA keep state (source-track rule, max-src-states 3, max-src-nodes 1000)
diff --git a/sbin/pfctl/tests/files/pf0098.in b/sbin/pfctl/tests/files/pf0098.in
index b2b642be2026..c26f0fcfe4d3 100644
--- a/sbin/pfctl/tests/files/pf0098.in
+++ b/sbin/pfctl/tests/files/pf0098.in
@@ -1,4 +1,3 @@
 # Test rule order processing should pass (require-order no longer required)
 pass in on lo1000000 all
-#match out on lo0 inet6 all nat-to lo0
-
+match out on lo0 inet6 all nat-to lo0
diff --git a/sbin/pfctl/tests/files/pf0098.ok b/sbin/pfctl/tests/files/pf0098.ok
index 62016c91d60b..105bb46b4ae5 100644
--- a/sbin/pfctl/tests/files/pf0098.ok
+++ b/sbin/pfctl/tests/files/pf0098.ok
@@ -1 +1,2 @@
 pass in on lo1000000 all flags S/SA keep state
+match out on lo0 inet6 all nat-to { ::1, fe80::1 } round-robin
diff --git a/share/man/man5/pf.conf.5 b/share/man/man5/pf.conf.5
index 6ff3d50e6dc3..b5e3e1126978 100644
--- a/share/man/man5/pf.conf.5
+++ b/share/man/man5/pf.conf.5
@@ -27,7 +27,7 @@
 .\" ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 .\" POSSIBILITY OF SUCH DAMAGE.
 .\"
-.Dd April 22, 2025
+.Dd April 27, 2025
 .Dt PF.CONF 5
 .Os
 .Sh NAME
@@ -1333,29 +1333,18 @@ If the
 .Xr dummynet 4
 module is not loaded any traffic sent into a queue or pipe will be dropped.
 .Sh TRANSLATION
-Translation rules modify either the source or destination address of the
-packets associated with a stateful connection.
-A stateful connection is automatically created to track packets matching
-such a rule as long as they are not blocked by the filtering section of
-.Nm pf.conf .
-The translation engine modifies the specified address and/or port in the
-packet, recalculates IP, TCP and UDP checksums as necessary, and passes
-it to the packet filter for evaluation.
-.Pp
-Since translation occurs before filtering the filter
-engine will see packets as they look after any
-addresses and ports have been translated.
-Filter rules will therefore have to filter based on the translated
+Translation options modify either the source or destination address and
+port of the packets associated with a stateful connection.
+.Xr pf 4
+modifies the specified address and/or port in the packet and recalculates
+IP, TCP, and UDP checksums as necessary.
+.Pp
+If specified on a
+.Ic match
+rule, subsequent rules will see packets as they look
+after any addresses and ports have been translated.
+These rules will therefore have to filter based on the translated
 address and port number.
-Packets that match a translation rule are only automatically passed if
-the
-.Ar pass
-modifier is given, otherwise they are
-still subject to
-.Ar block
-and
-.Ar pass
-rules.
 .Pp
 The state entry created permits
 .Xr pf 4
@@ -1418,13 +1407,18 @@ The current implementation will only extract IPv4 addresses from the
 IPv6 addresses with a prefix length of /96 and greater.
 .It Ar binat
 A
-.Ar binat
+.Ar binat-to
 rule specifies a bidirectional mapping between an external IP netblock
 and an internal IP netblock.
-.It Ar nat
+It expands to an outbound
+.Ar nat-to
+rule and an inbound
+.Ar rdr-to
+rule.
+.It Ar nat-to
 A
-.Ar nat
-rule specifies that IP addresses are to be changed as the packet
+.Ar nat-to
+option specifies that IP addresses are to be changed as the packet
 traverses the given interface.
 This technique allows one or more IP addresses
 on the translating host to support network traffic for a larger range of
@@ -1432,44 +1426,116 @@ machines on an "inside" network.
 Although in theory any IP address can be used on the inside, it is strongly
 recommended that one of the address ranges defined by RFC 1918 be used.
 These netblocks are:
-.Bd -literal
-10.0.0.0 - 10.255.255.255 (all of net 10, i.e., 10/8)
-172.16.0.0 - 172.31.255.255 (i.e., 172.16/12)
-192.168.0.0 - 192.168.255.255 (i.e., 192.168/16)
+.Bd -literal -offset indent
+10.0.0.0 - 10.255.255.255 (all of net 10.0.0.0, i.e., 10.0.0.0/8)
+172.16.0.0 - 172.31.255.255 (i.e., 172.16.0.0/12)
+192.168.0.0 - 192.168.255.255 (i.e., 192.168.0.0/16)
 .Ed
*** 2130 LINES SKIPPED ***