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

Romain FABBRI <[email protected]> Mon, 13 Jul 2026 17:16:44 +0000
Newsgroups gmane.os.openbsd.tech
Message-ID <GV4PR08MB11580659953EFA21E49AEAB17FCFA2@GV4PR08MB11580.eurprd08.prod.outlook.com>
Hi,

I am not subscribed to tech@, so please keep me in Cc on any
replies.

relayd(8) can already PREPEND a PROXY protocol header towards a
backend (proxy_protocol.c, "forward to ... proxy-protocol v1|v2").

The attached diff adds the symmetric direction: a listener can be
told to EXPECT a PROXY header from the immediately upstream proxy and
adopt the advertised client address.

Motivation:
- When relayd sits behind a TCP SNI demultiplexer that forwards the
  raw stream to a local relayd for TLS termination, every session
  appears to come from the loopback address.
- The real client address is then lost for the REMOTE_ADDR macro /
  X-Forwarded-For, for source-hash stickiness, and for logging.
- The PROXY protocol is the standard remedy; relayd could emit it but
  not consume it.

Patch design:
- one new per-listener field, relay_config.proxyproto (the F_* flag
  word is full, and a receive flag is orthogonal to the emit flags).
- the header is read before the TLS handshake via MSG_PEEK, then
  exactly its bytes are consumed with recv(2), leaving the following
  ClientHello untouched.
- a partial header reschedules on EV_READ under the existing session
  timeout (no userland reassembly).
- the version is auto-detected; a LOCAL/UNSPEC header keeps the
  accept(2) peer.
- refused together with a transparent forward (FWD_TRANS), otherwise
  the advertised address would become a bindany source (spoofing).
- as with any PROXY receiver, the listener must only be reachable
  from a trusted upstream (documented in relayd.conf(5)).

Configuration:
  listen on <addr> [tls] port <port> [proxy-protocol]

Testing (diff is against -current; built and run on both 7.9-release,
and -current, can send 7.9 patch if usefull to anyone):
- builds with no new warnings.
- "relayd -n" accepts the new keyword and rejects the FWD_TRANS
  combination , on both.
- a small unit harness around the parser (v1, v2, LOCAL, malformed
  input, exact header consumption) passes.
- verified end-to-end with sniproxy on 7.9 prepending a v2 header in front
  of a TLS-terminating relayd: the backend sees the real client
  address in X-Forwarded-For instead of the loopback address.

Feedback welcome, in particular on the naming and on whether a bare
"proxy-protocol" (auto-detected) reads well next to the versioned
emit directive.

Regards,
Romain
relayd-proxyproto.diff (application/octet-stream, 10.5 KB)
--- usr.sbin/relayd/relayd.h	2026-07-13 18:22:20.945615794 +0200
+++ usr.sbin/relayd/relayd.h	2026-07-13 18:22:20.949644934 +0200
@@ -577,6 +577,7 @@
 	struct timeval			 se_tv_last;
 	struct event			 se_inflightevt;
 	int				 se_done;
+	int				 se_proxydone;	/* PROXY header consumed */
 	int				 se_retry;
 	int				 se_retrycount;
 	int				 se_connectcount;
@@ -815,6 +816,7 @@
 	enum forwardmode	 fwdmode;
 	union hashkey		 hashkey;
 	off_t			 tls_cakey_len;
