From 806fb8b4b0abbf15f9717567e7b5a422b60f862a Mon Sep 17 00:00:00 2001
From: Julien Riou <julien.riou@ovhcloud.com>
Date: Sun, 14 Jun 2026 10:22:02 +0000
Subject: [PATCH] Implement server-side support for the PROXY protocol

Add support for HAProxy's PROXY protocol, versions 1 and 2, so the real
client address can be recovered from connections arriving through a trusted
proxy. The new proxy_networks GUC lists the networks whose peers are allowed
to prepend a PROXY header to declare the originating client.  This list
supports unix sockets with the "unix" token.  It is empty by default, which
disables the feature.

The header is parsed lazily.  The server only looks for it on connections
coming from a trusted network and falls through to the normal startup path
otherwise.  Existing clients that are not from the proxy networks and that
do not speak the PROXY protocol are unaffected.

For connections from a trusted proxy, client_addr and client_port in
pg_stat_activity and the host-based authentication checks reflect the
address from the header, while the proxy's own endpoint is exposed via the
new proxy_addr, proxy_hostname and proxy_port columns.  The %H and %R
escapes expose the proxy information in log_line_prefix.  The proxy host and
port are also emitted in the CSV and JSON log formats.
---
 doc/src/sgml/client-auth.sgml                 |  21 +
 doc/src/sgml/config.sgml                      |  77 ++-
 doc/src/sgml/monitoring.sgml                  |  44 ++
 doc/src/sgml/protocol.sgml                    |  16 +
 src/backend/catalog/system_views.sql          |   3 +
 src/backend/libpq/Makefile                    |   3 +-
 src/backend/libpq/auth.c                      |  12 +
 src/backend/libpq/meson.build                 |   1 +
 src/backend/libpq/pqcomm.c                    |   2 +-
 src/backend/libpq/proxy_protocol.c            | 517 ++++++++++++++++++
 src/backend/tcop/backend_startup.c            | 112 +++-
 src/backend/utils/activity/backend_status.c   |  35 ++
 src/backend/utils/adt/pgstatfuncs.c           |  59 +-
 src/backend/utils/error/csvlog.c              |  13 +
 src/backend/utils/error/elog.c                |  49 ++
 src/backend/utils/error/jsonlog.c             |   8 +
 src/backend/utils/misc/guc_parameters.dat     |  10 +
 src/backend/utils/misc/guc_tables.c           |   1 +
 src/backend/utils/misc/postgresql.conf.sample |   3 +
 src/include/catalog/pg_proc.dat               |   6 +-
 src/include/libpq/libpq-be.h                  |   6 +
 src/include/libpq/libpq.h                     |   1 +
 src/include/libpq/proxy_protocol.h            |  38 ++
 src/include/utils/backend_status.h            |   4 +
 src/include/utils/guc_hooks.h                 |   3 +
 src/test/Makefile                             |   1 +
 src/test/meson.build                          |   1 +
 src/test/protocol/.gitignore                  |   2 +
 src/test/protocol/Makefile                    |  25 +
 src/test/protocol/README                      |  28 +
 src/test/protocol/meson.build                 |  17 +
 src/test/protocol/t/001_proxy_protocol.pl     | 515 +++++++++++++++++
 src/test/protocol/t/002_proxy_protocol_ssl.pl | 115 ++++
 src/test/protocol/t/ProxyProtocol.pm          | 385 +++++++++++++
 src/test/regress/expected/rules.out           |  11 +-
 src/tools/pgindent/typedefs.list              |   3 +
 36 files changed, 2131 insertions(+), 16 deletions(-)
 create mode 100644 src/backend/libpq/proxy_protocol.c
 create mode 100644 src/include/libpq/proxy_protocol.h
 create mode 100644 src/test/protocol/.gitignore
 create mode 100644 src/test/protocol/Makefile
 create mode 100644 src/test/protocol/README
 create mode 100644 src/test/protocol/meson.build
 create mode 100644 src/test/protocol/t/001_proxy_protocol.pl
 create mode 100644 src/test/protocol/t/002_proxy_protocol_ssl.pl
 create mode 100644 src/test/protocol/t/ProxyProtocol.pm

diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index e4e65f8feb1..a5c93530261 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -421,6 +421,16 @@ include_dir         <replaceable>directory</replaceable>
        These fields do not apply to <literal>local</literal> records.
       </para>
 
+      <note>
+       <para>
+        When a connection arrives through a connection-forwarding proxy and a
+        PROXY protocol header is accepted, the address matched against this
+        field is the client address declared in that header, not the proxy's
+        own address.  See <xref linkend="guc-proxy-networks"/> for
+        details.
+       </para>
+      </note>
+
       <note>
        <para>
         Users sometimes wonder why host names are handled
@@ -1708,6 +1718,17 @@ omicron         bryanh                  guest1
     since <productname>PostgreSQL</productname> does not have any way to decrypt the
     returned string to determine the actual user name.
    </para>
+
+   <para>
+    Ident authentication is rejected for connections received through
+    the <link linkend="protocol-flow-proxy">PROXY protocol</link>. A client
+    connected to the proxy rather than to the server. The port pair
+    <replaceable>X</replaceable> and <replaceable>Y</replaceable> known to the
+    server does not describe any connection that the client's ident server has
+    a record of, and the query would reach the ident server from the server's
+    address rather than the proxy's. Any answer would therefore be
+    meaningless.
+   </para>
   </sect1>
 
   <sect1 id="auth-peer">
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index fa566c9e553..2aa8ed353d1 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -712,6 +712,51 @@ include_dir 'conf.d'
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-proxy-networks" xreflabel="proxy_networks">
+      <term><varname>proxy_networks</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>proxy_networks</varname> configuration parameter</primary>
+      </indexterm>
+      <indexterm>
+       <primary>PROXY protocol</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies the networks from which the server will accept a
+        PROXY protocol header.  When the server is reached through a
+        connection-forwarding proxy (like <productname>HAProxy</productname>),
+        the proxy can prepend a small header, as defined by the
+        <ulink url="https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt">PROXY
+        protocol</ulink>, that declares the address of the real client.
+        When such a header is accepted, the declared client
+        address replaces the proxy's address for host-based authentication
+        (see <xref linkend="auth-pg-hba-conf"/>) and in the server log.
+       </para>
+       <para>
+        The value is a comma-separated list of CIDR networks or the special
+        token <literal>unix</literal> for Unix-domain socket (for example
+        <literal>10.0.0.0/8, 192.168.1.10, ::1/128, unix</literal>).  A
+        PROXY protocol header is honored only when the actual peer address
+        falls within one of these networks.  A header received from any
+        other address is rejected as an invalid connection attempt, so that
+        ordinary clients cannot spoof their address.  The default is an
+        empty string, which disables the PROXY protocol support entirely.
+        This parameter can only be set in the
+        <filename>postgresql.conf</filename> file or on the server command
+        line.
+       </para>
+       <para>
+        Because a peer within these networks is treated as a proxy rather than
+        a direct client, it is <emphasis>required</emphasis> to lead with a
+        PROXY protocol header.  A connection from one of these networks that
+        begins with an ordinary startup packet, or an SSL or GSS negotiation
+        request, instead of a PROXY header is rejected. This prevents the
+        proxy's own address from being mistaken for a client's.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-max-connections" xreflabel="max_connections">
       <term><varname>max_connections</varname> (<type>integer</type>)
       <indexterm>
@@ -8107,11 +8152,12 @@ local0.*    /var/log/postgresql
       <listitem>
        <para>
         By default, connection log messages only show the IP address of the
-        connecting host. Turning this parameter on causes logging of the
-        host name as well.  Note that depending on your host name resolution
-        setup this might impose a non-negligible performance penalty.
-        This parameter can only be set in the <filename>postgresql.conf</filename>
-        file or on the server command line.
+        connecting host and the proxy host if the connection came from a
+        trusted proxy.  Turning this parameter on causes logging of the host
+        names as well.  Note that depending on your host name resolution setup
+        this might impose a non-negligible performance penalty.  This parameter
+        can only be set in the <filename>postgresql.conf</filename> file or on
+        the server command line.
        </para>
       </listitem>
      </varlistentry>
@@ -8181,6 +8227,16 @@ local0.*    /var/log/postgresql
              <entry>Remote host name or IP address</entry>
              <entry>yes</entry>
             </row>
+            <row>
+             <entry><literal>%H</literal></entry>
+             <entry>Proxy host name or IP address</entry>
+             <entry>yes</entry>
+            </row>
+            <row>
+             <entry><literal>%R</literal></entry>
+             <entry>Proxy host name or IP address, and proxy port</entry>
+             <entry>yes</entry>
+            </row>
             <row>
              <entry><literal>%L</literal></entry>
              <entry>Local address (the IP address on the server that the
@@ -8641,6 +8697,7 @@ CREATE TABLE postgres_log
   backend_type text,
   leader_pid integer,
   query_id bigint,
+  proxy_connection text,
   PRIMARY KEY (session_id, session_line_num)
 );
 </programlisting>
@@ -8767,6 +8824,16 @@ COPY postgres_log FROM '/full/path/to/logfile.csv' WITH csv;
          <entry>number</entry>
          <entry>Client port</entry>
         </row>
+        <row>
+         <entry><literal>proxy_host</literal></entry>
+         <entry>string</entry>
+         <entry>Proxy host</entry>
+        </row>
+        <row>
+         <entry><literal>proxy_port</literal></entry>
+         <entry>number</entry>
+         <entry>Proxy port</entry>
+        </row>
         <row>
          <entry><literal>session_id</literal></entry>
          <entry>string</entry>
diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 08d5b824552..ab0443446eb 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -843,6 +843,10 @@ postgres   27093  0.0  0.0  30096  2752 ?        Ss   11:34   0:00 postgres: ser
        If this field is null, it indicates either that the client is
        connected via a Unix socket on the server machine or that this is an
        internal process such as autovacuum.
+       When the connection was made through a trusted proxy using the PROXY
+       protocol, this is the client address sent by the proxy, not the address
+       of the proxy itself (which is shown in <structfield>proxy_addr</structfield>
+       instead).
       </para></entry>
      </row>
 
@@ -865,6 +869,46 @@ postgres   27093  0.0  0.0  30096  2752 ?        Ss   11:34   0:00 postgres: ser
        TCP port number that the client is using for communication
        with this backend, or <literal>-1</literal> if a Unix socket is used.
        If this field is null, it indicates that this is an internal server process.
+       When the connection was made through a trusted proxy using the PROXY
+       protocol, this is the client port sent by the proxy, not the port of the
+       proxy itself (which is shown in <structfield>proxy_port</structfield>
+       instead).
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>proxy_addr</structfield> <type>inet</type>
+      </para>
+      <para>
+       IP address of the trusted proxy that forwarded this connection.
+       If this field is null, it indicates either that the PROXY protocol has
+       not been used, or the proxy forwarded the connection over a Unix socket,
+       or the proxy used a <literal>LOCAL<literal> command.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>proxy_hostname</structfield> <type>text</type>
+      </para>
+      <para>
+       Host name of the proxy that forwarded this connection, as reported by a
+       reverse DNS lookup of <structfield>proxy_addr</structfield>. This field
+       will only be non-null when the client connected through a trusted proxy
+       using the PROXY protocol, and only when <xref linkend="guc-log-hostname"/>
+       is enabled.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>proxy_port</structfield> <type>integer</type>
+      </para>
+      <para>
+       TCP port number of the trusted proxy that forwarded this connection, or
+       <literal>-1</literal> if the proxy forwarded it over a Unix socket.
+       This field is null when the PROXY protocol was not used.
       </para></entry>
      </row>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index 49f81676712..556fd325d7a 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1693,6 +1693,22 @@ SELCT 1/0;<!-- this typo is intentional -->
    </para>
   </sect2>
 
+  <sect2 id="protocol-flow-proxy">
+   <title><acronym>PROXY</acronym> Protocol</title>
+
+   <para>
+    When a connection is forwarded by a proxy, the address the server sees is
+    the proxy's, not the real client's.  To preserve the
+    originating address, the proxy can prepend a header conforming to the
+    <ulink url="https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt">PROXY
+    protocol</ulink> designed by <productname>HAProxy</productname>.  The
+    header carries the original source and destination addresses and ports.
+    The header is sent once, before any other data, ahead of the SSLRequest,
+    GSSENCRequest, or StartupMessage.  Both version 1 (text) and version 2
+    (binary) of the protocol are accepted.
+   </para>
+  </sect2>
+
   <sect2 id="protocol-flow-ssl">
    <title><acronym>SSL</acronym> Session Encryption</title>
 
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8f129baec90..e3988344966 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -948,6 +948,9 @@ CREATE VIEW pg_stat_activity AS
             S.client_addr,
             S.client_hostname,
             S.client_port,
+            S.proxy_addr,
+            S.proxy_hostname,
+            S.proxy_port,
             S.backend_start,
             S.xact_start,
             S.query_start,
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 98eb2a8242d..5f8863d0dbf 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -28,7 +28,8 @@ OBJS = \
 	pqcomm.o \
 	pqformat.o \
 	pqmq.o \
