Index | Thread | Search

From:
Kirill A. Korinsky <kirill@korins.ky>
Subject:
Re: relayd: receive the PROXY protocol (v1 and v2) on listeners
To:
Romain FABBRI <romain.fabbri@alienconsulting.net>
Cc:
Rafael Sadowski <rafael@sizeofvoid.org>, OpenBSD tech <tech@openbsd.org>
Date:
Wed, 21 Oct 2026 21:14:53 +0200

Download raw body.

Thread
On Tue, 29 Sep 2026 16:46:26 +0200,
Romain FABBRI <romain.fabbri@alienconsulting.net> wrote:
> 
> Thanks for getting back to it. I've attached the diff directly this time
> 0001-relayd-proxy-protocol-receive.patch
> 
> It already includes your last review round: the header is consumed
> incrementally with no event-loop spin, hooked in relay_accept ->
> relay_accepted, and proxy-protocol is refused on udp and with a
> transparent forward.
> 
> Short version of what it does: it lets a relayd(8) listener *receive* a
> PROXY header (v1/v2) via a new "proxy-protocol" keyword on the listen line,
> so a relay sitting behind a raw-TCP upstream (e.g. an SNI demux that hands
> the stream to a local relayd for TLS termination) recovers the real client
> address for $REMOTE_ADDR / X-Forwarded-For, source-hash stickiness and
> logging. relayd can already emit a PROXY header but couldn't receive one.
> 
> I rebased it on today's -current (8.0 snapshot #65): it applies with no
> fuzz, builds with no new warnings, links, and "relayd -n" is happy. The one
> hunk that had drifted was the F_NATLOOK proc_compose error check in relay.c,
> which the rebase keeps in the moved relay_accepted(). The unit harness for
> proxy_protocol_read() still passes on all cases (v1/v2, IPv6, LOCAL,
> malformed, partial-header anti-spin, byte-by-byte trickle).
> 
> It still builds on 7.9 too :)
>

Thanks for the diff.

I think here two rest small things.

1. Logic of multiple listeners, specially when the later one enabled
   proxy-protocol isn't clear. It smells buggy. It reads like it passes
   validation but resulted state is not that is expected nor clear.

2. I think here a potential miss when relayd recivies and sends proxy
   protocol header, seems it messed up destination port with source port.

Otherwise I think it is almost good to go.

Rafael?

Also, to save time to search for the diff I had re-inlined it:

relayd: receive the PROXY protocol (v1 and v2) on relay listeners

Adds reception of a PROXY protocol header on a relayd(8) listener so the
relay adopts the real client address advertised by the immediately upstream
proxy (e.g. an SNI demultiplexer forwarding the raw stream to a local relayd
for TLS termination), restoring $REMOTE_ADDR/X-Forwarded-For, source-hash
stickiness and logging.

Design: a per-listener field relay_config.proxyproto (the F_* flag word is
full).  The header is consumed right after accept(2), before nat-lookup /
tls-inspect / session start (in relay_accept -> relay_accepted), so the
advertised address is in place for every path.  proxy_protocol_read() reads
the header incrementally into a per-session buffer, consuming only its bytes
and never past its end, so the ClientHello that follows is left intact and a
partial header does not spin the event loop (the socket is drained; the read
resumes on EV_READ, bounded by the session timeout).  The version is auto-
detected; both advertised endpoints are adopted so source and local name keep
the same family.  proxy-protocol is refused with a transparent forward
(FWD_TRANS) and on udp relays; documented in relayd.conf(5).

Regression tests under regress/usr.sbin/relayd (v1 and v2).

--- regress/usr.sbin/relayd/Relayd.pm
+++ regress/usr.sbin/relayd/Relayd.pm
@@ -108,8 +108,9 @@
 	print $fh  "\n\tprotocol proto-$test"
 	    unless grep { /^protocol / } @relay;
 	my $tls = $self->{listenssl} ? " tls" : "";