+	int			 proxyproto;	/* expect PROXY header from client */
 };
 
 struct relay {
@@ -1459,5 +1461,6 @@
 /* proxy_protocol.c */
 int	proxy_protocol_v1(struct rsession *, struct evbuffer *);
 int	proxy_protocol_v2(struct rsession *, struct evbuffer *);
+int	proxy_protocol_read(int, struct sockaddr_storage *, in_port_t *);
 
 #endif /* RELAYD_H */
--- usr.sbin/relayd/parse.y	2026-07-13 18:22:20.933105130 +0200
+++ usr.sbin/relayd/parse.y	2026-07-13 18:23:02.927923187 +0200
@@ -189,7 +189,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
 %type	<v.port>	port
@@ -1112,6 +1112,10 @@
 		| PROXYPROTO V2 { $$ = F_PROXYV2; }
 		;
 
+optproxyproto_in : /* empty */	{ $$ = 0; }
+		| PROXYPROTO	{ $$ = 1; }
+		;
+
 protopts_n	: /* empty */
 		| '{' '}'
 		| '{' optnl protopts_l '}'
@@ -1946,6 +1950,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)) {
@@ -1994,7 +2005,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;
@@ -2046,6 +2057,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)
--- usr.sbin/relayd/relay.c	2026-07-13 18:22:20.939135740 +0200
+++ usr.sbin/relayd/relay.c	2026-07-13 18:22:20.950318844 +0200
@@ -70,6 +70,8 @@
 
 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_hash_addr(SIPHASH_CTX *, struct sockaddr_storage *, int);
 
@@ -1438,11 +1440,63 @@
 }
 
 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	 ss;