-	pqsignal.o
+	pqsignal.o \
+	proxy_protocol.o
 
 ifeq ($(with_ssl),openssl)
 OBJS += be-secure-openssl.o
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index 2af5615e54a..5d9b4119c2f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -1701,6 +1701,18 @@ ident_inet(Port *port)
 			   *la = NULL,
 				hints;
 
+	/*
+	 * Ident is incompatible with the PROXY protocol.  A proxied client
+	 * connected to the proxy, not to the server, so its ident server has no
+	 * record of any connection matching the address pair in the query.
+	 */
+	if (port->proxy_protocol)
+	{
+		ereport(LOG,
+				(errmsg("ident authentication is not supported over connections using the PROXY protocol")));
+		return STATUS_ERROR;
+	}
+
 	/*
 	 * Might look a little weird to first convert it to text and then back to
 	 * sockaddr, but it's protocol independent.
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 8571f652844..30584ee8dfd 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -15,6 +15,7 @@ backend_sources += files(
   'pqformat.c',
   'pqmq.c',
   'pqsignal.c',
+  'proxy_protocol.c',
 )
 
 if ssl.found()
diff --git a/src/backend/libpq/pqcomm.c b/src/backend/libpq/pqcomm.c
index 4a442f22df6..4eeb4eb4d45 100644
--- a/src/backend/libpq/pqcomm.c
+++ b/src/backend/libpq/pqcomm.c
@@ -1094,7 +1094,7 @@ pq_getbytes(void *b, size_t len)
  *		returns 0 if OK, EOF if trouble
  * --------------------------------
  */
-static int
+int
 pq_discardbytes(size_t len)
 {
 	size_t		amount;
diff --git a/src/backend/libpq/proxy_protocol.c b/src/backend/libpq/proxy_protocol.c
new file mode 100644
index 00000000000..6a66c12926b
--- /dev/null
+++ b/src/backend/libpq/proxy_protocol.c
@@ -0,0 +1,517 @@
+/*-------------------------------------------------------------------------
+ *
+ * proxy_protocol.c
+ *	  Functions related to parse the PROXY Protocol (versions 1 and 2).
+ *	  https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/libpq/proxy_protocol.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+
+#include "common/ip.h"
+#include "libpq/ifaddr.h"
+#include "libpq/libpq.h"
+#include "libpq/proxy_protocol.h"
+#include "port/pg_bswap.h"
+#include "utils/guc.h"
+#include "utils/guc_hooks.h"
+#include "utils/memutils.h"
+#include "utils/varlena.h"
+
+/* Raw text of the proxy_networks GUC. */
+char	   *ProxyNetworks = NULL;
+
+/*
+ * Pre-parsed form of proxy_networks, produced by the check hook and
+ * installed by the assign hook.  Stored with guc_malloc() so that the GUC
+ * machinery owns its lifetime.
+ */
+typedef struct ProxyNet
+{
+	struct sockaddr_storage addr;	/* network address */
+	struct sockaddr_storage mask;	/* network mask */
+} ProxyNet;
+
+typedef struct ProxyNets
+{
+	bool		trust_unix;		/* trust Unix-socket peers to send a header */
+	int			nnets;
+	ProxyNet	nets[FLEXIBLE_ARRAY_MEMBER];
+} ProxyNets;
+
+/*
+ * The special proxy_networks token to trust Unix-domain socket peers
+ * to send a PROXY header.
+ */
+#define PROXY_UNIX_TOKEN	"unix"
+
+static ProxyNets *proxy_networks = NULL;
+
+/* The 12-byte PROXY protocol v2 signature. */
+static const uint8 v2_signature[12] =
+"\x0d\x0a\x0d\x0a\x00\x0d\x0a\x51\x55\x49\x54\x0a";
+
+/* Largest possible v1 header line, including the trailing CRLF. */
+#define PROXY_V1_MAX_LEN	107
+
+/* Supported v2 address families. */
+#define PROXY_V2_AF_UNSPEC	0x00
+#define PROXY_V2_TCP4		0x11
+#define PROXY_V2_TCP6		0x21
+
+/*
+ * Length of the leading address block for each supported v2 family.
+ * Any bytes beyond these lengths are TLV vectors and are skipped.
+ */
+#define PROXY_V2_TCP4_ADDRLEN	12	/* 2 x 4-byte addr + 2 x 2-byte port */
+#define PROXY_V2_TCP6_ADDRLEN	36	/* 2 x 16-byte addr + 2 x 2-byte port */
+
+/* Supported v2 commands */
+#define PROXY_V2_CMD_LOCAL 0x0
+#define PROXY_V2_CMD_PROXY 0x1
+
+/*
+ * Parse a single "address" or "address/masklen" network specification into a
+ * ProxyNet.  Returns true on success.  No DNS is performed (numeric host
+ * only), so this is safe to call from a GUC check hook.
+ */
+static bool
+parse_proxy_network(const char *spec, ProxyNet *net)
+{
+	char	   *str = pstrdup(spec);
+	char	   *slash;
+	struct addrinfo hints;
+	struct addrinfo *gai_result = NULL;
+	bool		ok = false;
+
+	memset(net, 0, sizeof(*net));
+
+	slash = strchr(str, '/');
+	if (slash)
+		*slash = '\0';
+
+	MemSet(&hints, 0, sizeof(hints));
+	hints.ai_flags = AI_NUMERICHOST;
+	hints.ai_family = AF_UNSPEC;
+
+	if (pg_getaddrinfo_all(str, NULL, &hints, &gai_result) == 0 &&
+		gai_result != NULL &&
+		gai_result->ai_addrlen <= sizeof(net->addr))
+	{
+		memcpy(&net->addr, gai_result->ai_addr, gai_result->ai_addrlen);
+
+		ok = pg_sockaddr_cidr_mask(&net->mask, slash ? slash + 1 : NULL,
+								   net->addr.ss_family) >= 0;
+	}
+
+	if (gai_result)
+		pg_freeaddrinfo_all(hints.ai_family, gai_result);
+	pfree(str);
+	return ok;
+}
+
+/*
+ * GUC check hook for proxy_networks.  Parses the comma-separated
+ * list of networks into a ProxyNets structure stored in *extra.
+ */
+bool
+check_proxy_networks(char **newval, void **extra, GucSource source)
+{
+	char	   *rawstring;
+	List	   *elemlist;
+	ListCell   *l;
+	int			nnets;
+	int			i;
+	ProxyNets  *result;
+
+	/* Need a modifiable copy of the string */
+	rawstring = pstrdup(*newval);
+
+	if (!SplitGUCList(rawstring, ',', &elemlist))
+	{
+		GUC_check_errdetail("List syntax is invalid.");
+		pfree(rawstring);
+		list_free(elemlist);
+		return false;
+	}
+
+	nnets = list_length(elemlist);
+
+	result = (ProxyNets *) guc_malloc(LOG,
+									  offsetof(ProxyNets, nets) +
+									  nnets * sizeof(ProxyNet));
+	if (result == NULL)
+	{
+		pfree(rawstring);
+		list_free(elemlist);
+		return false;
+	}
+	result->nnets = 0;
+	result->trust_unix = false;
+
+	i = 0;
+	foreach(l, elemlist)
+	{
+		char	   *tok = (char *) lfirst(l);
+
+		if (pg_strcasecmp(tok, PROXY_UNIX_TOKEN) == 0)
+		{
+			result->trust_unix = true;
+			continue;
+		}
+
+		if (!parse_proxy_network(tok, &result->nets[i]))
+		{
+			GUC_check_errdetail("Invalid network specification: \"%s\".", tok);
+			guc_free(result);
+			pfree(rawstring);
+			list_free(elemlist);
+			return false;
+		}
+		i++;
+	}
+	result->nnets = i;
+
+	pfree(rawstring);
+	list_free(elemlist);
+
+	*extra = result;
+	return true;
+}
+
+/*
+ * GUC assign hook for proxy_networks.
+ */
+void
+assign_proxy_networks(const char *newval, void *extra)
+{
+	proxy_networks = (ProxyNets *) extra;
+}
+
+/*
+ * Reports whether PROXY protocol parsing is enabled.
+ */
+bool
+ProxyProtocolEnabled(void)
+{
+	return proxy_networks != NULL &&
+		(proxy_networks->nnets > 0 || proxy_networks->trust_unix);
+}
+
+/*
+ * Reports whether the given peer address falls within one of the trusted
+ * proxy networks.
+ */
+static bool
+proxy_source_trusted(const SockAddr *raddr)
+{
+	int			i;
+
+	if (proxy_networks == NULL)
+		return false;
+
+	if (raddr->addr.ss_family == AF_UNIX)
+		return proxy_networks->trust_unix;
+
+	if (raddr->addr.ss_family != AF_INET &&
+		raddr->addr.ss_family != AF_INET6)
+		return false;
+
+	for (i = 0; i < proxy_networks->nnets; i++)
+	{
+		if (raddr->addr.ss_family == proxy_networks->nets[i].addr.ss_family &&
+			pg_range_sockaddr(&raddr->addr,
+							  &proxy_networks->nets[i].addr,
+							  &proxy_networks->nets[i].mask))
+			return true;
+	}
+	return false;
+}
+
+/*
+ * Reports whether this connection must begin with a PROXY header.
+ */
+bool
+ProxyProtocolRequired(Port *port)
+{
+	return proxy_source_trusted(&port->raddr);
+}
+
+/*
+ * Build an IPv4 or IPv6 SockAddr from a numeric address string and a port
+ * string for PROXY v1.
+ */
+static bool
+make_v1_sockaddr(int family, const char *addr, const char *port, SockAddr *sa)
+{
+	char	   *endptr;
+	long		portnum;
+
+	errno = 0;
+	portnum = strtol(port, &endptr, 10);
+	if (endptr == port || *endptr != '\0' || errno != 0 ||
+		portnum < 0 || portnum > 65535)
+		return false;
+
+	memset(&sa->addr, 0, sizeof(sa->addr));
+
+	if (family == AF_INET)
+	{
+		struct sockaddr_in *sin = (struct sockaddr_in *) &sa->addr;
+
+		if (inet_pton(AF_INET, addr, &sin->sin_addr) != 1)
+			return false;
+		sin->sin_family = AF_INET;
+		sin->sin_port = pg_hton16((uint16) portnum);
+		sa->salen = sizeof(struct sockaddr_in);
+	}
+	else
+	{
+		struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *) &sa->addr;
+
+		if (inet_pton(AF_INET6, addr, &sin6->sin6_addr) != 1)
+			return false;
+		sin6->sin6_family = AF_INET6;
+		sin6->sin6_port = pg_hton16((uint16) portnum);
+		sa->salen = sizeof(struct sockaddr_in6);
+	}
+
+	return true;
+}
+
+/*
+ * Parse a human-readable PROXY header (version 1).
+ */
+static ProxyProtocolResult
+parse_proxy_v1(Port *port, bool *have_client, SockAddr *client)
+{
+	char		line[PROXY_V1_MAX_LEN + 1];
+	int			len = 0;
+	char	   *saveptr;
+	char	   *proto;
+	char	   *src_addr;
+	char	   *dst_addr;
+	char	   *src_port;
+	char	   *dst_port;
+	int			family;
+
+	/* The first four bytes ("PROX") have already been consumed by the caller */
+	line[len++] = 'P';
+	line[len++] = 'R';
+	line[len++] = 'O';
+	line[len++] = 'X';
+
+	for (;;)
+	{
+		int			c = pq_getbyte();
+
+		if (c == EOF)
+			return PROXY_PROTO_ERROR;
+		if (len >= PROXY_V1_MAX_LEN)
+			return PROXY_PROTO_ERROR;	/* no CRLF within the size limit */
+		line[len++] = (char) c;
+		if (c == '\n')
+			break;
+	}
+
+	/* The line must end with CRLF. */
+	if (len < 2 || line[len - 2] != '\r' || line[len - 1] != '\n')
+		return PROXY_PROTO_ERROR;
+	line[len - 2] = '\0';
+
+	/* It must start with the exact "PROXY " token. */
+	if (strncmp(line, "PROXY ", 6) != 0)
+		return PROXY_PROTO_ERROR;
+
+	proto = strtok_r(line + 6, " ", &saveptr);
+	if (proto == NULL)
+		return PROXY_PROTO_ERROR;
+
+	if (strcmp(proto, "UNKNOWN") == 0)
+	{
+		/* Address is unknown.  Keep the real peer address. */
+		*have_client = false;
+		return PROXY_PROTO_DONE;
+	}
+	else if (strcmp(proto, "TCP4") == 0)
+		family = AF_INET;
+	else if (strcmp(proto, "TCP6") == 0)
+		family = AF_INET6;
+	else
+		return PROXY_PROTO_ERROR;
+
+	src_addr = strtok_r(NULL, " ", &saveptr);
+	dst_addr = strtok_r(NULL, " ", &saveptr);
+	src_port = strtok_r(NULL, " ", &saveptr);
+	dst_port = strtok_r(NULL, " ", &saveptr);
+
+	if (src_addr == NULL || dst_addr == NULL ||
+		src_port == NULL || dst_port == NULL)
+		return PROXY_PROTO_ERROR;
+
+	/* No further tokens are allowed. */
+	if (strtok_r(NULL, " ", &saveptr) != NULL)
+		return PROXY_PROTO_ERROR;
+
+	if (!make_v1_sockaddr(family, src_addr, src_port, client))
+		return PROXY_PROTO_ERROR;
+
+	*have_client = true;
+	return PROXY_PROTO_DONE;
+}
+
+/*
+ * Parse a binary PROXY header (version 2).
+ */
+static ProxyProtocolResult
+parse_proxy_v2(Port *port, bool *have_client, SockAddr *client)
+{
+	uint8		sigrest[8];
+	uint8		hdr[4];
+	uint8		ver,
+				cmd,
+				fam;
+	uint16		datalen;
+	uint16		toread;
+	uint8		data[PROXY_V2_TCP6_ADDRLEN];	/* largest supported address
+												 * block */
+
+	/* Read and verify the remaining 8 bytes of the signature. */
+	if (pq_getbytes(sigrest, sizeof(sigrest)) == EOF)
+		return PROXY_PROTO_ERROR;
+
+	if (memcmp(sigrest, v2_signature + 4, sizeof(sigrest)) != 0)
+		return PROXY_PROTO_ERROR;
+
+	/* Read version+command, family+protocol, and the 2-byte length. */
+	if (pq_getbytes(hdr, sizeof(hdr)) == EOF)
+		return PROXY_PROTO_ERROR;
+
+	ver = hdr[0] >> 4;
+	cmd = hdr[0] & 0x0f;
+	fam = hdr[1];
+	datalen = ((uint16) hdr[2] << 8) | hdr[3];
+
+	if (ver != 0x2)
+		return PROXY_PROTO_ERROR;
+
+	/*
+	 * Read the address block.  We only need the leading address bytes.  Any
+	 * trailing TLV vectors are discarded so that the stream stays in sync for
+	 * the genuine startup packet.
+	 */
+	toread = Min(datalen, (uint16) sizeof(data));
+
+	if (toread > 0 && pq_getbytes(data, toread) == EOF)
+		return PROXY_PROTO_ERROR;
+
+	if (datalen > toread && pq_discardbytes(datalen - toread) == EOF)
+		return PROXY_PROTO_ERROR;
+
+	switch (cmd)
+	{
+		case PROXY_V2_CMD_LOCAL:	/* mainly used for health checks */
+			{
+				*have_client = false;
+				return PROXY_PROTO_DONE;
+			}
+		case PROXY_V2_CMD_PROXY:
+			break;
+		default:
+			return PROXY_PROTO_ERROR;
+
+	}
+
+	memset(&client->addr, 0, sizeof(client->addr));
+
+	switch (fam)
+	{
+		case PROXY_V2_TCP4:
+			{
+				struct sockaddr_in *sin = (struct sockaddr_in *) &client->addr;
+
+				/* The address block must be present.  TLVs may follow it. */
+				if (datalen < PROXY_V2_TCP4_ADDRLEN)
+					return PROXY_PROTO_ERROR;
+				sin->sin_family = AF_INET;
+				memcpy(&sin->sin_addr, data, 4);	/* source address */
+				memcpy(&sin->sin_port, data + 8, 2);	/* source port */
+				client->salen = sizeof(struct sockaddr_in);
+				*have_client = true;
+				break;
+			}
+		case PROXY_V2_TCP6:
+			{
+				struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *) &client->addr;
+
+				/* The address block must be present.  TLVs may follow it. */
+				if (datalen < PROXY_V2_TCP6_ADDRLEN)
+					return PROXY_PROTO_ERROR;
+				sin6->sin6_family = AF_INET6;
+				memcpy(&sin6->sin6_addr, data, 16); /* source address */
+				memcpy(&sin6->sin6_port, data + 32, 2); /* source port */
+				client->salen = sizeof(struct sockaddr_in6);
+				*have_client = true;
+				break;
+			}
+		case PROXY_V2_AF_UNSPEC:
+		default:
+			*have_client = false;
+			break;
+	}
+
+	return PROXY_PROTO_DONE;
+}
+
+/*
+ * Try to process a PROXY protocol header.
+ *
+ */
+ProxyProtocolResult
+ProcessProxyProtocol(Port *port, const char firstbytes[4])
+{
+	bool		is_v1;
+	bool		is_v2;
+	bool		have_client = false;
+	SockAddr	client;
+	ProxyProtocolResult result;
+
+	/* Only connections from a trusted proxy are eligible */
+	if (!proxy_source_trusted(&port->raddr))
+		return PROXY_PROTO_NONE;
+
+	is_v1 = (memcmp(firstbytes, "PROX", 4) == 0);
+	is_v2 = (memcmp(firstbytes, v2_signature, 4) == 0);
+
+	if (!is_v1 && !is_v2)
+		return PROXY_PROTO_NONE;
+
+	if (is_v1)
+		result = parse_proxy_v1(port, &have_client, &client);
+	else
+		result = parse_proxy_v2(port, &have_client, &client);
+
+	if (result != PROXY_PROTO_DONE)
+		return result;
+
+	/* Header accepted */
+	port->proxy_protocol = true;
+	if (have_client)
+	{
+		port->proxy_addr = port->raddr;
+		port->raddr = client;
+	}
+
+	pq_endmsgread();
+
+	return PROXY_PROTO_DONE;
+}
diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c
index 25205cee0fa..30c7f5f4874 100644
--- a/src/backend/tcop/backend_startup.c
+++ b/src/backend/tcop/backend_startup.c
@@ -25,6 +25,7 @@
 #include "libpq/libpq-be.h"
 #include "libpq/pqformat.h"
 #include "libpq/pqsignal.h"
