From 438d935387a9b58c85c2d566ca18a1ca60910832 Mon Sep 17 00:00:00 2001
From: Greg Sabino Mullane <greg@turnstep.com>
Date: Tue, 17 Feb 2026 14:35:32 -0500
Subject: [PATCH] Allow specific information to be output directly by Postgres.

---
 src/backend/tcop/backend_startup.c            | 185 ++++++++++++++++++
 src/backend/utils/misc/guc_parameters.dat     |  19 ++
 src/backend/utils/misc/postgresql.conf.sample |   6 +
 src/include/postmaster/postmaster.h           |   3 +
 src/test/modules/test_misc/meson.build        |   1 +
 src/test/modules/test_misc/t/011_expose.pl    | 122 ++++++++++++
 6 files changed, 336 insertions(+)
 create mode 100644 src/test/modules/test_misc/t/011_expose.pl

diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c
index c517115927c..e7696d34d59 100644
--- a/src/backend/tcop/backend_startup.c
+++ b/src/backend/tcop/backend_startup.c
@@ -46,6 +46,29 @@
 bool		Trace_connection_negotiation = false;
 uint32		log_connections = 0;
 char	   *log_connections_string = NULL;
+bool		expose_recovery = false;
+bool		expose_sysid = false;
+bool		expose_version = false;
+
+#define EXPOSE_MIN_QUERY 9		/* Shortest possible line: "Get /info" */
+#define EXPOSE_MAX_QUERY 16		/* Longest possible GET line */
+
+typedef enum
+{
+	EXPOSE_NOTHING,
+	EXPOSE_HEAD_REPLICA,
+	EXPOSE_GET_ALL,
+	EXPOSE_GET_REPLICA,
+	EXPOSE_GET_SYSID,
+	EXPOSE_GET_VERSION,
+}			ExposeReturnType;
+
+typedef struct
+{
+	const char *endpoint;
+	const bool *require;
+	ExposeReturnType	type;
+}			endpoint_action;
 
 /* Other globals */
 
@@ -65,6 +88,7 @@ static void SendNegotiateProtocolVersion(List *unrecognized_protocol_options);
 static void process_startup_packet_die(SIGNAL_ARGS);
 static void StartupPacketTimeoutHandler(void);
 static bool validate_log_connections_options(List *elemlist, uint32 *flags);
+static bool ExposeInformation(pgsocket fd);
 
 /*
  * Entry point for a new backend process.
@@ -148,6 +172,14 @@ BackendInitialize(ClientSocket *client_sock, CAC_state cac)
 	StringInfoData ps_data;
 	MemoryContext oldcontext;
 
+	/*
+	 * Scan for a simple GET / HEAD request. If this is detected and
+	 * handled, we are done and can immediately exit
+	 */
+	if ((expose_recovery || expose_sysid || expose_version)
+		&& ExposeInformation(client_sock->sock))
+		_exit(0); /* Safe to use exit: no state or resources created yet */
+
 	/* Tell fd.c about the long-lived FD associated with the client_sock */
 	ReserveExternalFD();
 
@@ -1125,3 +1157,156 @@ assign_log_connections(const char *newval, void *extra)
 {
 	log_connections = *((int *) extra);
 }