+	my $proxy = $self->{listenproxyproto} ? " proxy-protocol" : "";
 	print $fh  "\n\tlisten on $self->{listenaddr} ".
-	    "port $self->{listenport}$tls" unless grep { /^listen / } @relay;
+	    "port $self->{listenport}$tls$proxy" unless grep { /^listen / } @relay;
 	my $withtls = $self->{forwardssl} ? " with tls" : "";
 	print $fh  "\n\tforward$withtls to $self->{connectaddr} ".
 	    "port $self->{connectport}" unless grep { /^forward / } @relay;
--- /dev/null
+++ regress/usr.sbin/relayd/args-proxy-protocol-filter.pl
@@ -0,0 +1,40 @@
+# test that a "from" filter rule matches the PROXY-advertised source
+
+use strict;
+use warnings;
+
+# The client prepends a PROXY v1 header advertising 192.0.2.1.  The protocol
+# blocks every request but re-allows those coming "from 192.0.2.1/32".  The
+# request therefore only reaches the backend if relay_test() evaluates the
+# address adopted from the PROXY header; had it kept the real loopback peer
+# 127.0.0.1, the pass rule would not match, the block would stand, and the
+# server would never see the connection.  "log connection" also shows the
+# adopted source.
+
+our %args = (
+    client => {
+	func => sub {
+	    my $self = shift;
+	    print "PROXY TCP4 192.0.2.1 198.51.100.1 4321 80\r\n";
+	    http_client($self);
+	},
+    },
+    relayd => {
+	listenproxyproto => 1,
+	protocol => [ "http",
+	    "block request",
+	    "pass request from 192.0.2.1/32",
+	],
+	loggrep => {
+	    qr/, 192\.0\.2\.1 -> /		=> 1,
+	    qr/Forbidden/			=> 0,
+	},
+    },
+    server => {
+	func => \&http_server,
+    },
+    len => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
--- /dev/null
+++ regress/usr.sbin/relayd/args-proxy-protocol-v2.pl
@@ -0,0 +1,37 @@
+# test PROXY protocol version 2 header received on the listener
+
+use strict;
+use warnings;
+use Socket qw(inet_aton);
+
+# Same as the v1 test but with a binary v2 header (TCP over IPv4).
+
+our %args = (
+    client => {
+	func => sub {
+	    my $self = shift;
+	    my $sig = pack("C12", 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D,
+		0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A);
+	    my $hdr = $sig . pack("CCn", 0x21, 0x11, 12) .
+		inet_aton("192.0.2.1") . inet_aton("198.51.100.1") .
+		pack("nn", 4321, 80);
+	    print $hdr;
+	    http_client($self);
+	},
+    },
+    relayd => {
+	listenproxyproto => 1,
+	protocol => [ "http" ],
+	loggrep => {
+	    qr/, 192\.0\.2\.1 -> /		=> 1,
+	    qr/, 127\.0\.0\.1 -> /		=> 0,
+	},
+    },
+    server => {
+	func => \&http_server,
+    },
+    len => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
--- /dev/null
+++ regress/usr.sbin/relayd/args-proxy-protocol.pl
@@ -0,0 +1,33 @@
+# test PROXY protocol version 1 header received on the listener
+
+use strict;
+use warnings;
+
+# The client prepends a PROXY protocol v1 header advertising a client address
+# that differs from the real loopback peer.  With "log connection" the relay
+# logs the adopted source; check that it is the advertised address.
+
+our %args = (
+    client => {
+	func => sub {
+	    my $self = shift;
+	    print "PROXY TCP4 192.0.2.1 198.51.100.1 4321 80\r\n";
+	    http_client($self);
+	},
+    },
+    relayd => {
+	listenproxyproto => 1,
+	protocol => [ "http" ],
+	loggrep => {
+	    qr/, 192\.0\.2\.1 -> /		=> 1,
+	    qr/, 127\.0\.0\.1 -> /		=> 0,
+	},
+    },
+    server => {
+	func => \&http_server,
+    },
+    len => 251,
+    md5 => "bc3a3f39af35fe5b1687903da2b00c7f",
+);
+
+1;
--- usr.sbin/relayd/parse.y
+++ usr.sbin/relayd/parse.y
@@ -202,7 +202,7 @@
 %type	<v.string>	context hostname interface table value path
 %type	<v.number>	http_type loglevel quick
 %type	<v.number>	dstmode flag forwardmode retry
-%type	<v.number>	opttls opttlsclient optproxyproto
+%type	<v.number>	opttls opttlsclient optproxyproto optproxyproto_in
 %type	<v.number>	redirect_proto relay_proto match pflog
 %type	<v.number>	action ruleaf key_option optpattern
 %type	<v.port>	port
@@ -1131,6 +1131,10 @@
 		| PROXYPROTO V2 { $$ = F_PROXYV2; }
 		;
 
+optproxyproto_in : /* empty */	{ $$ = 0; }
+		| PROXYPROTO	{ $$ = 1; }
+		;
+
 protopts_n	: /* empty */
 		| '{' '}'
 		| '{' optnl protopts_l '}'
@@ -2006,6 +2010,13 @@
 				    "and peer options", rlay->rl_conf.name);
 				YYERROR;
 			}