+#include "libpq/proxy_protocol.h"
 #include "miscadmin.h"
 #include "postmaster/postmaster.h"
 #include "replication/walsender.h"
@@ -145,6 +146,8 @@ BackendInitialize(ClientSocket *client_sock, CAC_state cac)
 	Port	   *port;
 	char		remote_host[NI_MAXHOST];
 	char		remote_port[NI_MAXSERV];
+	char		proxy_host[NI_MAXHOST];
+	char		proxy_port[NI_MAXSERV];
 	StringInfoData ps_data;
 	MemoryContext oldcontext;
 
@@ -182,6 +185,8 @@ BackendInitialize(ClientSocket *client_sock, CAC_state cac)
 	/* set these to empty in case they are needed before we set them up */
 	port->remote_host = "";
 	port->remote_port = "";
+	port->proxy_host = "";
+	port->proxy_port = "";
 
 	/*
 	 * We arrange to do _exit(1) if we receive SIGTERM or timeout while trying
@@ -294,6 +299,61 @@ BackendInitialize(ClientSocket *client_sock, CAC_state cac)
 	if (status == STATUS_OK)
 		status = ProcessStartupPacket(port);
 
+	/*
+	 * If a PROXY protocol header replaced port->raddr with the real client
+	 * address (marked by a non-zero proxy_addr.salen), recompute the cached
+	 * host/port strings so that log output reflects the originating client.
+	 * A LOCAL command, a v1 UNKNOWN, or an unsupported family leaves both
+	 * addresses untouched and the proxy host/port empty.
+	 */
+	if (status == STATUS_OK && port->proxy_protocol &&
+		port->proxy_addr.salen > 0)
+	{
+		remote_host[0] = '\0';
+		remote_port[0] = '\0';
+		if ((ret = pg_getnameinfo_all(&port->raddr.addr, port->raddr.salen,
+									  remote_host, sizeof(remote_host),
+									  remote_port, sizeof(remote_port),
+									  (log_hostname ? 0 : NI_NUMERICHOST) | NI_NUMERICSERV)) != 0)
+			ereport(WARNING,
+					(errmsg_internal("pg_getnameinfo_all() failed: %s",
+									 gai_strerror(ret))));
+
+		port->remote_host = MemoryContextStrdup(TopMemoryContext, remote_host);
+		port->remote_port = MemoryContextStrdup(TopMemoryContext, remote_port);
+
+		if (log_hostname &&
+			ret == 0 &&
+			strspn(remote_host, "0123456789.") < strlen(remote_host) &&
+			strspn(remote_host, "0123456789ABCDEFabcdef:") < strlen(remote_host))
+			port->remote_hostname = MemoryContextStrdup(TopMemoryContext, remote_host);
+
+		/*
+		 * Also resolve the proxy's own address so that it can be reported
+		 * separately via the %H and %R log_line_prefix escapes.  As with the
+		 * client address above, log_hostname controls whether a reverse DNS
+		 * lookup is attempted.
+		 */
+		proxy_host[0] = '\0';
+		proxy_port[0] = '\0';
+		if ((ret = pg_getnameinfo_all(&port->proxy_addr.addr, port->proxy_addr.salen,
+									  proxy_host, sizeof(proxy_host),
+									  proxy_port, sizeof(proxy_port),
+									  (log_hostname ? 0 : NI_NUMERICHOST) | NI_NUMERICSERV)) != 0)
+			ereport(WARNING,
+					(errmsg_internal("pg_getnameinfo_all() failed: %s",
+									 gai_strerror(ret))));
+
+		port->proxy_host = MemoryContextStrdup(TopMemoryContext, proxy_host);
+		port->proxy_port = MemoryContextStrdup(TopMemoryContext, proxy_port);
+
+		if (log_hostname &&
+			ret == 0 &&
+			strspn(proxy_host, "0123456789.") < strlen(proxy_host) &&
+			strspn(proxy_host, "0123456789ABCDEFabcdef:") < strlen(proxy_host))
+			port->proxy_hostname = MemoryContextStrdup(TopMemoryContext, proxy_host);
+	}
+
 	/*
 	 * If we're going to reject the connection due to database state, say so
 	 * now instead of wasting cycles on an authentication exchange. (This also
@@ -486,11 +546,13 @@ static int
 ProcessStartupPacket(Port *port)
 {
 	int32		len;
+	char		firstbytes[4];	/* raw first 4 bytes, for PROXY detection */
 	char	   *buf = NULL;
 	ProtocolVersion proto;
 	MemoryContext oldcontext;
 	bool		gss_done;
 	bool		ssl_done;
+	bool		proxy_done;
 
 	/*
 	 * Set ssl_done and/or gss_done when negotiation of an encrypted layer
@@ -502,6 +564,7 @@ ProcessStartupPacket(Port *port)
 	 */
 	gss_done = false;
 	ssl_done = false;
+	proxy_done = false;
 
 retry:
 	pq_startmsgread();
@@ -537,15 +600,62 @@ retry:
 		goto fail;
 	}
 
+	/* Preserve the raw, network-order length bytes for PROXY detection. */
+	memcpy(firstbytes, &len, 4);
+
 	len = pg_ntoh32(len);
 	len -= 4;
 
 	if (len < (int32) sizeof(ProtocolVersion) ||
 		len > MAX_STARTUP_PACKET_LENGTH)
+	{
+		/*
+		 * The length looks invalid.  Before rejecting the connection, check
+		 * whether these bytes are actually the start of a PROXY protocol
+		 * header sent by a trusted proxy (see proxy_networks).  A genuine
+		 * startup packet always begins with two zero bytes, so this test
+		 * never misfires on real client traffic.  If a header is found and
+		 * accepted, port->raddr is replaced with the real client address and
+		 * we loop back to read the genuine startup packet.
+		 */
+		if (!ssl_done && !gss_done && !proxy_done && ProxyProtocolEnabled())
+		{
+			switch (ProcessProxyProtocol(port, firstbytes))
+			{
+				case PROXY_PROTO_DONE:
+					proxy_done = true;
+
+					/*
+					 * A direct SSL negotiation happens before PROXY header
+					 * detection.  Run it now, after consuming the header.
+					 */
+					if (ProcessSSLStartup(port) != STATUS_OK)
+						goto fail;
+					goto retry;
+				case PROXY_PROTO_ERROR:
+				case PROXY_PROTO_NONE:
+					break;
+			}
+		}
+
+		ereport(COMMERROR,
+				(errcode(ERRCODE_PROTOCOL_VIOLATION),
+				 errmsg("incomplete startup packet")));
+		goto fail;
+	}
+
+	/*
+	 * We have a plausible startup, cancel, or negotiation packet.  If it
+	 * arrived from a trusted proxy network but was not preceded by a PROXY
+	 * header (proxy_done is still unset), reject it.  Such peers must
+	 * announce the real client via the PROXY protocol, which has to come
+	 * first, ahead of any SSL or GSS negotiation.
+	 */
+	if (!proxy_done && ProxyProtocolRequired(port))
 	{
 		ereport(COMMERROR,
 				(errcode(ERRCODE_PROTOCOL_VIOLATION),
-				 errmsg("invalid length of startup packet")));
+				 errmsg("connection from a trusted proxy network must use the PROXY protocol")));
 		goto fail;
 	}
 
diff --git a/src/backend/utils/activity/backend_status.c b/src/backend/utils/activity/backend_status.c
index d685fc5cd87..be51f2d52a5 100644
--- a/src/backend/utils/activity/backend_status.c
+++ b/src/backend/utils/activity/backend_status.c
@@ -52,6 +52,7 @@ PgBackendStatus *MyBEEntry = NULL;
 static PgBackendStatus *BackendStatusArray = NULL;
 static char *BackendAppnameBuffer = NULL;
 static char *BackendClientHostnameBuffer = NULL;