+
+/*
+ * ExposeInformation
+ *
+ * Handle early socket probe before full backend startup.
+ * Responds to small set of predefined endpoints (e.g. GET /info)
+ *
+ * Requires at least one "expose_" GUC to be true.
+ *
+ * Returns true if any endpoint is recognized.
+ */
+
+static bool
+ExposeInformation(pgsocket fd)
+{
+	static endpoint_action endpoint_actions[] =
+	{
+		{
+			"HEAD /replica", &expose_recovery, EXPOSE_HEAD_REPLICA
+		},
+		{
+			"GET /replica", &expose_recovery, EXPOSE_GET_REPLICA
+		},
+		{
+			"GET /sysid", &expose_sysid, EXPOSE_GET_SYSID
+		},
+		{
+			"GET /version", &expose_version, EXPOSE_GET_VERSION
+		},
+		{
+			"GET /info", NULL, EXPOSE_GET_ALL
+		}
+	};
+
+	ssize_t		n;
+	char		buf[EXPOSE_MAX_QUERY + 1];
+	ExposeReturnType	type;
+
+	Assert(expose_recovery || expose_sysid || expose_version);
+
+	do
+	{
+		n = recv(fd, buf, EXPOSE_MAX_QUERY, MSG_PEEK);
+	} while (n < 0 && errno == EINTR);
+
+	/*
+	 * Leave as soon as possible if no chance we are interested.
+	 * (we also leave on partial reads from slow clients)
+	 * We also simply return false for n == -1
+	 */
+	if (n < EXPOSE_MIN_QUERY)
+		return false;
+
+	buf[n] = '\0';
+
+	type = EXPOSE_NOTHING;
+	for (int i = 0; i < lengthof(endpoint_actions); i++)
+	{
+		if (
+			pg_strncasecmp(buf, endpoint_actions[i].endpoint, strlen(endpoint_actions[i].endpoint)) == 0
+			&&
+			(endpoint_actions[i].require == NULL
+			 ||
+			 *(endpoint_actions[i].require)
+			 ))
+		{
+			type = endpoint_actions[i].type;
+			break;
+		}
+	}
+
+	if (type == EXPOSE_NOTHING)
+		return false;
+
+	{
+		static const char http_version[] = "HTTP/1.1";
+		static const char http_type[] = "Content-Type: text/plain";
+		static const char http_conn[] = "Connection: close";
+		static const char http_len[] = "Content-Length";
+
+		StringInfoData msg;
+
+		if (type == EXPOSE_HEAD_REPLICA)
+		{
+			/*
+			 * Caller only cares about the HTTP response code, so no content
+			 * needed
+			 */
+
+			initStringInfoExt(&msg, 64);
+
+			appendStringInfo(&msg,
+							 "%s %s\r\n"
+							 "%s\r\n"
+							 "%s\r\n\r\n",
+							 http_version,
+							 (RecoveryInProgress() ? "200 OK" : "503 Service Unavailable"),
+							 http_type,
+							 http_conn
+				);
+		}
+		else
+		{
+			StringInfoData content;
+
+			initStringInfoExt(&content, 64);
+
+			if (expose_recovery && (type == EXPOSE_GET_ALL || type == EXPOSE_GET_REPLICA))
+				appendStringInfo(&content, "%s%d\r\n",
+								 type == EXPOSE_GET_ALL ? "RECOVERY: " : "",
+								 RecoveryInProgress() ? 1 : 0);
+			if (expose_sysid && (type == EXPOSE_GET_ALL || type == EXPOSE_GET_SYSID))
+				appendStringInfo(&content, "%s"  UINT64_FORMAT "\r\n",
+								 type == EXPOSE_GET_ALL ? "SYSID: " : "",
+								 GetSystemIdentifier());
+			if (expose_version && (type == EXPOSE_GET_ALL || type == EXPOSE_GET_VERSION))
+				appendStringInfo(&content, "%s%d\r\n",
+								 type == EXPOSE_GET_ALL ? "VERSION: " : "",
+								 PG_VERSION_NUM);
+
+			initStringInfoExt(&msg, 256);
+
+			appendStringInfo(&msg,
+							 "%s 200 OK\r\n"
+							 "%s\r\n"
+							 "%s: %d\r\n"
+							 "%s\r\n\r\n"
+							 "%s",
+							 http_version,
+							 http_type,
+							 http_len, content.len,
+							 http_conn,
+							 content.data
+				);
+
+			pfree(content.data);
+		}
+
+		do
+		{
+			n = send(fd, msg.data, msg.len, 0);
+		} while (n < 0 && errno == EINTR);
+
+		pfree(msg.data);
+
+		if (n < 0)
+			elog(DEBUG1, "could not send to client: %m");
+
+		return true;
+
+	}
+
+}
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 271c033952e..3e99d9f6b7c 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -1010,6 +1010,25 @@
   boot_val => 'false',
 },
 