+			if (rlay->rl_conf.proxyproto &&
+			    rlay->rl_conf.fwdmode == FWD_TRANS) {
+				yyerror("relay %s cannot accept proxy-protocol "
+				    "with a transparent forward",
+				    rlay->rl_conf.name);
+				YYERROR;
+			}
 			if ((rlay->rl_conf.flags & (F_NATLOOK|F_DIVERT)) == 0 &&
 			    rlay->rl_conf.dstss.ss_family == AF_UNSPEC &&
 			    TAILQ_EMPTY(&rlay->rl_tables)) {
@@ -2018,6 +2029,13 @@
 				rlay->rl_conf.proto = conf->sc_proto_default.id;
 			}
 
+			if (rlay->rl_conf.proxyproto &&
+			    rlay->rl_proto->type == RELAY_PROTO_DNS) {
+				yyerror("relay %s cannot accept proxy-protocol "
+				    "on a udp relay", rlay->rl_conf.name);
+				YYERROR;
+			}
+
 			if (TAILQ_EMPTY(&rlay->rl_proto->tlscerts) &&
 			    relay_load_certfiles(conf, rlay, NULL) == -1) {
 				yyerror("cannot load certificates for relay %s",
@@ -2054,7 +2072,7 @@
 		| relayoptsl optnl
 		;
 
-relayoptsl	: LISTEN ON STRING port opttls {
+relayoptsl	: LISTEN ON STRING port opttls optproxyproto_in {
 			struct addresslist	 al;
 			struct address		*h;
 			struct relay		*nr, *r;
@@ -2106,6 +2124,8 @@
 				nr->rl_conf.port = h->port.val[0];
 				if ($5)
 					nr->rl_conf.flags |= F_TLS;
+				if ($6)
+					nr->rl_conf.proxyproto = 1;
 				cnt++;
 			}
 			if ($5)
@@ -3638,6 +3658,7 @@
 	rb->rl_conf.port = rc.port;
 	rb->rl_conf.flags =
 	    (ra->rl_conf.flags & ~F_TLS) | (rc.flags & F_TLS);
+	rb->rl_conf.proxyproto = rc.proxyproto;
 	if (!(rb->rl_conf.flags & F_TLS)) {
 		rb->rl_tls_cacert_fd = -1;
 		rb->rl_tls_ca_fd = -1;
--- usr.sbin/relayd/proxy_protocol.c
+++ usr.sbin/relayd/proxy_protocol.c
@@ -14,6 +14,10 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
+#include <arpa/inet.h>
+#include <errno.h>
+#include <stdlib.h>
+
 #include "relayd.h"
 
 #define PROXY_V2_CMD_PROXY 0x01
@@ -142,3 +146,216 @@
 
 	return 0;
 }
+
+static void
+proxy_addr4(struct sockaddr_storage *ss, in_addr_t addr, in_port_t port)
+{
+	struct sockaddr_in	*sin = (struct sockaddr_in *)ss;
+
+	memset(sin, 0, sizeof(*sin));
+	sin->sin_family = AF_INET;
+	sin->sin_len = sizeof(*sin);
+	sin->sin_addr.s_addr = addr;
+	sin->sin_port = port;
+}
+
+static void
+proxy_addr6(struct sockaddr_storage *ss, const u_int8_t *addr, in_port_t port)
+{
+	struct sockaddr_in6	*sin6 = (struct sockaddr_in6 *)ss;
+
+	memset(sin6, 0, sizeof(*sin6));
+	sin6->sin6_family = AF_INET6;
+	sin6->sin6_len = sizeof(*sin6);
+	memcpy(&sin6->sin6_addr, addr, sizeof(sin6->sin6_addr));
+	sin6->sin6_port = port;
+}
+
+/*
+ * Parse a textual (v1) PROXY line already delimited by CRLF within buf.
+ * Both endpoints are always the same family (TCP4 or TCP6), so src and dst
+ * are stored consistently.  Returns the line length including CRLF, 0 if
+ * still incomplete, or (size_t)-1 on a malformed line.  A UNKNOWN transport
+ * keeps the peers untouched.
+ */
+static size_t
+proxy_protocol_v1_parse(const u_int8_t *buf, size_t n,
+    struct sockaddr_storage *src, struct sockaddr_storage *dst)
+{
+	char			 line[PROXY_V1_MAX + 1];
+	char			*fields[6], *p;
+	struct in_addr		 sa, da;
+	struct in6_addr		 sa6, da6;
+	const char		*errstr;
+	size_t			 i, len = 0;
+	int			 nf = 0, af;
+	long long		 sport, dport;
+
+	for (i = 1; i < n && i <= PROXY_V1_MAX; i++) {
+		if (buf[i - 1] == '\r' && buf[i] == '\n') {
+			len = i + 1;
+			break;
+		}
+	}
+	if (len == 0)
+		return 0;
+
+	memcpy(line, buf, len - 2);
+	line[len - 2] = '\0';
+
+	fields[nf++] = line;
+	for (p = line; *p != '\0' && nf < 6; p++) {
+		if (*p == ' ') {
+			*p = '\0';
+			fields[nf++] = p + 1;
+		}
+	}
+	if (strcmp(fields[0], "PROXY") != 0)
+		return (size_t)-1;
+	if (nf == 2 && strcmp(fields[1], "UNKNOWN") == 0)
+		return len;			/* keep the real peers */
+	if (nf != 6)
+		return (size_t)-1;
+
+	if (strcmp(fields[1], "TCP4") == 0)
+		af = AF_INET;
+	else if (strcmp(fields[1], "TCP6") == 0)
+		af = AF_INET6;
+	else
+		return (size_t)-1;
+
+	sport = strtonum(fields[4], 0, USHRT_MAX, &errstr);
+	if (errstr != NULL)
+		return (size_t)-1;
+	dport = strtonum(fields[5], 0, USHRT_MAX, &errstr);
+	if (errstr != NULL)
+		return (size_t)-1;
+
+	if (af == AF_INET) {
+		if (inet_pton(AF_INET, fields[2], &sa) != 1 ||
+		    inet_pton(AF_INET, fields[3], &da) != 1)
+			return (size_t)-1;
+		proxy_addr4(src, sa.s_addr, htons((in_port_t)sport));
+		proxy_addr4(dst, da.s_addr, htons((in_port_t)dport));
+	} else {
+		if (inet_pton(AF_INET6, fields[2], &sa6) != 1 ||
+		    inet_pton(AF_INET6, fields[3], &da6) != 1)
+			return (size_t)-1;
+		proxy_addr6(src, sa6.s6_addr, htons((in_port_t)sport));
+		proxy_addr6(dst, da6.s6_addr, htons((in_port_t)dport));
+	}
+	return len;
+}
+
+/*
+ * Given the OFF header bytes already accumulated in BUF, return how many more
+ * bytes may be consumed from the socket without reading past the end of the
+ * PROXY header (so the stream that follows is left untouched): a positive
+ * count to read next, 0 when the header is complete, or (size_t)-1 on a
+ * malformed header.  v2 is length-delimited (16-byte fixed header then its
+ * address block); v1 is read one byte at a time up to the terminating CRLF.
+ */
+static size_t
+proxy_header_want(const u_int8_t *buf, size_t off)
+{
+	const struct proxy_v2_hdr	*hdr;
+	size_t				 cmp, hlen;
+
+	if (off == 0)
+		return 1;		/* need the first byte to pick v1/v2 */
+
+	cmp = off < sizeof(PROXY_V2_SIG) ? off : sizeof(PROXY_V2_SIG);
+	if (memcmp(buf, PROXY_V2_SIG, cmp) == 0) {
+		if (off < sizeof(struct proxy_v2_hdr))
+			return sizeof(struct proxy_v2_hdr) - off;
+		hdr = (const struct proxy_v2_hdr *)buf;
+		hlen = sizeof(struct proxy_v2_hdr) + ntohs(hdr->len);
+		if (hlen > PROXY_HEADER_MAX)
+			return (size_t)-1;
+		return off < hlen ? hlen - off : 0;
+	}
+
+	if (off >= 2 && buf[off - 2] == '\r' && buf[off - 1] == '\n')
+		return 0;
+	if (off >= PROXY_V1_MAX)
+		return (size_t)-1;
+	return 1;
+}
+
+/*
+ * Read and consume a PROXY protocol header (v1 or v2) from a client socket,
+ * before any TLS or application data.  Bytes are consumed as they arrive and
+ * accumulated in buf (PROXY_HEADER_MAX, allocated by the caller and carried
+ * across calls through *off), never reading past the header, so the following
+ * stream is left intact -- and, since nothing readable is left unconsumed, a
+ * partial header does not spin the event loop.  On success the advertised
+ * source and destination are stored in src and dst (both of the same family);
+ * a LOCAL/UNSPEC header leaves them unchanged.  Returns 1 when a full header
+ * was consumed, 0 when more data is needed (call again on EV_READ), -1 on a
+ * bad header or a connection error.
+ */
+int
+proxy_protocol_read(int fd, u_int8_t *buf, size_t *off,
+    struct sockaddr_storage *src, struct sockaddr_storage *dst)
+{
+	const struct proxy_v2_hdr	*hdr;
+	const union proxy_v2_addr	*addr;
+	ssize_t				 n;
+	size_t				 want, alen, hlen;
+
+	for (;;) {
+		want = proxy_header_want(buf, *off);
+		if (want == (size_t)-1)
+			return -1;
+		if (want == 0)
+			break;			/* full header accumulated */
+
+		n = recv(fd, buf + *off, want, 0);
+		if (n == -1) {
+			if (errno == EINTR)
+				continue;
+			if (errno == EAGAIN || errno == EWOULDBLOCK)
+				return 0;	/* drained; wait for EV_READ */
+			return -1;
+		}
+		if (n == 0)
+			return -1;		/* EOF before a full header */
+		*off += n;
+	}
+
+	if (*off >= sizeof(PROXY_V2_SIG) &&
+	    memcmp(buf, PROXY_V2_SIG, sizeof(PROXY_V2_SIG)) == 0) {
+		hdr = (const struct proxy_v2_hdr *)buf;
+		if ((hdr->ver_cmd >> 4) != 0x2)
+			return -1;
+		if ((hdr->ver_cmd & 0x0f) != PROXY_V2_CMD_PROXY)
+			return 1;		/* LOCAL: keep the real peers */
+		alen = ntohs(hdr->len);
+		addr = (const union proxy_v2_addr *)
+		    (buf + sizeof(struct proxy_v2_hdr));
+		switch (hdr->fam) {
+		case PROXY_V2_FAM_TCP4:
+			if (alen < sizeof(addr->ipv4_addr))
+				return -1;
+			proxy_addr4(src, addr->ipv4_addr.src_addr,
+			    addr->ipv4_addr.src_port);
+			proxy_addr4(dst, addr->ipv4_addr.dst_addr,
+			    addr->ipv4_addr.dst_port);
+			break;
+		case PROXY_V2_FAM_TCP6:
+			if (alen < sizeof(addr->ipv6_addr))
+				return -1;
+			proxy_addr6(src, addr->ipv6_addr.src_addr,
+			    addr->ipv6_addr.src_port);
+			proxy_addr6(dst, addr->ipv6_addr.dst_addr,
+			    addr->ipv6_addr.dst_port);
+			break;
+		default:
+			break;			/* UNSPEC/UDP: keep peers */
+		}
+		return 1;
+	}
+
+	hlen = proxy_protocol_v1_parse(buf, *off, src, dst);
+	return (hlen == 0 || hlen == (size_t)-1) ? -1 : 1;
+}
--- usr.sbin/relayd/relay.c
+++ usr.sbin/relayd/relay.c
@@ -70,6 +70,9 @@
 
 void		 relay_accept(int, short, void *);
 void		 relay_input(struct rsession *);
+void		 relay_proxy_read(struct rsession *);
+void		 relay_proxy_readcb(int, short, void *);
+void		 relay_accepted(struct rsession *);
 
 void		 relay_hash_addr(SIPHASH_CTX *, struct sockaddr_storage *, int);
 
@@ -1108,15 +1111,70 @@
 	relay_close(con, strerror(errno), 1);
 }
 
+/*
+ * Continue an accepted connection once its real endpoints are known:
+ * divert / nat-lookup / tls-inspect selection, then start the session.
+ */
+void
+relay_accepted(struct rsession *con)
+{
+	struct relay		*rlay = con->se_relay;
+	struct privsep		*ps = env->sc_ps;
+	struct ctl_natlook	*cnl;
+	struct timeval		 tv;
+
+	if (rlay->rl_conf.flags & F_DIVERT) {
+		memcpy(&con->se_out.ss, &con->se_sockname,
+		    sizeof(con->se_out.ss));
+		con->se_out.port = relay_socket_getport(&con->se_out.ss);
+
+		/* Detect loop and fall back to the alternate forward target */
+		if (bcmp(&rlay->rl_conf.ss, &con->se_out.ss,
+		    sizeof(con->se_out.ss)) == 0 &&
+		    con->se_out.port == rlay->rl_conf.port)
+			con->se_out.ss.ss_family = AF_UNSPEC;
+	} else if (rlay->rl_conf.flags & F_NATLOOK) {
+		if ((cnl = calloc(1, sizeof(*cnl))) == NULL) {
+			relay_close(con, "failed to allocate nat lookup", 1);
+			return;
+		}
+
+		con->se_cnl = cnl;
+		bzero(cnl, sizeof(*cnl));
+		cnl->in = -1;
+		cnl->id = con->se_id;
+		cnl->proc = ps->ps_instance;
+		cnl->proto = IPPROTO_TCP;
+
+		memcpy(&cnl->src, &con->se_in.ss, sizeof(cnl->src));
+		memcpy(&cnl->dst, &con->se_sockname, sizeof(cnl->dst));
+
+		if (proc_compose(env->sc_ps, PROC_PFE, IMSG_NATLOOK,
+		    cnl, sizeof(*cnl)) == -1)
+			log_warn("%s: proc_compose", __func__);
+
+		/* Schedule timeout */
+		evtimer_set(&con->se_ev, relay_natlook, con);
+		bcopy(&rlay->rl_conf.timeout, &tv, sizeof(tv));
+		evtimer_add(&con->se_ev, &tv);
+		return;
+	}
+
+	if (rlay->rl_conf.flags & F_TLSINSPECT) {
+		relay_preconnect(con);
+		return;
+	}
+
+	relay_session(con);
+}
+
 void
 relay_accept(int fd, short event, void *arg)
 {
 	struct privsep		*ps = env->sc_ps;
 	struct relay		*rlay = arg;
 	struct rsession		*con = NULL;
-	struct ctl_natlook	*cnl = NULL;
 	socklen_t		 slen;
-	struct timeval		 tv;
 	struct sockaddr_storage	 ss;
 	int			 s = -1;
 
@@ -1211,49 +1269,16 @@
 		return;
 	}
 
-	if (rlay->rl_conf.flags & F_DIVERT) {
-		memcpy(&con->se_out.ss, &con->se_sockname,
-		    sizeof(con->se_out.ss));
-		con->se_out.port = relay_socket_getport(&con->se_out.ss);
-
-		/* Detect loop and fall back to the alternate forward target */
-		if (bcmp(&rlay->rl_conf.ss, &con->se_out.ss,
-		    sizeof(con->se_out.ss)) == 0 &&
-		    con->se_out.port == rlay->rl_conf.port)
-			con->se_out.ss.ss_family = AF_UNSPEC;
-	} else if (rlay->rl_conf.flags & F_NATLOOK) {
-		if ((cnl = calloc(1, sizeof(*cnl))) == NULL) {
-			relay_close(con, "failed to allocate nat lookup", 1);
+	if (rlay->rl_conf.proxyproto) {
+		if ((con->se_proxybuf = calloc(1, PROXY_HEADER_MAX)) == NULL) {
+			relay_close(con, "failed to allocate proxy buffer", 1);
 			return;
 		}
-
-		con->se_cnl = cnl;
-		bzero(cnl, sizeof(*cnl));
-		cnl->in = -1;
-		cnl->id = con->se_id;
-		cnl->proc = ps->ps_instance;
-		cnl->proto = IPPROTO_TCP;
-
-		memcpy(&cnl->src, &con->se_in.ss, sizeof(cnl->src));
-		memcpy(&cnl->dst, &con->se_sockname, sizeof(cnl->dst));
-
-		if (proc_compose(env->sc_ps, PROC_PFE, IMSG_NATLOOK,
-		    cnl, sizeof(*cnl)) == -1)
-			log_warn("%s: proc_compose", __func__);
-
-		/* Schedule timeout */
-		evtimer_set(&con->se_ev, relay_natlook, con);
-		bcopy(&rlay->rl_conf.timeout, &tv, sizeof(tv));
-		evtimer_add(&con->se_ev, &tv);
+		relay_proxy_read(con);
 		return;
 	}
 
-	if (rlay->rl_conf.flags & F_TLSINSPECT) {
-		relay_preconnect(con);
-		return;
-	}
-
-	relay_session(con);
+	relay_accepted(con);
 	return;
  err:
 	if (s != -1) {
@@ -1443,6 +1468,53 @@
 }
 
 void
+relay_proxy_read(struct rsession *con)
+{
+	struct relay	*rlay = con->se_relay;
+
+	event_again(&con->se_ev, con->se_in.s, EV_TIMEOUT|EV_READ,
+	    relay_proxy_readcb, &con->se_tv_start, &rlay->rl_conf.timeout, con);
+}
+
+void
+relay_proxy_readcb(int fd, short event, void *arg)
+{
+	struct rsession		*con = arg;
+	struct sockaddr_storage	 src, dst;
+	int			 ret;
+
+	if (event & EV_TIMEOUT) {
+		relay_close(con, "PROXY protocol header timeout", 1);
+		return;
+	}
+
+	/* Seed with the real endpoints so a LOCAL/UNSPEC header is a no-op. */
+	src = con->se_in.ss;
+	dst = con->se_sockname;
+
+	ret = proxy_protocol_read(fd, con->se_proxybuf, &con->se_proxyoff,
+	    &src, &dst);
+	if (ret == 0) {			/* header not complete yet */
+		relay_proxy_read(con);
+		return;
+	}
+	if (ret == -1) {
+		relay_close(con, "invalid PROXY protocol header", 1);
+		return;
+	}
+
+	/*
+	 * Adopt both advertised endpoints so the source and the local name
+	 * stay in the same address family (a mismatched family would corrupt
+	 * a PROXY header re-emitted towards the backend).
+	 */
+	con->se_in.ss = src;
+	con->se_in.port = relay_socket_getport(&src);
+	con->se_sockname = dst;
+	relay_accepted(con);
+}
+
+void
 relay_session(struct rsession *con)
 {
 	struct relay		*rlay = con->se_relay;
@@ -1831,6 +1903,7 @@
 		free(con->se_cnl);
 	}
 
+	free(con->se_proxybuf);
 	free(con);
 	relay_sessions--;
 }
--- usr.sbin/relayd/relayd.conf.5
+++ usr.sbin/relayd/relayd.conf.5
@@ -731,6 +731,7 @@
 .It Xo
 .Ic listen on Ar address Ic port Ar port
 .Op Ic tls
+.Op Ic proxy-protocol
 .Xc
 Specify the address and port for the relay to listen on.
 The relay will accept incoming connections to the specified address or
@@ -750,6 +751,25 @@
 .Ic tls
 keyword is present, the relay will accept connections using the
 encrypted TLS protocol.
+.Pp
+If the
+.Ic proxy-protocol
+keyword is present, the relay expects each connection to begin with a
+PROXY protocol header (version 1 or 2, automatically detected) sent by the
+immediately upstream proxy, and adopts the advertised client address and
+port for source-based session distribution, the
+.Va REMOTE_ADDR
+macro, and logging.
+The header is consumed before the optional TLS handshake.
+Because the header is trusted, a listener using
+.Ic proxy-protocol
+must only be reachable from a trusted upstream: bind it to a private
+interface or the loopback address and restrict access with
+.Xr pf.conf 5 .
+This keyword may not be combined with a transparent
+.Ic forward to
+destination.
+It is not available for UDP relays.
 .It Ic protocol Ar name
 Use the specified protocol definition for the relay.
 The generic TCP protocol options will be used by default;
--- usr.sbin/relayd/relayd.h
+++ usr.sbin/relayd/relayd.h
@@ -81,6 +81,9 @@
 #define RELAY_MAX_HASH_RETRIES	5
 #define RELAY_TLS_PRIV_TIMEOUT	1000	/* wait 1sec for the ca */
 
+#define PROXY_V1_MAX		107	/* v1 line incl. CRLF, per spec */
+#define PROXY_HEADER_MAX	536	/* v2 fixed header + address block */
+
 #define CONFIG_RELOAD		0x00
 #define CONFIG_TABLES		0x01
 #define CONFIG_RDRS		0x02
@@ -581,6 +584,8 @@
 	struct timeval			 se_tv_last;
 	struct event			 se_inflightevt;
 	int				 se_done;
+	u_int8_t			*se_proxybuf;	/* PROXY header accumulator */
+	size_t				 se_proxyoff;	/* PROXY bytes accumulated */
 	int				 se_retry;
 	int				 se_retrycount;
 	int				 se_connectcount;
@@ -819,6 +824,7 @@
 	enum forwardmode	 fwdmode;
 	union hashkey		 hashkey;
 	off_t			 tls_cakey_len;
+	int			 proxyproto;	/* expect PROXY header from client */
 };
 
 struct relay {
@@ -1459,5 +1465,7 @@
 /* proxy_protocol.c */
 int	proxy_protocol_v1(struct rsession *, struct evbuffer *);
 int	proxy_protocol_v2(struct rsession *, struct evbuffer *);
+int	proxy_protocol_read(int, u_int8_t *, size_t *,
+	    struct sockaddr_storage *, struct sockaddr_storage *);
 
 #endif /* RELAYD_H */


-- 
wbr, Kirill