+static char *BackendProxyHostnameBuffer = NULL;
 static char *BackendActivityBuffer = NULL;
 static Size BackendActivityBufferSize = 0;
 #ifdef USE_SSL
@@ -106,6 +107,11 @@ BackendStatusShmemRequest(void *arg)
 					   .ptr = (void **) &BackendClientHostnameBuffer,
 		);
 
+	ShmemRequestStruct(.name = "Backend Proxy Host Name Buffer",
+					   .size = mul_size(NAMEDATALEN, NumBackendStatSlots),
+					   .ptr = (void **) &BackendProxyHostnameBuffer,
+		);
+
 	BackendActivityBufferSize = mul_size(pgstat_track_activity_query_size,
 										 NumBackendStatSlots);
 	ShmemRequestStruct(.name = "Backend Activity Buffer",
@@ -154,6 +160,14 @@ BackendStatusShmemInit(void *arg)
 		buffer += NAMEDATALEN;
 	}
 
+	/* Initialize st_proxyhostname pointers. */
+	buffer = BackendProxyHostnameBuffer;
+	for (i = 0; i < NumBackendStatSlots; i++)
+	{
+		BackendStatusArray[i].st_proxyhostname = buffer;
+		buffer += NAMEDATALEN;
+	}
+
 	/* Initialize st_activity pointers. */
 	buffer = BackendActivityBuffer;
 	for (i = 0; i < NumBackendStatSlots; i++)
@@ -279,6 +293,12 @@ pgstat_bestart_initial(void)
 	else
 		MemSet(&lbeentry.st_clientaddr, 0, sizeof(lbeentry.st_clientaddr));
 
+	if (MyProcPort && MyProcPort->proxy_protocol)
+		memcpy(&lbeentry.st_proxyaddr, &MyProcPort->proxy_addr,
+			   sizeof(lbeentry.st_proxyaddr));
+	else
+		MemSet(&lbeentry.st_proxyaddr, 0, sizeof(lbeentry.st_proxyaddr));
+
 	lbeentry.st_ssl = false;
 	lbeentry.st_gss = false;
 
@@ -319,10 +339,18 @@ pgstat_bestart_initial(void)
 				NAMEDATALEN);
 	else
 		lbeentry.st_clienthostname[0] = '\0';
+
+	if (MyProcPort && MyProcPort->proxy_hostname)
+		strlcpy(lbeentry.st_proxyhostname, MyProcPort->proxy_hostname,
+				NAMEDATALEN);
+	else
+		lbeentry.st_proxyhostname[0] = '\0';
+
 	lbeentry.st_activity_raw[0] = '\0';
 	/* Also make sure the last byte in each string area is always 0 */
 	lbeentry.st_appname[NAMEDATALEN - 1] = '\0';
 	lbeentry.st_clienthostname[NAMEDATALEN - 1] = '\0';
+	lbeentry.st_proxyhostname[NAMEDATALEN - 1] = '\0';
 	lbeentry.st_activity_raw[pgstat_track_activity_query_size - 1] = '\0';
 
 	/* These structs can just start from zeroes each time */
@@ -789,6 +817,7 @@ pgstat_read_current_status(void)
 	LocalPgBackendStatus *localentry;
 	char	   *localappname,
 			   *localclienthostname,
+			   *localproxyhostname,
 			   *localactivity;
 #ifdef USE_SSL
 	PgBackendSSLStatus *localsslstatus;
@@ -820,6 +849,9 @@ pgstat_read_current_status(void)
 	localclienthostname = (char *)
 		MemoryContextAlloc(backendStatusSnapContext,
 						   NAMEDATALEN * NumBackendStatSlots);
+	localproxyhostname = (char *)
+		MemoryContextAlloc(backendStatusSnapContext,
+						   NAMEDATALEN * NumBackendStatSlots);
 	localactivity = (char *)
 		MemoryContextAllocHuge(backendStatusSnapContext,
 							   (Size) pgstat_track_activity_query_size *
@@ -873,6 +905,8 @@ pgstat_read_current_status(void)
 				localentry->backendStatus.st_appname = localappname;
 				strcpy(localclienthostname, beentry->st_clienthostname);
 				localentry->backendStatus.st_clienthostname = localclienthostname;
+				strcpy(localproxyhostname, beentry->st_proxyhostname);
+				localentry->backendStatus.st_proxyhostname = localproxyhostname;
 				strcpy(localactivity, beentry->st_activity_raw);
 				localentry->backendStatus.st_activity_raw = localactivity;
 #ifdef USE_SSL
@@ -920,6 +954,7 @@ pgstat_read_current_status(void)
 			localentry++;
 			localappname += NAMEDATALEN;
 			localclienthostname += NAMEDATALEN;
+			localproxyhostname += NAMEDATALEN;
 			localactivity += pgstat_track_activity_query_size;
 #ifdef USE_SSL
 			localsslstatus++;
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 6f9c9c72de5..626ee377bfd 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -355,7 +355,7 @@ pg_stat_get_progress_info(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_activity(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_ACTIVITY_COLS	31
+#define PG_STAT_GET_ACTIVITY_COLS	34
 	int			num_backends = pgstat_fetch_stat_numbackends();
 	int			curr_backend;
 	int			pid = PG_ARGISNULL(0) ? -1 : PG_GETARG_INT32(0);
@@ -669,6 +669,60 @@ pg_stat_get_activity(PG_FUNCTION_ARGS)
 				nulls[30] = true;
 			else
 				values[30] = Int64GetDatum(beentry->st_query_id);
+
+			/* Proxy information */
+			if (pg_memory_is_all_zeros(&beentry->st_proxyaddr,
+									   sizeof(beentry->st_proxyaddr)))
+			{
+				nulls[31] = true;
+				nulls[32] = true;
+				nulls[33] = true;
+			}
+			else if (beentry->st_proxyaddr.addr.ss_family == AF_INET ||
+					 beentry->st_proxyaddr.addr.ss_family == AF_INET6)
+			{
+				char		proxy_host[NI_MAXHOST];
+				char		proxy_port[NI_MAXSERV];
+				int			ret;
+
+				proxy_host[0] = '\0';
+				proxy_port[0] = '\0';
+				ret = pg_getnameinfo_all(&beentry->st_proxyaddr.addr,
+										 beentry->st_proxyaddr.salen,
+										 proxy_host, sizeof(proxy_host),
+										 proxy_port, sizeof(proxy_port),
+										 NI_NUMERICHOST | NI_NUMERICSERV);
+				if (ret == 0)
+				{
+					clean_ipv6_addr(beentry->st_proxyaddr.addr.ss_family, proxy_host);
+					values[31] = DirectFunctionCall1(inet_in,
+													 CStringGetDatum(proxy_host));
+					if (beentry->st_proxyhostname &&
+						beentry->st_proxyhostname[0])
+						values[32] = CStringGetTextDatum(beentry->st_proxyhostname);
+					else
+						nulls[32] = true;
+					values[33] = Int32GetDatum(atoi(proxy_port));
+				}
+				else
+				{
+					nulls[31] = true;
+					nulls[32] = true;
+					nulls[33] = true;
+				}
+			}
+			else if (beentry->st_proxyaddr.addr.ss_family == AF_UNIX)
+			{
+				nulls[31] = true;
+				nulls[32] = true;
+				values[33] = Int32GetDatum(-1);
+			}
+			else
+			{
+				nulls[31] = true;
+				nulls[32] = true;
+				nulls[33] = true;
+			}
 		}
 		else
 		{
@@ -698,6 +752,9 @@ pg_stat_get_activity(PG_FUNCTION_ARGS)
 			nulls[28] = true;
 			nulls[29] = true;
 			nulls[30] = true;
+			nulls[31] = true;
+			nulls[32] = true;
+			nulls[33] = true;
 		}
 
 		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
diff --git a/src/backend/utils/error/csvlog.c b/src/backend/utils/error/csvlog.c
index 2b2b9484bdc..988fb4973e0 100644
--- a/src/backend/utils/error/csvlog.c
+++ b/src/backend/utils/error/csvlog.c
@@ -249,7 +249,20 @@ write_csvlog(ErrorData *edata)
 
 	/* query id */
 	appendStringInfo(&buf, "%" PRId64, pgstat_get_my_query_id());
+	appendStringInfoChar(&buf, ',');
 
+	/* Proxy host and port */
+	if (MyProcPort && MyProcPort->proxy_host && MyProcPort->proxy_host[0] != '\0')
+	{
+		appendStringInfoChar(&buf, '"');
+		appendStringInfoString(&buf, MyProcPort->proxy_host);
+		if (MyProcPort->proxy_port && MyProcPort->proxy_port[0] != '\0')
+		{
+			appendStringInfoChar(&buf, ':');
+			appendStringInfoString(&buf, MyProcPort->proxy_port);
+		}
+		appendStringInfoChar(&buf, '"');
+	}
 	appendStringInfoChar(&buf, '\n');
 
 	/* If in the syslogger process, try to write messages direct to file */
diff --git a/src/backend/utils/error/elog.c b/src/backend/utils/error/elog.c
index 50c53b571a0..1573fb02c77 100644
--- a/src/backend/utils/error/elog.c
+++ b/src/backend/utils/error/elog.c
@@ -3595,6 +3595,55 @@ log_status_format(StringInfo buf, const char *format, ErrorData *edata)
 					appendStringInfoSpaces(buf,
 										   padding > 0 ? padding : -padding);
 				break;
+			case 'H':
+				if (MyProcPort && MyProcPort->proxy_host)
+				{
+					if (padding != 0)
+						appendStringInfo(buf, "%*s", padding, MyProcPort->proxy_host);
+					else
+						appendStringInfoString(buf, MyProcPort->proxy_host);
+				}
+				else if (padding != 0)
+					appendStringInfoSpaces(buf,
+										   padding > 0 ? padding : -padding);
+				break;
+			case 'R':
+				if (MyProcPort && MyProcPort->proxy_host)
+				{
+					if (padding != 0)
+					{
+						if (MyProcPort->proxy_port && MyProcPort->proxy_port[0] != '\0')
+						{
+							/*
+							 * As with remote port, the port number may be appended
+							 * appended onto the end, so build a single string
+							 * containing the proxy_host and optionally the
+							 * proxy_port (if set) so we can properly align
+							 * it.
+							 */
+							char	   *hostport;
+
+							hostport = psprintf("%s(%s)", MyProcPort->proxy_host, MyProcPort->proxy_port);
+							appendStringInfo(buf, "%*s", padding, hostport);
+							pfree(hostport);
+						}
+						else
+							appendStringInfo(buf, "%*s", padding, MyProcPort->proxy_host);
+					}
+					else
+					{
+						/* padding is 0, so we don't need a temp buffer */
+						appendStringInfoString(buf, MyProcPort->proxy_host);
+						if (MyProcPort->proxy_port &&
+							MyProcPort->proxy_port[0] != '\0')
+							appendStringInfo(buf, "(%s)",
+											 MyProcPort->proxy_port);
+					}
+				}
+				else if (padding != 0)
+					appendStringInfoSpaces(buf,
+										   padding > 0 ? padding : -padding);
+				break;
 			case 'q':
 				/* in postmaster and friends, stop if %q is seen */
 				/* in a backend, just ignore */
diff --git a/src/backend/utils/error/jsonlog.c b/src/backend/utils/error/jsonlog.c
index e5ba22794d2..cea1e42e6e9 100644
--- a/src/backend/utils/error/jsonlog.c
+++ b/src/backend/utils/error/jsonlog.c
@@ -167,6 +167,14 @@ write_jsonlog(ErrorData *edata)
 			appendJSONKeyValue(&buf, "remote_port", MyProcPort->remote_port, false);
 	}
 
+	/* Proxy host and port */
+	if (MyProcPort && MyProcPort->proxy_host && MyProcPort->proxy_host[0] != '\0')
+	{
+		appendJSONKeyValue(&buf, "proxy_host", MyProcPort->proxy_host, true);
+		if (MyProcPort->proxy_port && MyProcPort->proxy_port[0] != '\0')
+			appendJSONKeyValue(&buf, "proxy_port", MyProcPort->proxy_port, false);
+	}
+
 	/* Session id */
 	appendJSONKeyValueFmt(&buf, "session_id", true, "%" PRIx64 ".%x",
 						  MyStartTime, MyProcPid);
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index afaa058b046..d99b1542945 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -2404,6 +2404,16 @@
   check_hook => 'check_primary_slot_name',
 },
 
