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;