+{ name => 'expose_recovery', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_AUTH',
+  short_desc => 'Exposes if the server is in recovery mode without a login.',
+  variable => 'expose_recovery',
+  boot_val => 'false',
+},
+
+{ name => 'expose_sysid', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_AUTH',
+  short_desc => 'Exposes the system identifier without a login.',
+  variable => 'expose_sysid',
+  boot_val => 'false',
+},
+
+{ name => 'expose_version', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_AUTH',
+  short_desc => 'Exposes the server version without a login.',
+  variable => 'expose_version',
+  boot_val => 'false',
+},
+
+
 { name => 'extension_control_path', type => 'string', context => 'PGC_SUSET', group => 'CLIENT_CONN_OTHER',
   short_desc => 'Sets the path for extension control files.',
   long_desc => 'The remaining extension script and secondary control files are then loaded from the same directory where the primary control file was found.',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index f938cc65a3a..76b640e4878 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -91,6 +91,12 @@
                                         # disconnection while running queries;
                                         # 0 for never
 
+# - Expose information -
+
+#expose_recovery = off
+#expose_sysid = off
+#expose_version = off
+
 # - Authentication -
 
 #authentication_timeout = 1min          # 1s-600s
diff --git a/src/include/postmaster/postmaster.h b/src/include/postmaster/postmaster.h
index d6ab9ee2d96..b042336728f 100644
--- a/src/include/postmaster/postmaster.h
+++ b/src/include/postmaster/postmaster.h
@@ -70,6 +70,9 @@ extern PGDLLIMPORT bool restart_after_crash;
 extern PGDLLIMPORT bool remove_temp_files_after_crash;
 extern PGDLLIMPORT bool send_abort_for_crash;
 extern PGDLLIMPORT bool send_abort_for_kill;
+extern PGDLLIMPORT bool expose_recovery;
+extern PGDLLIMPORT bool expose_sysid;
+extern PGDLLIMPORT bool expose_version;
 
 #ifdef WIN32
 extern PGDLLIMPORT HANDLE PostmasterHandle;
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 6e8db1621a7..c40a0455708 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -19,6 +19,7 @@ tests += {
       't/008_replslot_single_user.pl',
       't/009_log_temp_files.pl',
       't/010_index_concurrently_upsert.pl',
+      't/011_expose.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
     'runningcheck': false,
diff --git a/src/test/modules/test_misc/t/011_expose.pl b/src/test/modules/test_misc/t/011_expose.pl
new file mode 100644
index 00000000000..3496e3ae283
--- /dev/null
+++ b/src/test/modules/test_misc/t/011_expose.pl
@@ -0,0 +1,122 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test gathering information before authentication via expose_* variables
+
+# Force use of TCP/IP - call before the 'use'
+INIT{ $PostgreSQL::Test::Utils::use_unix_sockets = 0; }
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('node1');
+
+# Set as logical here so we can restart it as a replica later
+$node->init(allows_streaming => 'logical');
+$node->start;
+
+my $server_version = $node->safe_psql('postgres', 'show server_version_num');
+my $bindir = $node->config_data('--bindir');
+my $datadir = $node->data_dir;
+my $cdata = qx{$bindir/pg_controldata -D $datadir 2>&1};
+my ($sysid) = $cdata =~ /Database system identifier:\s+(\d+)/;
+my $receive_length = 200;
+
+my ($socket, $response, $test);
+
+sub call_socket {
+	my $string = shift;
+	$socket->close() if defined $socket;
+	$socket = $node->raw_connect();
+	$socket->send($string);
+	$response = '';
+	select(undef, undef, undef, 0.1);
+	$socket->recv($response, $receive_length);
+	return;
+}
+
+$test = 'GET /info returns nothing when nothing is listening';
+call_socket('GET /info');
+is ($response, '', $test);
+
+$test = 'HEAD /replica returns nothing when nothing is listening';
+call_socket('HEAD /replica');
+is ($response, '', $test);
+
+$node->append_conf('postgresql.conf', 'expose_recovery=on');
+$node->reload();
+
+$test = 'GET /replica returns HTTP code 200 when expose_recovery is true (primary)';
+call_socket('GET /replica');
+like ($response, qr{^HTTP/1.1 200 }, $test);
+
+$test = 'GET /replica returns "0" when expose_recovery is true (primary)';
+like ($response, qr{\r\n0\r\n}, $test);
+
+$test = 'HEAD /replica returns HTTP code 503 when expose_recovery is true (primary)';
+call_socket('HEAD /replica');
+like ($response, qr{^HTTP/1.1 503 }, $test);
+
+$test = 'GET /info returns "RECOVERY: 0" when expose_recovery is true (primary)';
+call_socket('GET /info');
+like ($response, qr{RECOVERY: 0\r\n}, $test);
+
+$test = 'GET /info does not return version information when expose_version is false';
+unlike ($response, qr{VERSION}, $test);
+
+$test = 'GET /info does not return sysid information when expose_sysid is false';
+unlike ($response, qr{SYSID}, $test);
+
+$node->append_conf('postgresql.conf', 'expose_version=on');
+$node->append_conf('postgresql.conf', 'expose_sysid=on');
+$node->reload();
+
+$test = 'GET /info returns correct version when expose_version is true';
+call_socket('GET /info');
+like ($response, qr/VERSION: $server_version/, $test);
+
+$test = 'GET /info returns correct value when expose_sysid is true';
+like ($response, qr/SYSID: $sysid/, $test);
+
+$test = 'Get /sysid returns correct value when expose_sysid is true';
+call_socket('Get /sysid'); ## Not required to be all uppercase according to the spec!
+like ($response, qr/^$sysid\r\n/m, $test);
+
+$test = 'GET /version returns correct value when expose_version is true';
+call_socket('GET /version');
+like ($response, qr/^$server_version\r\n/m, $test);
+
+$test = 'GET /foobar returns nothing';
+call_socket('GET /foobar');
+is ($response, '', $test);
+
+$node->set_standby_mode();
+$node->restart();
+
+$test = 'GET /replica returns HTTP code 200 when expose_recovery is true (replica)';
+call_socket('GET /replica');
+like ($response, qr{^HTTP/1.1 200 }, $test);
+
+$test = 'GET /replica returns "1" when expose_recovery is true (replica)';
+like ($response, qr{^1\r\n}m, $test);
+
+$test = 'HEAD /replica returns HTTP code 200 when expose_recovery is true (replica)';
+call_socket('HEAD /replica');
+like ($response, qr{^HTTP/1.1 200 }, $test);
+
+$test = 'GET /info returns "RECOVERY: 1" when expose_recovery is true (replica)';
+call_socket('GET /info');
+like ($response, qr/RECOVERY: 1/, $test);
+
+$node->append_conf('postgresql.conf', 'expose_version=off');
+$node->reload();
+
+$test = 'GET /version returns nothing after expose_version turned back off';
+call_socket('GET /version');
+is ($response, '', $test);
+
+$socket->close();
+
+done_testing();
-- 
2.47.3