+	in_port_t		 port;
+	int			 ret;
+
+	if (event & EV_TIMEOUT) {
+		relay_close(con, "PROXY header timeout", 1);
+		return;
+	}
+
+	/* Seed with the real peer so a LOCAL/UNSPEC header is a no-op. */
+	ss = con->se_in.ss;
+	port = con->se_in.port;
+
+	ret = proxy_protocol_read(fd, &ss, &port);
+	if (ret == 0) {			/* header not complete yet */
+		relay_proxy_read(con);
+		return;
+	}
+	if (ret == -1) {
+		relay_close(con, "invalid PROXY header", 1);
+		return;
+	}
+
+	con->se_in.ss = ss;
+	con->se_in.port = port;
+	con->se_proxydone = 1;
+	relay_session(con);
+}
+
+void
 relay_session(struct rsession *con)
 {
 	struct relay		*rlay = con->se_relay;
 	struct ctl_relay_event	*in = &con->se_in, *out = &con->se_out;
 
+	/*
+	 * Consume the PROXY protocol header (if the listener expects one)
+	 * before anything reads the socket, so the advertised client address
+	 * replaces the accept(2) peer for hashing, $REMOTE_ADDR and logging.
+	 */
+	if (rlay->rl_conf.proxyproto && !con->se_proxydone) {
+		relay_proxy_read(con);
+		return;
+	}
+
 	if (bcmp(&rlay->rl_conf.ss, &out->ss, sizeof(out->ss)) == 0 &&
 	    out->port == rlay->rl_conf.port) {
 		log_debug("%s: session %d: looping", __func__, con->se_id);
--- usr.sbin/relayd/proxy_protocol.c	2026-07-13 18:22:20.936194813 +0200
+++ usr.sbin/relayd/proxy_protocol.c	2026-07-13 18:22:20.951323846 +0200
@@ -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,164 @@
 
 	return 0;
 }
+
+#define PROXY_V1_MAX		107	/* incl. CRLF, per HAProxy spec */
+#define PROXY_HEADER_MAX	536	/* v2 header + address block (+ TLVs) */
+
+/*
+ * Parse a textual (v1) PROXY line already delimited by CRLF within buf.
+ * Returns the line length including CRLF, 0 if still incomplete, or
+ * (size_t)-1 on a malformed line.  A UNKNOWN transport keeps the peer.
+ */
+static size_t
+proxy_protocol_v1_parse(const u_int8_t *buf, size_t n,
+    struct sockaddr_storage *ss, in_port_t *port)
+{
+	char			 line[PROXY_V1_MAX + 1];
+	char			*fields[6], *p;
+	struct sockaddr_in	*sin;
+	struct sockaddr_in6	*sin6;
+	const char		*errstr;
+	size_t			 i, len = 0;
+	int			 nf = 0, af;
+	long long		 srcport;
+
+	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 peer */
+	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;
+
+	srcport = strtonum(fields[4], 1, USHRT_MAX, &errstr);
+	if (errstr != NULL)
+		return (size_t)-1;
+
+	if (af == AF_INET) {
+		sin = (struct sockaddr_in *)ss;
+		memset(sin, 0, sizeof(*sin));
+		sin->sin_family = AF_INET;
+		sin->sin_len = sizeof(*sin);
+		if (inet_pton(AF_INET, fields[2], &sin->sin_addr) != 1)
+			return (size_t)-1;
+	} else {
+		sin6 = (struct sockaddr_in6 *)ss;
+		memset(sin6, 0, sizeof(*sin6));
+		sin6->sin6_family = AF_INET6;
+		sin6->sin6_len = sizeof(*sin6);
+		if (inet_pton(AF_INET6, fields[2], &sin6->sin6_addr) != 1)
+			return (size_t)-1;
+	}
+	*port = htons((in_port_t)srcport);
+	return len;
+}
+
+/*
+ * Read and consume a PROXY protocol header (v1 or v2) from a client socket,
+ * before any TLS or application data.  The header is peeked first so exactly
+ * its bytes are removed, leaving the following stream intact.  On success the
+ * real client address and port are stored in ss and port (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.
+ */
+int
+proxy_protocol_read(int fd, struct sockaddr_storage *ss, in_port_t *port)
+{
+	u_int8_t		 buf[PROXY_HEADER_MAX];
+	struct proxy_v2_hdr	*hdr;
+	union proxy_v2_addr	*addr;
+	struct sockaddr_in	*sin;
+	struct sockaddr_in6	*sin6;
+	ssize_t			 n;
+	size_t			 hlen, alen;
+
+	n = recv(fd, buf, sizeof(buf), MSG_PEEK);
+	if (n == -1)
+		return (errno == EAGAIN || errno == EWOULDBLOCK) ? 0 : -1;
+	if (n == 0)
+		return -1;
+
+	if ((size_t)n >= sizeof(PROXY_V2_SIG) &&
+	    memcmp(buf, PROXY_V2_SIG, sizeof(PROXY_V2_SIG)) == 0) {
+		if ((size_t)n < sizeof(struct proxy_v2_hdr))
+			return 0;
+		hdr = (struct proxy_v2_hdr *)buf;
+		if ((hdr->ver_cmd >> 4) != 0x2)
+			return -1;
+		alen = ntohs(hdr->len);
+		hlen = sizeof(struct proxy_v2_hdr) + alen;
+		if (hlen > sizeof(buf))
+			return -1;
+		if ((size_t)n < hlen)
+			return 0;
+
+		if ((hdr->ver_cmd & 0x0f) == PROXY_V2_CMD_PROXY) {
+			addr = (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;
+				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->ipv4_addr.src_addr;
+				*port = addr->ipv4_addr.src_port;
+				break;
+			case PROXY_V2_FAM_TCP6:
+				if (alen < sizeof(addr->ipv6_addr))
+					return -1;
+				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->ipv6_addr.src_addr,
+				    sizeof(sin6->sin6_addr));
+				*port = addr->ipv6_addr.src_port;
+				break;
+			default:
+				break;		/* UNSPEC/UDP: keep peer */
+			}
+		}
+	} else {
+		hlen = proxy_protocol_v1_parse(buf, n, ss, port);
+		if (hlen == 0)
+			return ((size_t)n >= PROXY_V1_MAX) ? -1 : 0;
+		if (hlen == (size_t)-1)
+			return -1;
+	}
+
+	while ((n = recv(fd, buf, hlen, 0)) == -1 && errno == EINTR)
+		;
+	if (n != (ssize_t)hlen)
+		return -1;
+
+	return 1;
+}
--- usr.sbin/relayd/relayd.conf.5	2026-07-13 18:22:20.942455987 +0200
+++ usr.sbin/relayd/relayd.conf.5	2026-07-13 18:22:20.951757770 +0200
@@ -725,6 +725,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
@@ -744,6 +745,24 @@
 .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 Ic protocol Ar name
 Use the specified protocol definition for the relay.
 The generic TCP protocol options will be used by default;