+{ name => 'proxy_networks', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SETTINGS',
+  short_desc => 'Sets the networks from which PROXY protocol headers are accepted.',
+  long_desc => 'A comma-separated list of CIDR networks or the special token "unix" for Unix-domain socket. A connection whose peer address matches one of these networks must prepend a PROXY protocol header to declare the real client address. An empty string disables PROXY protocol support.',
+  flags => 'GUC_LIST_INPUT',
+  variable => 'ProxyNetworks',
+  boot_val => '""',
+  check_hook => 'check_proxy_networks',
+  assign_hook => 'assign_proxy_networks',
+},
+
 { name => 'quote_all_identifiers', type => 'bool', context => 'PGC_USERSET', group => 'COMPAT_OPTIONS_PREVIOUS',
   short_desc => 'When generating SQL fragments, quote all identifiers.',
   variable => 'quote_all_identifiers',
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 290ccbc543e..f2ed4246882 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -54,6 +54,7 @@
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
 #include "libpq/oauth.h"
+#include "libpq/proxy_protocol.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf9..337cc6861d3 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -64,6 +64,9 @@
                                         # defaults to 'localhost'; use '*' for all
                                         # (change requires restart)
 #port = 5432                            # (change requires restart)
+#proxy_networks = ''                    # comma-separated list of trusted proxy CIDRs
+                                        # or "unix" for Unix-socket peers sending the
+                                        # PROXY protocol header
 #max_connections = 100                  # (change requires restart)
 #reserved_connections = 0               # (change requires restart)
 #superuser_reserved_connections = 3     # (change requires restart)
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index be157a5fbe9..3944f518371 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5690,9 +5690,9 @@
   proname => 'pg_stat_get_activity', prorows => '100', proisstrict => 'f',
   proretset => 't', provolatile => 's', proparallel => 'r',
   prorettype => 'record', proargtypes => 'int4',
-  proallargtypes => '{int4,oid,int4,oid,text,text,text,text,text,timestamptz,timestamptz,timestamptz,timestamptz,inet,text,int4,xid,xid,text,bool,text,text,int4,text,numeric,text,bool,text,bool,bool,int4,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{pid,datid,pid,usesysid,application_name,state,query,wait_event_type,wait_event,xact_start,query_start,backend_start,state_change,client_addr,client_hostname,client_port,backend_xid,backend_xmin,backend_type,ssl,sslversion,sslcipher,sslbits,ssl_client_dn,ssl_client_serial,ssl_issuer_dn,gss_auth,gss_princ,gss_enc,gss_delegation,leader_pid,query_id}',
+  proallargtypes => '{int4,oid,int4,oid,text,text,text,text,text,timestamptz,timestamptz,timestamptz,timestamptz,inet,text,int4,xid,xid,text,bool,text,text,int4,text,numeric,text,bool,text,bool,bool,int4,int8,inet,text,int4}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{pid,datid,pid,usesysid,application_name,state,query,wait_event_type,wait_event,xact_start,query_start,backend_start,state_change,client_addr,client_hostname,client_port,backend_xid,backend_xmin,backend_type,ssl,sslversion,sslcipher,sslbits,ssl_client_dn,ssl_client_serial,ssl_issuer_dn,gss_auth,gss_princ,gss_enc,gss_delegation,leader_pid,query_id,proxy_addr,proxy_hostname,proxy_port}',
   prosrc => 'pg_stat_get_activity' },
 { oid => '6318', descr => 'describe wait events',
   proname => 'pg_get_wait_events', procost => '10', prorows => '250',
diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h
index 921b2daa4ff..60454df4b79 100644
--- a/src/include/libpq/libpq-be.h
+++ b/src/include/libpq/libpq-be.h
@@ -138,6 +138,12 @@ typedef struct Port
 	int			remote_hostname_resolv; /* see above */
 	int			remote_hostname_errcode;	/* see above */
 	char	   *remote_port;	/* text rep of remote port */
+	bool		proxy_protocol; /* whether this connection used the PROXY
+								   protocol */
+	SockAddr	proxy_addr;		/* real TCP peer (the proxy itself) */
+	char	   *proxy_host;		/* name (or ip addr) of the proxy */
+	char	   *proxy_hostname; /* name (not ip addr) of the proxy */
+	char	   *proxy_port;		/* text rep of the proxy's port */
 
 	/* local_host is filled only if needed (see log_status_format) */
 	char		local_host[64]; /* ip addr of local socket for client conn */
diff --git a/src/include/libpq/libpq.h b/src/include/libpq/libpq.h
index d15073a0a93..0ff4fc64584 100644
--- a/src/include/libpq/libpq.h
+++ b/src/include/libpq/libpq.h
@@ -75,6 +75,7 @@ extern void TouchSocketFiles(void);
 extern void RemoveSocketFiles(void);
 extern Port *pq_init(ClientSocket *client_sock);
 extern int	pq_getbytes(void *b, size_t len);
+extern int	pq_discardbytes(size_t len);
 extern void pq_startmsgread(void);
 extern void pq_endmsgread(void);
 extern bool pq_is_reading_msg(void);
diff --git a/src/include/libpq/proxy_protocol.h b/src/include/libpq/proxy_protocol.h
new file mode 100644
index 00000000000..06e99bb570a
--- /dev/null
+++ b/src/include/libpq/proxy_protocol.h
@@ -0,0 +1,38 @@
+/*-------------------------------------------------------------------------
+ *
+ * proxy_protocol.h
+ *    Interface of libpq/proxy_protocol.c
+ *
+ * src/include/libpq/proxy_protocol.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PROXY_PROTOCOL_H
+#define PROXY_PROTOCOL_H
+
+#include "libpq/libpq-be.h"
+
+/* Comma-separated list of networks allowed to send PROXY headers. */
+extern PGDLLIMPORT char *ProxyNetworks;
+
+/* Types of PROXY protocol parsing results. */
+typedef enum ProxyProtocolResult
+{
+	PROXY_PROTO_NONE,			/* not a PROXY header (or not from a trusted
+								 * source).  Caller should handle the data
+								 * normally */
+	PROXY_PROTO_DONE,			/* a valid PROXY header was consumed, port has
+								 * been updated and the real startup packet
+								 * should now be read */
+	PROXY_PROTO_ERROR,			/* a PROXY header from a trusted source was
+								 * malformed, the connection must be closed */
+} ProxyProtocolResult;
+
+extern bool ProxyProtocolEnabled(void);
+
+extern bool ProxyProtocolRequired(Port *port);
+
+extern ProxyProtocolResult ProcessProxyProtocol(Port *port,
+												const char firstbytes[4]);
+
+#endif
diff --git a/src/include/utils/backend_status.h b/src/include/utils/backend_status.h
index a334e096e4a..7b71e0d5440 100644
--- a/src/include/utils/backend_status.h
+++ b/src/include/utils/backend_status.h
@@ -133,6 +133,10 @@ typedef struct PgBackendStatus
 	SockAddr	st_clientaddr;
 	char	   *st_clienthostname;	/* MUST be null-terminated */
 
+	/* Proxy information */
+	SockAddr	st_proxyaddr;
+	char	   *st_proxyhostname;	/* MUST be null-terminated */
+
 	/* Information about SSL connection */
 	bool		st_ssl;
 	PgBackendSSLStatus *st_sslstatus;
diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h
index 307f4fbaefe..b75a28fdc28 100644
--- a/src/include/utils/guc_hooks.h
+++ b/src/include/utils/guc_hooks.h
@@ -95,6 +95,9 @@ extern bool check_multixact_offset_buffers(int *newval, void **extra,
 extern bool check_notify_buffers(int *newval, void **extra, GucSource source);
 extern bool check_primary_slot_name(char **newval, void **extra,
 									GucSource source);
+extern bool check_proxy_networks(char **newval, void **extra,
+								 GucSource source);
+extern void assign_proxy_networks(const char *newval, void *extra);
 extern bool check_random_seed(double *newval, void **extra, GucSource source);
 extern void assign_random_seed(double newval, void *extra);
 extern const char *show_random_seed(void);
diff --git a/src/test/Makefile b/src/test/Makefile
index 3eb0a06abb4..2eb65073bc6 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -18,6 +18,7 @@ SUBDIRS = \
 	modules \
 	perl \
 	postmaster \
+	protocol \
 	recovery \
 	regress \
 	subscription
diff --git a/src/test/meson.build b/src/test/meson.build
index cd45cbf57fb..6844952d894 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -5,6 +5,7 @@ subdir('isolation')
 
 subdir('authentication')
 subdir('postmaster')
+subdir('protocol')
 subdir('recovery')
 subdir('subscription')
 subdir('modules')
diff --git a/src/test/protocol/.gitignore b/src/test/protocol/.gitignore
new file mode 100644
index 00000000000..871e943d50e
--- /dev/null
+++ b/src/test/protocol/.gitignore
@@ -0,0 +1,2 @@
+# Generated by test suite
+/tmp_check/
diff --git a/src/test/protocol/Makefile b/src/test/protocol/Makefile
new file mode 100644
index 00000000000..4228cf6d2a3
--- /dev/null
+++ b/src/test/protocol/Makefile
@@ -0,0 +1,25 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/protocol
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/protocol/Makefile
+#
+#-------------------------------------------------------------------------
+
+subdir = src/test/protocol
+top_builddir = ../../..
+include $(top_builddir)/src/Makefile.global
+
+export OPENSSL with_ssl
+
+check:
+	$(prove_check)
+
+installcheck:
+	$(prove_installcheck)
+
+clean distclean:
+	rm -rf tmp_check
diff --git a/src/test/protocol/README b/src/test/protocol/README
new file mode 100644
index 00000000000..6eb3d5a000e
--- /dev/null
+++ b/src/test/protocol/README
@@ -0,0 +1,28 @@
+src/test/protocol/README
+
+Regression tests for wire protocol features
+===========================================
+
+This directory contains a test suite for features of the frontend/backend
+wire protocol that are exercised by driving raw connections, such as the
+PROXY protocol.
+
+
+Running the tests
+=================
+
+NOTE: You must have given the --enable-tap-tests argument to configure.
+
+Run
+    make check
+or
+    make installcheck
+You can use "make installcheck" if you previously did "make install".
+In that case, the code in the installation tree is tested.  With
+"make check", a temporary installation tree is built from the current
+sources and then tested.
+
+Either way, this test initializes, starts, and stops a test Postgres
+cluster.
+
+See src/test/perl/README for more info about running these tests.
diff --git a/src/test/protocol/meson.build b/src/test/protocol/meson.build
new file mode 100644
index 00000000000..27975e58743
--- /dev/null
+++ b/src/test/protocol/meson.build
@@ -0,0 +1,17 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+tests += {
+  'name': 'protocol',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'env': {
+      'with_ssl': ssl_library,
+      'OPENSSL': openssl.found() ? openssl.full_path() : '',
+    },
+    'tests': [
+      't/001_proxy_protocol.pl',
+      't/002_proxy_protocol_ssl.pl',
+    ],
+  },
+}
diff --git a/src/test/protocol/t/001_proxy_protocol.pl b/src/test/protocol/t/001_proxy_protocol.pl
new file mode 100644
index 00000000000..7140e98e15d
--- /dev/null
+++ b/src/test/protocol/t/001_proxy_protocol.pl
@@ -0,0 +1,515 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Tests for the PROXY protocol.
+
+use strict;
+use warnings FATAL => 'all';
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use ProxyProtocol;
+
+my $host = '127.0.0.1';
+
+# Network ranges trusted as proxies
+my $loopback_net = "$host/32";
+my $loopback_v6_net = '::1/128';
+my $client_net = '192.0.2.0/24';       # TEST-NET-1, v1 TCP4 client
+my $client_v4_net = '198.51.100.0/24'; # TEST-NET-2, v2 TCP4 client
+my $client_v6_net = '2001:db8::/20';   # documentation range, TCP6 clients
+my $ident_net = '203.0.113.0/24';      # TEST-NET-3, mapped to ident
+my $untrusted_net = '10.0.0.0/8';      # a network the loopback peer is not in
+
+# Client (source) addresses carried in the PROXY headers.
+my $v1_client_v4 = '192.0.2.1';        # in $client_net
+my $v2_client_v4 = '198.51.100.5';     # in $client_v4_net
+my $v1_client_v6 = '2001:db8::1';
+my $v2_client_v6 = '2001:db8::dead';
+my $ident_client = '203.0.113.20';     # in $ident_net
+my $unused_dest_v4 = '198.51.100.9';
+my $unused_dest_v6 = '2001:db8::2';
+
+# Source ports declared in the PROXY headers
+my $v1_client_port = 56324;
+my $v2_client_port = 40000;
+my $unused_dest_port = 5432;
+
+my $node = PostgreSQL::Test::Cluster->new('proxy_protocol');
+$node->init;
+$node->append_conf('postgresql.conf',
+		"listen_addresses = '$host'\n"
+	  . "proxy_networks = '$loopback_net'\n"
+	  . "log_line_prefix = 'PXLOG h=%h H=%H r=%r R=%R '\n"
+	  . "log_statement = 'all'\n");
+$node->append_conf('pg_hba.conf',
+		"host all all $loopback_net trust\n"
+	  . "host all all $loopback_v6_net trust\n"
+	  . "host all all $client_net trust\n"
+	  . "host all all $client_v4_net trust\n"
+	  . "host all all $client_v6_net trust\n"
+	  . "host all all $ident_net ident\n");
+$node->start;
+
+my $port = $node->port;
+my $user = $node->safe_psql('postgres', 'SELECT current_user');
+
+set_connection(host => $host, port => $port, user => $user);
+
+my $unix_connect = sub { $node->raw_connect };
+
+# A query to check the client address.
+my $CLIENT_ADDR = 'SELECT host(inet_client_addr())';
+
+
+# ----------------------------------------------------------------------------
+# Protocol validation.
+# ----------------------------------------------------------------------------
+# Trusted peers over TCP.
+my $r = proxy_query(
+	proxy_v1(
+		4, $v1_client_v4, $unused_dest_v4,
+		$v1_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok($r->{ok}, 'v1 TCP4 header: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $v1_client_v4, 'v1 TCP4 header: client address substituted');
+
+$r = proxy_query(
+	proxy_v1(
+		6, $v1_client_v6, $unused_dest_v6,
+		$v1_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok($r->{ok}, 'v1 TCP6 header: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $v1_client_v6, 'v1 TCP6 header: client address substituted');
+
+$r = proxy_query(
+	proxy_v2(
+		4, $v2_client_v4, $unused_dest_v4,
+		$v2_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok($r->{ok}, 'v2 TCP4 header: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $v2_client_v4, 'v2 TCP4 header: client address substituted');
+
+$r = proxy_query(
+	proxy_v2(
+		6, $v2_client_v6, $unused_dest_v6,
+		$v2_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok($r->{ok}, 'v2 TCP6 header: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $v2_client_v6, 'v2 TCP6 header: client address substituted');
+
+# TLV vectors after the address block are accepted and ignored.
+my $tlv = "\x04" . pack('n', 3) . "abc"    # PP2_TYPE_NOOP, 3 bytes
+  . "\xee"
+  . pack('n', 600)
+  . ("\x00" x 600);                        # opaque type, spans many reads
+$r = proxy_query(
+	proxy_v2(
+		4, $v2_client_v4, $unused_dest_v4,
+		$v2_client_port, $unused_dest_port, $tlv),
+	$CLIENT_ADDR);
+ok($r->{ok}, 'v2 header with trailing TLVs: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $v2_client_v4,
+	'v2 header with trailing TLVs: TLVs ignored, client address substituted'
+);
+
+# v1 UNKNOWN keeps the real peer address.
+$r = proxy_query(proxy_v1_unknown(), $CLIENT_ADDR);
+ok($r->{ok}, 'v1 UNKNOWN header: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $host, 'v1 UNKNOWN header: real peer address kept');
+
+# v2 LOCAL keeps the real peer address.
+$r = proxy_query(proxy_v2_local(), $CLIENT_ADDR);
+ok($r->{ok}, 'v2 LOCAL header: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $host, 'v2 LOCAL header: real peer address kept');
+
+# Unix socket.
+SKIP:
+{
+	skip "Unix-domain sockets not in use on this platform", 6
+	  unless $PostgreSQL::Test::Utils::use_unix_sockets
+	  && $node->raw_connect_works;
+
+	$node->append_conf('postgresql.conf',
+		"proxy_networks = 'unix, $loopback_net'\n");
+	$node->reload;
+
+	# v1 TCP address from an Unix peer must pass
+	$r = proxy_query(
+		proxy_v1(
+			4, $v1_client_v4, $unused_dest_v4,
+			$v1_client_port, $unused_dest_port),
+		$CLIENT_ADDR,
+		$unix_connect);
+	ok($r->{ok}, 'unix: v1 header over a Unix socket succeeds')
+	  or diag("error: $r->{error}");
+	is($r->{value}, $v1_client_v4,
+		'unix: v1 header substitutes the parsed TCP client over a Unix socket'
+	);
+
+	# v2 TCP address from an Unix peer must pass
+	$r = proxy_query(
+		proxy_v2(
+			4, $v2_client_v4, $unused_dest_v4,
+			$v2_client_port, $unused_dest_port),
+		$CLIENT_ADDR,
+		$unix_connect);
+	ok($r->{ok}, 'unix: v2 header over a Unix socket succeeds')
+	  or diag("error: $r->{error}");
+	is($r->{value}, $v2_client_v4,
+		'unix: v2 header substitutes the parsed TCP client over a Unix socket'
+	);
+
+	# A trusted peer must lead with a PROXY header
+	my $unix_logoff = -s $node->logfile;
+	$r = proxy_query(undef, $CLIENT_ADDR, $unix_connect);
+	ok(!$r->{ok},
+		'unix: header-less connection over a Unix socket is rejected');
+	$node->wait_for_log(
+		qr/connection from a trusted proxy network must use the PROXY protocol/,
+		$unix_logoff);
+	ok(1,
+		'unix: header-less Unix-socket connection logs the PROXY requirement'
+	);
+
+	$node->append_conf('postgresql.conf',
+		"proxy_networks = '$loopback_net'\n");
+	$node->reload;
+}
+
+# A trusted peer must lead with a PROXY header
+# For plain.
+my $logoff = -s $node->logfile;
+$r = proxy_query(undef, $CLIENT_ADDR);
+ok(!$r->{ok}, 'plain startup packet from a trusted peer is rejected');
+$node->wait_for_log(
+	qr/connection from a trusted proxy network must use the PROXY protocol/,
+	$logoff);
+ok(1, 'plain connection from a trusted network logs the PROXY requirement');
+
+# For SSL.
+$logoff = -s $node->logfile;
+$r = ssl_request();
+ok(!$r->{ok},
+	'SSL negotiation request from a trusted peer with no PROXY header is rejected'
+);
+$node->wait_for_log(
+	qr/connection from a trusted proxy network must use the PROXY protocol/,
+	$logoff);
+ok(1,
+	'SSL-first connection from a trusted network logs the PROXY requirement');
+
+# Reject ident authentication
+$logoff = -s $node->logfile;
+$r = proxy_query(
+	proxy_v1(
+		4, $ident_client, $unused_dest_v4,
+		$v1_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok(!$r->{ok}, 'ident authentication over a proxied connection is rejected');
+$node->wait_for_log(
+	qr/ident authentication is not supported over connections using the PROXY protocol/,
+	$logoff);
+ok(1, 'ident over the PROXY protocol logs that the method is unsupported');
+
+# Reject malformed header from a trusted peer.
+$logoff = -s $node->logfile;
+$r = proxy_query("PROXY BOGUS arguments here\r\n", $CLIENT_ADDR);
+ok(!$r->{ok}, 'malformed v1 header from trusted peer is rejected');
+$node->wait_for_log(qr/incomplete startup packet/, $logoff);
+ok(1, 'malformed header logs the generic "incomplete startup packet"');
+
+# Reject connection from an untrusted peer.
+$node->append_conf('postgresql.conf', "proxy_networks = '$untrusted_net'\n");
+$node->reload;
+
+$logoff = -s $node->logfile;
+$r = proxy_query(
+	proxy_v1(
+		4, $v1_client_v4, $unused_dest_v4,
+		$v1_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok(!$r->{ok}, 'header from untrusted peer is rejected');
+$node->wait_for_log(qr/incomplete startup packet/, $logoff);
+ok(1, 'untrusted header is handled as an incomplete startup packet');
+
+# Accept peers outside of the proxy networks without the PROXY header
+$r = proxy_query(undef, $CLIENT_ADDR);
+ok($r->{ok}, 'header-less connection from untrusted peer succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $host, 'real peer address reported for an ordinary connection');
+
+# Ignore header when proxy networks is empty
+$node->append_conf('postgresql.conf', "proxy_networks = ''\n");
+$node->reload;
+
+$logoff = -s $node->logfile;
+$r = proxy_query(
+	proxy_v1(
+		4, $v1_client_v4, $unused_dest_v4,
+		$v1_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok(!$r->{ok}, 'header ignored when proxy_networks is empty');
+$node->wait_for_log(qr/incomplete startup packet/, $logoff);
+ok(1, 'disabled feature handles header as an incomplete startup packet');
+
+# ----------------------------------------------------------------------------
+# GUC validation.
+# ----------------------------------------------------------------------------
+my ($ret, $stdout, $stderr);
+
+($ret, $stdout, $stderr) = $node->psql('postgres',
+	"ALTER SYSTEM SET proxy_networks = 'not-an-address'");
+isnt($ret, 0, 'invalid network specification is rejected');
+like(
+	$stderr,
+	qr/invalid value for parameter "proxy_networks"/,
+	'invalid network: error mentions the parameter');
+like(
+	$stderr,
+	qr/Invalid network specification/,
+	'invalid network: error shows the reason');
+
+($ret, $stdout, $stderr) =
+  $node->psql('postgres', "ALTER SYSTEM SET proxy_networks = '10.0.0.0/99'");
+isnt($ret, 0, 'invalid CIDR mask length is rejected');
+
+($ret, $stdout, $stderr) = $node->psql('postgres',
+	"ALTER SYSTEM SET proxy_networks = '$loopback_net, $untrusted_net, $loopback_v6_net'"
+);
+is($ret, 0, 'a list of valid networks is accepted')
+  or diag("stderr: $stderr");
+
+# Ensure the special "unix" token is accepted.
+($ret, $stdout, $stderr) = $node->psql('postgres',
+	"ALTER SYSTEM SET proxy_networks = 'unix, $loopback_net'");
+is($ret, 0, 'the "unix" token is accepted in proxy_networks')
+  or diag("stderr: $stderr");
+
+$node->safe_psql('postgres', 'ALTER SYSTEM RESET proxy_networks');
+
+
+# ----------------------------------------------------------------------------
+# pg_stat_activity validation.
+# ----------------------------------------------------------------------------
+$node->append_conf('postgresql.conf', "proxy_networks = '$loopback_net'\n");
+$node->reload;
+
+# v1 PROXY returns proxy_addr and proxy_port for the proxy and client_addr
+# and client_port are parsed from the header.
+my $STAT_PROXY = q{SELECT format('%s|%s|%s|%s|%s',
+	proxy_addr, (proxy_hostname IS NULL), (proxy_port IS NOT NULL),
+	client_addr, client_port)
+	FROM pg_stat_activity WHERE pid = pg_backend_pid()};
+
+$r = proxy_query(
+	proxy_v1(
+		4, $v1_client_v4, $unused_dest_v4,
+		$v1_client_port, $unused_dest_port),
+	$STAT_PROXY);
+ok($r->{ok}, 'pg_stat_activity over a proxied connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, "$host|t|t|$v1_client_v4|$v1_client_port",
+	'pg_stat_activity: proxy_addr and port are the proxy, client_addr and port are the real peer'
+);
+
+# v2 LOCAL returns null for proxy_addr and proxy_port.
+my $STAT_PLAIN = q{SELECT format('%s|%s|%s',
+	(proxy_addr IS NULL), (proxy_port IS NULL), client_addr)
+	FROM pg_stat_activity WHERE pid = pg_backend_pid()};
+
+$r = proxy_query(proxy_v2_local(), $STAT_PLAIN);
+ok($r->{ok}, 'pg_stat_activity over a LOCAL connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, "t|t|$host",
+	'pg_stat_activity: proxy_addr and port are null for a LOCAL command');
+
+# No PROXY header from untrusted network returns null for proxy_addr and
+# proxy_port.
+$node->append_conf('postgresql.conf', "proxy_networks = '$untrusted_net'\n");
+$node->reload;
+
+$r = proxy_query(undef, $STAT_PLAIN);
+ok($r->{ok},
+	'pg_stat_activity over an ordinary connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, "t|t|$host",
+	'pg_stat_activity: proxy_addr and port are null for ordinary connection'
+);
+
+SKIP:
+{
+	skip "Unix-domain sockets not in use on this platform", 2
+	  unless $PostgreSQL::Test::Utils::use_unix_sockets
+	  && $node->raw_connect_works;
+
+	$node->append_conf('postgresql.conf',
+		"proxy_networks = 'unix, $loopback_net'\n");
+	$node->reload;
+
+	my $stat_unix_proxy = q{SELECT format('%s|%s|%s|%s',
+		(proxy_addr IS NULL), proxy_port, client_addr, client_port)
+		FROM pg_stat_activity WHERE pid = pg_backend_pid()};
+	$r = proxy_query(
+		proxy_v1(
+			4, $v1_client_v4, $unused_dest_v4,
+			$v1_client_port, $unused_dest_port),
+		$stat_unix_proxy,
+		$unix_connect);
+	ok($r->{ok},
+		'pg_stat_activity: connection over a proxied Unix socket succeeds'
+	) or diag("error: $r->{error}");
+	is($r->{value}, "t|-1|$v1_client_v4|$v1_client_port",
+		'pg_stat_activity: proxy_addr is NULL and proxy_port is -1, like a Unix-socket client'
+	);
+}
+
+# ----------------------------------------------------------------------------
+# Logs validation.
+# ----------------------------------------------------------------------------
+$node->append_conf('postgresql.conf', "proxy_networks = '$loopback_net'\n");
+$node->reload;
+
+# Check client and proxy escapes for a proxied connection.
+$logoff = -s $node->logfile;
+$r = proxy_query(
+	proxy_v1(
+		4, $v1_client_v4, $unused_dest_v4,
+		$v1_client_port, $unused_dest_port),
+	"SELECT 'pxlog-proxied'");
+ok($r->{ok}, 'logging: proxied connection succeeds')
+  or diag("error: $r->{error}");
+$node->wait_for_log(
+	qr{PXLOG h=\Q$v1_client_v4\E H=\Q$host\E r=\Q$v1_client_v4\E\($v1_client_port\) R=\Q$host\E\(\d+\) LOG:\s+statement: SELECT 'pxlog-proxied'},
+	$logoff);
+ok(1, 'logging: %h/%r show the client, %H/%R show the proxy');
+
+# Ensure proxy escapes are empty for an ordinary connection.
+$node->append_conf('postgresql.conf', "proxy_networks = '$untrusted_net'\n");
+$node->reload;
+
+$logoff = -s $node->logfile;
+$r = proxy_query(undef, "SELECT 'pxlog-ordinary'");
+ok($r->{ok}, 'logging over an ordinary connection succeeds')
+  or diag("error: $r->{error}");
+$node->wait_for_log(
+	qr{PXLOG h=\Q$host\E H= r=\Q$host\E\(\d+\) R= LOG:\s+statement: SELECT 'pxlog-ordinary'},
+	$logoff);
+ok(1, 'logging: %H/%R are empty without the PROXY protocol');
+
+# Test csvlog and jsonlog destinations.
+$node->append_conf(
+	'postgresql.conf',
+	"logging_collector = on\n"
+	  . "log_destination = 'stderr, csvlog, jsonlog'\n"
+	  . "log_rotation_age = 0\n"
+	  # Re-trust loopback for the proxied case below.
+	  . "proxy_networks = '$loopback_net'\n");
+$node->restart;
+
+# Wait for the collector to report the csv/json file names.
+my $clf_path = $node->data_dir . '/current_logfiles';
+PostgreSQL::Test::Utils::wait_for_file($clf_path, qr/^csvlog /m);
+PostgreSQL::Test::Utils::wait_for_file($clf_path, qr/^jsonlog /m);
+my $current_logfiles = slurp_file($clf_path);
+
+$r = proxy_query(
+	proxy_v1(
+		4, $v1_client_v4, $unused_dest_v4,
+		$v1_client_port, $unused_dest_port),
+	"SELECT 'pxlog-loggers-proxied'");
+ok($r->{ok}, 'loggers: proxied connection succeeds')
+  or diag("error: $r->{error}");
+
+my $csvline =
+  wait_for_logger_line($node, $current_logfiles, 'csvlog',
+	'pxlog-loggers-proxied');
+like($csvline, qr/"\Q$host\E:\d+"/,
+	'csvlog: proxy_host:proxy_port detected');
+
+my $jsonline =
+  wait_for_logger_line($node, $current_logfiles, 'jsonlog',
+	'pxlog-loggers-proxied');
+like($jsonline, qr/"proxy_host":"\Q$host\E"/,
+	'jsonlog: proxy_host key holds the proxy host');
+like($jsonline, qr/"proxy_port":\d+/,
+	'jsonlog: proxy_port key holds the proxy port');
+
+# Ordinary connection returns null for proxy_host and proxy_port.
+$node->append_conf('postgresql.conf', "proxy_networks = '$untrusted_net'\n");
+$node->reload;
+
+$r = proxy_query(undef, "SELECT 'pxlog-loggers-ordinary'");
+ok($r->{ok}, 'loggers: ordinary connection succeeds')
+  or diag("error: $r->{error}");
+
+$csvline =
+  wait_for_logger_line($node, $current_logfiles, 'csvlog',
+	'pxlog-loggers-ordinary');
+like($csvline, qr/,$/,
+	'csvlog: proxy_connection column is empty without the PROXY protocol');
+
+$jsonline =
+  wait_for_logger_line($node, $current_logfiles, 'jsonlog',
+	'pxlog-loggers-ordinary');
+unlike($jsonline, qr/"proxy_host"/,
+	'jsonlog: proxy_host key is omitted without the PROXY protocol');
+unlike($jsonline, qr/"proxy_port"/,
+	'jsonlog: proxy_port key is omitted without the PROXY protocol');
+
+SKIP:
+{
+	skip "Unix-domain sockets not in use on this platform", 5
+	  unless $PostgreSQL::Test::Utils::use_unix_sockets
+	  && $node->raw_connect_works;
+
+	$node->append_conf('postgresql.conf',
+		"proxy_networks = 'unix, $loopback_net'\n");
+	$node->reload;
+
+	$r = proxy_query(
+		proxy_v1(
+			4, $v1_client_v4, $unused_dest_v4,
+			$v1_client_port, $unused_dest_port),
+		"SELECT 'pxlog-loggers-unix'",
+		$unix_connect);
+	ok($r->{ok}, 'loggers: proxied Unix-socket connection succeeds')
+	  or diag("error: $r->{error}");
+
+	my $sl =
+	  wait_for_logger_line($node, $current_logfiles, 'stderr',
+		'pxlog-loggers-unix');
+	like(
+		$sl,
+		qr/h=\Q$v1_client_v4\E H=\[local\] r=\Q$v1_client_v4\E\($v1_client_port\) R=\[local\] /,
+		'stderr: %H/%R returns [local] with no port for a Unix-socket proxy');
+
+	$csvline =
+	  wait_for_logger_line($node, $current_logfiles, 'csvlog',
+		'pxlog-loggers-unix');
+	like($csvline, qr/"\[local\]"$/,
+		'csvlog: trailing proxy_connection returns [local] for a Unix-socket proxy'
+	);
+
+	$jsonline =
+	  wait_for_logger_line($node, $current_logfiles, 'jsonlog',
+		'pxlog-loggers-unix');
+	like($jsonline, qr/"proxy_host":"\[local\]"/,
+		'jsonlog: proxy_host returns [local] for a Unix-socket proxy');
+	unlike($jsonline, qr/"proxy_port"/,
+		'jsonlog: proxy_port key is omitted for Unix-socket proxy'
+	);
+}
+
+done_testing();
diff --git a/src/test/protocol/t/002_proxy_protocol_ssl.pl b/src/test/protocol/t/002_proxy_protocol_ssl.pl
new file mode 100644
index 00000000000..65ec596a59e
--- /dev/null
+++ b/src/test/protocol/t/002_proxy_protocol_ssl.pl
@@ -0,0 +1,115 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Tests for the PROXY protocol with sslnegotiation=direct.
+# Run only if OpenSSL is enabled with ALPN support.
+
+use strict;
+use warnings FATAL => 'all';
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use File::Copy qw(copy);
+use Test::More;
+
+use ProxyProtocol;
+
+if (($ENV{with_ssl} || '') ne 'openssl')
+{
+	plan skip_all => 'OpenSSL not supported by this build';
+}
+
+unless (eval { require IO::Socket::SSL; 1 })
+{
+	plan skip_all => 'IO::Socket::SSL not available';
+}
+unless (IO::Socket::SSL->can('can_alpn') && IO::Socket::SSL->can_alpn)
+{
+	plan skip_all => 'IO::Socket::SSL lacks ALPN support';
+}
+
+my $host = '127.0.0.1';
+
+# Network ranges trusted as proxies
+my $loopback_net = "$host/32";
+my $client_net = '192.0.2.0/24';       # TEST-NET-1, v1 TCP4 client
+
+# Client (source) addresses carried in the PROXY headers.
+my $v1_client = '192.0.2.1';        # in $client_net
+my $v2_client = '192.0.2.5';        # in $client_net
+my $unused_dest = '198.51.100.9';
+
+# Source ports declared in the PROXY headers
+my $v1_client_port = 56324;
+my $v2_client_port = 40000;
+my $unused_dest_port = 5432;
+
+# Reuse the committed test certificate from the SSL test suite.
+my $ssldir = "$FindBin::RealBin/../../ssl/ssl";
+plan skip_all => "test certificate not found in $ssldir"
+  unless -f "$ssldir/server-cn-only.crt";
+
+my $node = PostgreSQL::Test::Cluster->new('proxy_protocol_ssl');
+$node->init;
+
+my $certfile = $node->data_dir . '/server.crt';
+my $keyfile = $node->data_dir . '/server.key';
+copy("$ssldir/server-cn-only.crt", $certfile)
+  or die "could not copy server certificate: $!";
+copy("$ssldir/server-cn-only.key", $keyfile)
+  or die "could not copy server key: $!";
+chmod 0600, $keyfile or die "could not chmod server key: $!";
+
+$node->append_conf('postgresql.conf',
+		"listen_addresses = '$host'\n"
+	  . "ssl = on\n"
+	  . "ssl_cert_file = '$certfile'\n"
+	  . "ssl_key_file = '$keyfile'\n"
+	  . "proxy_networks = '$loopback_net'\n");
+$node->append_conf('pg_hba.conf',
+		"host all all $loopback_net trust\n"
+	  . "host all all $client_net trust\n");
+$node->start;
+
+my $port = $node->port;
+my $user = $node->safe_psql('postgres', 'SELECT current_user');
+
+set_connection(host => $host, port => $port, user => $user);
+
+# A query to check the client address.
+my $CLIENT_ADDR = 'SELECT host(inet_client_addr())';
+
+# ----------------------------------------------------------------------------
+# Protocol validation.
+# ----------------------------------------------------------------------------
+my $r = proxy_query_with_ssl(
+	proxy_v1(4, $v1_client, $unused_dest, $v1_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok($r->{ok}, 'v1 header before direct SSL: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $v1_client,
+	'v1 header before direct SSL: client address substituted');
+
+$r = proxy_query_with_ssl(
+	proxy_v2(4, $v2_client, $unused_dest, $v2_client_port, $unused_dest_port),
+	$CLIENT_ADDR);
+ok($r->{ok}, 'v2 header before direct SSL: connection succeeds')
+  or diag("error: $r->{error}");
+is($r->{value}, $v2_client,
+	'v2 header before direct SSL: client address substituted');
+
+# ----------------------------------------------------------------------------
+# Logs validation.
+# ----------------------------------------------------------------------------
+my $logoff = -s $node->logfile;
+$r = proxy_query_with_ssl(undef, $CLIENT_ADDR);
+ok(!$r->{ok},
+	'direct SSL with no PROXY header from a trusted peer is rejected');
+$node->wait_for_log(
+	qr/connection from a trusted proxy network must use the PROXY protocol/,
+	$logoff);
+ok(1, 'direct SSL without a header logs the PROXY requirement');
+
+done_testing();
diff --git a/src/test/protocol/t/ProxyProtocol.pm b/src/test/protocol/t/ProxyProtocol.pm
new file mode 100644
index 00000000000..078ea22e7a2
--- /dev/null
+++ b/src/test/protocol/t/ProxyProtocol.pm
@@ -0,0 +1,385 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+ProxyProtocol - helpers for driving PROXY protocol connections in TAP tests
+
+=head1 SYNOPSIS
+
+  use lib $FindBin::RealBin;
+  use ProxyProtocol;
+
+  # Build PROXY headers.
+  my $v1 = proxy_v1(4, '192.0.2.1', '198.51.100.9', 56324, 5432);
+  my $v2 = proxy_v2(4, '203.0.113.5', '198.51.100.9', 40000, 5432);
+
+  # Authenticate (trust method) and run one query over a connected socket.
+  my $addr = authenticate_and_query($sock, $user,
+      'SELECT host(inet_client_addr())');
+
+  # Or bind the per-test connection details once and drive whole queries.
+  set_connection(host => $host, port => $port, user => $user);
+  my $r = proxy_query($v1, 'SELECT host(inet_client_addr())');
+
+=head1 DESCRIPTION
+
+A minimal hand-rolled implementation of the parts of the v3 frontend/backend
+protocol and the PROXY protocol that the regression tests need.  It is enough
+to prepend a PROXY header, authenticate with the trust method, and run a single
+query over a raw socket, whether plaintext or TLS.
+
+=cut
+
+package ProxyProtocol;
+
+use strict;
+use warnings FATAL => 'all';
+use Exporter 'import';
+use Socket qw(inet_aton inet_pton AF_INET6);
+use IO::Socket::INET;
+
+our @EXPORT = qw(
+  startup_packet
+  read_message
+  error_text
+  proxy_v1
+  proxy_v1_unknown
+  proxy_v2
+  proxy_v2_local
+  authenticate_and_query
+  set_connection
+  proxy_query
+  ssl_request
+  proxy_query_with_ssl
+  logger_file_name
+  wait_for_logger_line
+);
+
+use constant PROXY_V2_SIG =>
+  "\x0d\x0a\x0d\x0a\x00\x0d\x0a\x51\x55\x49\x54\x0a";
+
+sub startup_packet
+{
+	my (%params) = @_;
+	my $body = pack('N', 0x00030000);    # protocol version 3.0
+	for my $key (sort keys %params)
+	{
+		$body .= $key . "\0" . $params{$key} . "\0";
+	}
+	$body .= "\0";                       # empty key name terminates the list
+	return pack('N', length($body) + 4) . $body;
+}
+
+# Returns undef when the peer closed the connection before $n bytes arrived, so
+# callers can treat a server-side disconnect as a distinct outcome.
+sub read_exact
+{
+	my ($sock, $n) = @_;
+	my $buf = '';
+	while (length($buf) < $n)
+	{
+		my $chunk;
+		my $r = sysread($sock, $chunk, $n - length($buf));
+		return if !defined $r || $r == 0;
+		$buf .= $chunk;
+	}
+	return $buf;
+}
+
+# Read one typed backend message.  Returns ($type, $payload), or an empty list
+# once the connection has closed.
+sub read_message
+{
+	my ($sock) = @_;
+	my $type = read_exact($sock, 1);
+	return () unless defined $type;
+	my $lenbytes = read_exact($sock, 4);
+	return () unless defined $lenbytes;
+	my $len = unpack('N', $lenbytes);
+	my $payload = '';
+	if ($len > 4)
+	{
+		$payload = read_exact($sock, $len - 4);
+		return () unless defined $payload;
+	}
+	return ($type, $payload);
+}
+
+# Pull the human-readable text (the 'M' field) out of an ErrorResponse or
+# NoticeResponse body, which is the only part the tests assert on.
+sub error_text
+{
+	my ($payload) = @_;
+	for my $field (split /\0/, $payload)
+	{
+		return substr($field, 1) if substr($field, 0, 1) eq 'M';
+	}
+	return '';
+}
+
+# Build the PROXY v1 payload.
+sub proxy_v1
+{
+	my ($family, $src, $dst, $sport, $dport) = @_;
+	my $proto = $family == 4 ? 'TCP4' : 'TCP6';
+	return "PROXY $proto $src $dst $sport $dport\r\n";
+}
+
+# Build the PROXY v1 payload for the unknown command.
+sub proxy_v1_unknown
+{
+	return "PROXY UNKNOWN\r\n";
+}
+
+# Build the PROXY v2 payload.
+sub proxy_v2
+{
+	my ($family, $src, $dst, $sport, $dport, $tlv) = @_;
+	$tlv = '' unless defined $tlv;
+	my $vercmd = "\x21";    # version 2, command PROXY
+	my ($fambyte, $addr);
+	if ($family == 4)
+	{
+		$fambyte = "\x11";    # TCP4
+		$addr =
+			inet_aton($src)
+		  . inet_aton($dst)
+		  . pack('n', $sport)
+		  . pack('n', $dport);
+	}
+	else
+	{
+		$fambyte = "\x21";    # TCP6
+		$addr =
+			inet_pton(AF_INET6, $src)
+		  . inet_pton(AF_INET6, $dst)
+		  . pack('n', $sport)
+		  . pack('n', $dport);
+	}
+	return
+		PROXY_V2_SIG
+	  . $vercmd
+	  . $fambyte
+	  . pack('n', length($addr) + length($tlv))
+	  . $addr
+	  . $tlv;
+}
+
+# Build the PROXY v2 payload for the LOCAL command.
+sub proxy_v2_local
+{
+	return PROXY_V2_SIG . "\x20" . "\x00"
+	  . pack('n', 0);    # command LOCAL, AF_UNSPEC
+}
+
+# Drive a full session over an already-connected stream, whether plaintext or
+# TLS, and return the first column of the first row (undef for NULL or no row).
+# Dies with a descriptive message on EOF, a server ErrorResponse, or a non-zero
+# authentication request, so callers can map any failure to a single eval.
+sub authenticate_and_query
+{
+	my ($stream, $user, $query) = @_;
+
+	print $stream startup_packet(user => $user, database => 'postgres');
+
+	while (1)
+	{
+		my ($type, $payload) = read_message($stream);
+		defined $type or die "connection closed during startup\n";
+		die error_text($payload) . "\n" if $type eq 'E';
+		if ($type eq 'R')
+		{
+			my $code = unpack('N', $payload);
+			die "authentication required (code $code)\n" if $code != 0;
+		}
+		last if $type eq 'Z';    # ReadyForQuery
+	}
+
+	print $stream 'Q' . pack('N', length($query) + 5) . $query . "\0";
+
+	my $value;
+	while (1)
+	{
+		my ($type, $payload) = read_message($stream);
+		defined $type or die "connection closed during query\n";
+		die error_text($payload) . "\n" if $type eq 'E';
+		if ($type eq 'D')        # DataRow
+		{
+			my $ncols = unpack('n', substr($payload, 0, 2));
+			if ($ncols >= 1)
+			{
+				my $collen = unpack('N', substr($payload, 2, 4));
+				$value =
+				  ($collen == 0xFFFFFFFF)
+				  ? undef
+				  : substr($payload, 6, $collen);
+			}
+		}
+		last if $type eq 'Z';
+	}
+
+	return $value;
+}
+
+# The per-test connection details that the query drivers below reuse, bound once
+# with set_connection() so the individual calls need only the header and query.
+my ($conn_host, $conn_port, $conn_user);
+
+sub set_connection
+{
+	my (%params) = @_;
+	$conn_host = $params{host};
+	$conn_port = $params{port};
+	$conn_user = $params{user};
+	return;
+}
+
+# Connect to the server, optionally sending $prefix (a PROXY header) ahead of
+# the startup packet, then authenticate and run a one-column query.  The
+# connection defaults to TCP loopback.  Pass $connect, a coderef returning a
+# connected socket, to use another transport such as a Unix socket.
+#
+# Returns a hash-ref with ok => 1 and the query value on success, or ok => 0
+# and an error string otherwise.
+sub proxy_query
+{
+	my ($prefix, $query, $connect) = @_;
+	my $result = { ok => 0, error => 'unknown' };
+
+	eval {
+		local $SIG{ALRM} = sub { die "timeout\n" };
+		alarm($PostgreSQL::Test::Utils::timeout_default);
+
+		my $sock =
+			$connect
+		  ? $connect->()
+		  : IO::Socket::INET->new(
+			PeerHost => $conn_host,
+			PeerPort => $conn_port,
+			Proto => 'tcp');
+		die "cannot connect: $!\n" unless $sock;
+		$sock->autoflush(1);
+
+		print $sock $prefix if defined $prefix;
+		my $value = authenticate_and_query($sock, $conn_user, $query);
+
+		close $sock;
+		alarm(0);
+		$result = { ok => 1, value => $value };
+	};
+	return { ok => 0, error => $@ } if $@;
+	return $result;
+}
+
+# Open a raw connection and send an SSL negotiation request as the very first
+# bytes, with no preceding PROXY header.
+#
+# Returns a hash-ref with ok => 1 if the request succeeded, or ok => 0 and an
+# error string if it failed.
+sub ssl_request
+{
+	my $result = { ok => 0, error => 'unknown' };
+
+	eval {
+		local $SIG{ALRM} = sub { die "timeout\n" };
+		alarm($PostgreSQL::Test::Utils::timeout_default);
+
+		my $sock = IO::Socket::INET->new(
+			PeerHost => $conn_host,
+			PeerPort => $conn_port,
+			Proto => 'tcp') or die "cannot connect: $!\n";
+		$sock->autoflush(1);
+
+		# SSLRequest is a length of 8 followed by the negotiate-SSL request code.
+		print $sock pack('N', 8) . pack('N', 80877103);
+
+		while (1)
+		{
+			my ($type, $payload) = read_message($sock);
+			die "connection closed\n" unless defined $type;
+			die error_text($payload) . "\n" if $type eq 'E';
+			last if $type eq 'Z';
+		}
+		close $sock;
+		alarm(0);
+		$result = { ok => 1 };
+	};
+	return { ok => 0, error => $@ } if $@;
+	return $result;
+}
+
+# Connect over TCP, send $prefix (a PROXY header) in cleartext, then open a
+# direct SSL connection (no SSLRequest) offering the PostgreSQL ALPN protocol,
+# authenticate over TLS, and run a one-column query.  The caller must have
+# loaded IO::Socket::SSL, which the module leaves optional.
+#
+# Returns a hash-ref with ok => 1 and the query value on success, or ok => 0
+# and an error string otherwise.
+sub proxy_query_with_ssl
+{
+	my ($prefix, $query) = @_;
+	my $result = { ok => 0, error => 'unknown' };
+
+	eval {
+		local $SIG{ALRM} = sub { die "timeout\n" };
+		alarm($PostgreSQL::Test::Utils::timeout_default);
+
+		my $sock = IO::Socket::INET->new(
+			PeerHost => $conn_host,
+			PeerPort => $conn_port,
+			Proto => 'tcp') or die "cannot connect: $!\n";
+		$sock->autoflush(1);
+
+		# The PROXY header travels in cleartext, ahead of the TLS handshake.
+		print $sock $prefix if defined $prefix;
+
+		my $ssl = IO::Socket::SSL->start_SSL(
+			$sock,
+			SSL_verify_mode => 0,
+			SSL_alpn_protocols => ['postgresql'])
+		  or die "TLS handshake failed: "
+		  . (IO::Socket::SSL->errstr // 'unknown') . "\n";
+
+		die "ALPN did not negotiate 'postgresql'\n"
+		  unless defined $ssl->alpn_selected
+		  && $ssl->alpn_selected eq 'postgresql';
+
+		my $value = authenticate_and_query($ssl, $conn_user, $query);
+
+		close $ssl;
+		alarm(0);
+		$result = { ok => 1, value => $value };
+	};
+	return { ok => 0, error => $@ } if $@;
+	return $result;
+}
+
+# Given the contents of current_logfiles, return the file name (relative to the
+# data directory) recorded for the given destination, 'csvlog' or 'jsonlog'.
+sub logger_file_name
+{
+	my ($current_logfiles, $format) = @_;
+	return ($current_logfiles =~ /^$format (.*)$/m) ? $1 : undef;
+}
+
+# Wait for the collected $format log file to contain $marker, then return the
+# line carrying it.  A unique marker query is what pins a log record to one
+# specific connection.
+sub wait_for_logger_line
+{
+	my ($node, $current_logfiles, $format, $marker) = @_;
+	my $path =
+	  $node->data_dir . '/' . logger_file_name($current_logfiles, $format);
+
+	PostgreSQL::Test::Utils::wait_for_file($path, quotemeta($marker));
+
+	foreach my $line (split(/\n/, PostgreSQL::Test::Utils::slurp_file($path)))
+	{
+		return $line if index($line, $marker) >= 0;
+	}
+	return '';
+}
+
+1;
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index a65a5bf0c4f..ed574ca2fe1 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1786,6 +1786,9 @@ pg_stat_activity| SELECT s.datid,
     s.client_addr,
     s.client_hostname,
     s.client_port,
+    s.proxy_addr,
+    s.proxy_hostname,
+    s.proxy_port,
     s.backend_start,
     s.xact_start,
     s.query_start,
@@ -1798,7 +1801,7 @@ pg_stat_activity| SELECT s.datid,
     s.query_id,
     s.query,
     s.backend_type
-   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, proxy_addr, proxy_hostname, proxy_port)
      LEFT JOIN pg_database d ON ((s.datid = d.oid)))
      LEFT JOIN pg_authid u ON ((s.usesysid = u.oid)));
 pg_stat_all_indexes| SELECT c.oid AS relid,
@@ -1944,7 +1947,7 @@ pg_stat_gssapi| SELECT pid,
     gss_princ AS principal,
     gss_enc AS encrypted,
     gss_delegation AS credentials_delegated
-   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, proxy_addr, proxy_hostname, proxy_port)
   WHERE (client_port IS NOT NULL);
 pg_stat_io| SELECT backend_type,
     object,
@@ -2247,7 +2250,7 @@ pg_stat_replication| SELECT s.pid,
     w.sync_priority,
     w.sync_state,
     w.reply_time
-   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM ((pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, proxy_addr, proxy_hostname, proxy_port)
      JOIN pg_stat_get_wal_senders() w(pid, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag, sync_priority, sync_state, reply_time) ON ((s.pid = w.pid)))
      LEFT JOIN pg_authid u ON ((s.usesysid = u.oid)));
 pg_stat_replication_slots| SELECT s.slot_name,
@@ -2284,7 +2287,7 @@ pg_stat_ssl| SELECT pid,
     ssl_client_dn AS client_dn,
     ssl_client_serial AS client_serial,
     ssl_issuer_dn AS issuer_dn
-   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id)
+   FROM pg_stat_get_activity(NULL::integer) s(datid, pid, usesysid, application_name, state, query, wait_event_type, wait_event, xact_start, query_start, backend_start, state_change, client_addr, client_hostname, client_port, backend_xid, backend_xmin, backend_type, ssl, sslversion, sslcipher, sslbits, ssl_client_dn, ssl_client_serial, ssl_issuer_dn, gss_auth, gss_princ, gss_enc, gss_delegation, leader_pid, query_id, proxy_addr, proxy_hostname, proxy_port)
   WHERE (client_port IS NOT NULL);
 pg_stat_subscription| SELECT su.oid AS subid,
     su.subname,
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 8cf40c87043..cd51760f973 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2450,6 +2450,9 @@ PropGraphLabelAndProperties
 PropGraphProperties
 PropGraphVertex
 ProtocolVersion
+ProxyNet
+ProxyNets
+ProxyProtocolResult
 PrsStorage
 PruneFreezeParams
 PruneFreezeResult
-- 
2.39.5

