public inbox for [email protected]  
help / color / mirror / Atom feed
POC: Carefully exposing information without authentication
12+ messages / 4 participants
[nested] [flat]

* POC: Carefully exposing information without authentication
@ 2025-05-29 14:32  Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Greg Sabino Mullane @ 2025-05-29 14:32 UTC (permalink / raw)
  To: [email protected] <[email protected]>

Proposal: Allow a carefully curated selection of information to be shown
without authentication.

A common task for an HA system or a load balancer is to quickly determine
which of your Postgres clusters is the primary, and which are the replicas.
The canonical way to do this is to log in to each server with a valid
username and password, and then run pg_is_in_recovery().
That's a lot of work to determine if a server is a replica or not, and it
struck me that this true/false information about a running cluster is not
super-sensitive information. In other words, would it really be wrong if
there was a way to advertise that information without having to log in? I
toyed with the idea of Postgres maintaining some sort of signal file, but
then I realized that we already have a process, listening on a known port,
that has that information available to us.

Thus, this POC (proof of concept), which lets the postmaster scan for
incoming requests and quickly handle them *before* doing forking and
authenticating. We scan for a simple trigger string, and immediately return
the information to the client.

It also occured to me that since we are going to need to provide a
non-Postgres-protocol special trigger string, and we might as well do
something like "GET /info" to allow existing programs to treat Postgres as
a mini http server. To that end, we end
up with something like this:

$ psql -p 5432 -tc 'select pg_is_in_recovery()'
t

$ curl http://localhost:5432/foobar
curl: (52) Empty reply from server

$ curl http://localhost:5432/info
RECOVERY: 1

To accomplish this, we have boolean GUC flags (defaulting to false) that
control which information is exposed. For the example above, the
expose_recovery boolean has been set to true. If any of these GUCs are
true, we take a slight detour right after we accept() but before we
actually fork. We use recv with the MSG_PEEK flag to take a quick scan of
the incoming data, and use strncmp to see if it matches. If it does, we
send() some information and move on without forking. If it doesn't, we
simply move on as if we were never there, and proceed to the next step of
forking a new backend to start the authentication process.

There are three pieces of information that can be exposed with this patch.
There may be more in the future, but these are all simple, global, and not
(IMO) security leaks. The GUCs are expose_recovery, expose_sysid, and
expose_version. Each one adds a line to the output in a KEY: VALUE format
for the GET /info endpoint. The raw value is output for the direct
endpoints:

* GET /replica
* GET /sysid
* GET /version

The expose_recovery GUC uses RecoveryInProgress() to return a 1 or a 0.
This is returned by the GET /replica endpoint.

The expose_sysid GUC returns GetSystemIdentifier(). Since this can be
thought of as a fingerprint for the server, it's a nice way for external
programs to determine if the cluster is the same one it saw last time, or
for leader/replica matching.

The purpose of the expose_version GUC is to output PG_VERSION_NUM. This
will allow external tools - particularly security scanners - to know the
exact version of Postgres that is running. While some may consider this
privileged information, tools are already taking advantage of our debug
loophole to make an educated guess about the version. See my old post about
this:
https://www.endpointdev.com/blog/2010/05/finding-postgresql-version-without/
Note that this guess by security scanners is sometimes wrong, or only able
to cover a range of versions. Thus, we should give them the correct answer,
rather than providing a dubious one via some trickery.

Here's some example output with all three enabled:

$ psql -c 'alter system set expose_recovery=on'
$ psql -c 'alter system set expose_version=on'
$ psql -c 'alter system set expose_sysid=on'
$ psql -c 'select pg_reload_conf()'

$ curl http://localhost:5432/version
180000

$ curl http://localhost:5432/info
RECOVERY: 1
SYSID: 7504513530771111839
VERSION: 180000

But wait! We can do more. For the recovery, we don't even need a string
that spells out "RECOVERY:", we only need to know if the server is in
recovery or not. In short, a boolean. Patroni does this in its API with a
call of HEAD /replica. It returns a different HTTP code if the server is a
replica (200) or not a replica (503). We can do the same thing! What's
more, we can do it in a way that will allow existing calls to simply point
to the postgres server instead of a Patroni process, and get the same
result back, but faster. Here's an example of what that looks like:

## Calling Patroni
$ curl -s -w "%{http_code}" -o /dev/null -I http://localhost:8008/replica
200

## Calling Postgres directly
$ curl -s -w "%{http_code}" -o /dev/null -I http://localhost:5432/replica
200

Here's a simple Python program showing how easy it is to grab this
information:

import socket

try:
  with socket.create_connection(('localhost', 5432), timeout=1) as s:
    s.sendall(b'GET /sysid')
    print(s.recv(200).split(b'\r\n\r\n',1)[1].decode())
except Exception as e:
  print(f"Error: {e}")


That's the basic idea: proof of concept patch is attached. Additional
things to do:

* handling socket quirks (esp. Win32)
* docs (once details are hashed out)
* moving things around (everything is in one function right now for reading
ease)

Cheers,
Greg

--
Crunchy Data - https://www.crunchydata.com
Enterprise Postgres Software Products & Tech Support


Attachments:

  [application/octet-stream] 0001-Allow-specific-information-to-be-output-directly-by-Postgres.patch (11.3K, 3-0001-Allow-specific-information-to-be-output-directly-by-Postgres.patch)
  download | inline diff:
From fd58612f74a308aed3f0d3caf79250c0bfce2068 Mon Sep 17 00:00:00 2001
From: Greg Sabino Mullane <[email protected]>
Date: Tue, 27 May 2025 07:30:07 -0400
Subject: [PATCH] Allow specific information to be output directly by Postgres.

---
 src/backend/postmaster/postmaster.c           | 196 +++++++++++++++++-
 src/backend/utils/misc/guc_tables.c           |  27 +++
 src/backend/utils/misc/postgresql.conf.sample |   8 +
 src/include/postmaster/postmaster.h           |   4 +
 src/test/postmaster/t/004_exposed.pl          | 105 ++++++++++
 5 files changed, 339 insertions(+), 1 deletion(-)
 create mode 100644 src/test/postmaster/t/004_exposed.pl

diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c
index 490f7ce..5818f63 100644
--- a/src/backend/postmaster/postmaster.c
+++ b/src/backend/postmaster/postmaster.c
@@ -92,6 +92,7 @@
 #include "access/xlog.h"
 #include "access/xlog_internal.h"
 #include "access/xlogrecovery.h"
+#include "access/xlogutils.h"
 #include "common/file_perm.h"
 #include "common/pg_prng.h"
 #include "lib/ilist.h"
@@ -197,6 +198,9 @@ btmask_contains(BackendTypeMask mask, BackendType t)
 }
 
 
+int			total_expose = 0;
+
+
 BackgroundWorker *MyBgworkerEntry = NULL;
 
 /* The socket number we are listening for connections on */
@@ -247,6 +251,10 @@ char	   *bonjour_name;
 bool		restart_after_crash = true;
 bool		remove_temp_files_after_crash = true;
 
+bool		expose_recovery = false;
+bool		expose_sysid = false;
+bool		expose_version = false;
+
 /*
  * When terminating child processes after fatal errors, like a crash of a
  * child process, we normally send SIGQUIT -- and most other comments in this
@@ -453,6 +461,8 @@ static void StartSysLogger(void);
 static void StartAutovacuumWorker(void);
 static bool StartBackgroundWorker(RegisteredBgWorker *rw);
 static void InitPostmasterDeathWatchHandle(void);
+static bool ExposeInformation(int fd);
+
 
 #ifdef WIN32
 #define WNOHANG 0				/* ignored, so any integer value will do */
@@ -1699,7 +1709,13 @@ ServerLoop(void)
 				ClientSocket s;
 
 				if (AcceptConnection(events[i].fd, &s) == STATUS_OK)
-					BackendStartup(&s);
+				{
+					if ((expose_recovery || expose_sysid || expose_version)
+						&& ExposeInformation(s.sock))
+						total_expose++;
+					else
+						BackendStartup(&s);
+				}
 
 				/* We no longer need the open socket in this process */
 				if (s.sock != PGINVALID_SOCKET)
@@ -4616,3 +4632,181 @@ InitPostmasterDeathWatchHandle(void)
 								 GetLastError())));
 #endif							/* WIN32 */
 }
+
+
+static
+bool
+ExposeInformation(int fd)
+{
+
+/*
+ * 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 endpoint is recognized.
+ * Caller is responsible for closing the socket.
+ */
+
+#define EXPOSE_MIN_QUERY 9		/* Shortest possible line: "Get /info" */
+#define EXPOSE_MAX_QUERY 16		/* Longest possible GET line */
+
+/* What information is being returned */
+	typedef enum
+	{
+		EXPOSE_NOTHING,
+		EXPOSE_HEAD_REPLICA,
+		EXPOSE_GET_ALL,
+		EXPOSE_GET_REPLICA,
+		EXPOSE_GET_SYSID,
+		EXPOSE_GET_VERSION,
+	}			ReturnType;
+
+	typedef struct
+	{
+		const char *endpoint;
+		const bool *require;
+		ReturnType	type;
+	}			endpoint_action;
+
+	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];
+	int			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 not chance we are interested. 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 (
+			strncmp(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%lu\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_tables.c b/src/backend/utils/misc/guc_tables.c
index 2f8cbd8..38bea71 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -1701,6 +1701,33 @@ struct config_bool ConfigureNamesBool[] =
 		true,
 		NULL, NULL, NULL
 	},
+	{
+		{"expose_recovery", PGC_SIGHUP, CLIENT_CONN_STATEMENT,
+			gettext_noop("Exposes if the server is in recovery without a login."),
+			NULL
+		},
+		&expose_recovery,
+		false,
+		NULL, NULL, NULL
+	},
+	{
+		{"expose_sysid", PGC_SIGHUP, CLIENT_CONN_STATEMENT,
+			gettext_noop("Exposes the system identifier without a login."),
+			NULL
+		},
+		&expose_sysid,
+		false,
+		NULL, NULL, NULL
+	},
+	{
+		{"expose_version", PGC_SIGHUP, CLIENT_CONN_STATEMENT,
+			gettext_noop("Exposes the version without a login."),
+			NULL
+		},
+		&expose_version,
+		false,
+		NULL, NULL, NULL
+	},
 	{
 		{"array_nulls", PGC_USERSET, COMPAT_OPTIONS_PREVIOUS,
 			gettext_noop("Enables input of NULL elements in arrays."),
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 87ce76b..a16d372 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -91,6 +91,14 @@
 					# 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 92497cd..15662ce 100644
--- a/src/include/postmaster/postmaster.h
+++ b/src/include/postmaster/postmaster.h
@@ -70,6 +70,10 @@ 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/postmaster/t/004_exposed.pl b/src/test/postmaster/t/004_exposed.pl
new file mode 100644
index 0000000..97cc6b1
--- /dev/null
+++ b/src/test/postmaster/t/004_exposed.pl
@@ -0,0 +1,105 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test pre-fork HTTP endpoint information
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init();
+$node->start;
+
+SKIP:
+{
+	skip "this test requires working raw_connect()"
+	  unless $node->raw_connect_works();
+
+	my ($endpoint, $sock, $reply);
+
+	$endpoint = 'GET /info';
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	is($reply, '', "nothing is returned by $endpoint");
+	$sock->close();
+
+	$endpoint = 'HEAD /replica';
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	is($reply, '', "nothing is returned by $endpoint");
+
+	$endpoint = 'GET /replica';
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	is($reply, '', "nothing is returned by $endpoint");
+	$sock->close();
+
+	$node->append_conf('postgresql.conf', "expose_recovery = on");
+	$node->reload();
+
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	like($reply, qr[\r\n0\b], "recovery information is returned by $endpoint");
+	$sock->close();
+
+	$endpoint = 'GET /sysid';
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	is($reply, '', "nothing is returned by $endpoint");
+	$sock->close();
+
+	$node->append_conf('postgresql.conf', "expose_sysid = on");
+	$node->reload();
+
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	like($reply, qr[\r\n\d{10}], "system identifier is returned by $endpoint");
+	$sock->close();
+
+	$endpoint = 'GET /version';
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	is($reply, '', "nothing is returned by $endpoint");
+	$sock->close();
+
+	$node->append_conf('postgresql.conf', "expose_version = on");
+	$node->reload();
+
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	like($reply, qr[\r\n\d{6}\b], "version number is returned by $endpoint");
+	$sock->close();
+
+
+	$endpoint = 'GET /info';
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	like($reply, qr[RECOVERY: 0\b], "recovery is returned by $endpoint");
+	like($reply, qr[SYSID: \d{10}], "sysid is returned by $endpoint");
+	like($reply, qr[VERSION: \d{6}\b], "version number is returned by $endpoint");
+	$sock->close();
+
+	$endpoint = 'HEAD /replica';
+	$sock = $node->raw_connect();
+	$sock->send($endpoint);
+	$sock->recv($reply, 200);
+	like($reply, qr[503 Service Unavailable], "503 returned by $endpoint");
+	$sock->close();
+
+	## Not working yet:
+	## $node->backup('testbackup1');
+
+}
+
+done_testing();
-- 
2.30.2



^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2025-05-30 15:02  Antonin Houska <[email protected]>
  parent: Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Antonin Houska @ 2025-05-30 15:02 UTC (permalink / raw)
  To: Greg Sabino Mullane <[email protected]>; +Cc: [email protected] <[email protected]>

Greg Sabino Mullane <[email protected]> wrote:

> Proposal: Allow a carefully curated selection of information to be shown without authentication.
> 
> A common task for an HA system or a load balancer is to quickly determine which of your Postgres clusters is the primary, and which are the
> replicas. The canonical way to do this is to log in to each server with a valid username and password, and then run pg_is_in_recovery().
> That's a lot of work to determine if a server is a replica or not, and it struck me that this true/false information about a running cluster is not
> super-sensitive information. In other words, would it really be wrong if there was a way to advertise that information without having to log in?
> I toyed with the idea of Postgres maintaining some sort of signal file, but then I realized that we already have a process, listening on a known
> port, that has that information available to us.
> 
> Thus, this POC (proof of concept), which lets the postmaster scan for incoming requests and quickly handle them *before* doing forking and
> authenticating. We scan for a simple trigger string, and immediately return the information to the client.

Why is it important not to fork?  My understanding is that pg_is_ready also
tries to start a regular connection, i.e. forks a new backend. I think this
functionality would fit into libpq. (I've got no strong opinion on the amount
of information to be revealed this way. In any case, a GUC to enable the
feature only if the DBA wants it makes sense.)

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com





^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2025-05-31 00:13  Greg Sabino Mullane <[email protected]>
  parent: Antonin Houska <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Greg Sabino Mullane @ 2025-05-31 00:13 UTC (permalink / raw)
  To: Antonin Houska <[email protected]>; +Cc: [email protected] <[email protected]>

On Fri, May 30, 2025 at 11:02 AM Antonin Houska <[email protected]> wrote:

> Why is it important not to fork?


Good question. Forking is expensive, and there is also a lot of
housekeeping associated with it that is simply not needed here. We want
this to be lightweight, and simple. No need to fork if we are just going to
do a few strncmp() calls and a send(). However, I'm not highly opposed to
fork-first, as I understand that we want to not slow down postmaster. My
testing showed a barely measurable impact, but I will defer to whatever
decision the elder Postgres gods decide on.


> My understanding is that pg_is_ready also tries to start a regular
> connection, i.e. forks a new backend.


Yep. I consider pg_isready a spiritual cousin to this feature, but it's not
something that can really do what this does.

Cheers,
Greg

--
Crunchy Data - https://www.crunchydata.com
Enterprise Postgres Software Products & Tech Support


^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2025-05-31 01:34  Tom Lane <[email protected]>
  parent: Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Tom Lane @ 2025-05-31 01:34 UTC (permalink / raw)
  To: Greg Sabino Mullane <[email protected]>; +Cc: Antonin Houska <[email protected]>; [email protected] <[email protected]>

Greg Sabino Mullane <[email protected]> writes:
> Good question. Forking is expensive, and there is also a lot of
> housekeeping associated with it that is simply not needed here. We want
> this to be lightweight, and simple. No need to fork if we are just going to
> do a few strncmp() calls and a send().

send() can block.  I think calling it in the postmaster is a
nonstarter.  For comparison, we make an effort to not do any
communication with incoming clients until after forking a child
to do the communication.  The one exception is if we have to
report fork failure --- but we don't make any strong guarantees
about that report succeeding.  (IIRC, we put the port into nonblock
mode and try only once.)  That's probably not a behavior you want
to adopt for non-edge-case usages.

Another point is that you'll recall that there's a lot of
interest in switching to a threaded model.  The argument that
"fork is too expensive" may not have a long shelf life.

I'm not taking a position on whether $SUBJECT is a good idea
in the first place.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2025-05-31 04:48  Greg Sabino Mullane <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Greg Sabino Mullane @ 2025-05-31 04:48 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Antonin Houska <[email protected]>; [email protected] <[email protected]>

On Fri, May 30, 2025 at 9:34 PM Tom Lane <[email protected]> wrote:

> I think calling it in the postmaster is a nonstarter.


Thanks for the feedback. Please find attached version two, which moves the
code to the very start of BackendInitialize in
tcop/backend_startup.c. If we handle the request, we simply proc_exit and
avoid all the other backend startup stuff. So still a big win. I also made
a first rough pass at the documentation.

Cheers,
Greg

--
Crunchy Data - https://www.crunchydata.com
Enterprise Postgres Software Products & Tech Support


Attachments:

  [application/octet-stream] 0002-Allow-specific-information-to-be-output-directly-by-Postgres.patch (11.7K, 3-0002-Allow-specific-information-to-be-output-directly-by-Postgres.patch)
  download | inline diff:
From dab6ea7fd21aafbf5f87b163903e54f129f45e0b Mon Sep 17 00:00:00 2001
From: Greg Sabino Mullane <[email protected]>
Date: Sat, 31 May 2025 00:38:26 -0400
Subject: [PATCH] Allow specific information to be output directly by Postgres.

---
 doc/src/sgml/config.sgml                      |  78 +++++++
 src/backend/tcop/backend_startup.c            | 191 ++++++++++++++++++
 src/backend/utils/misc/guc_tables.c           |  27 +++
 src/backend/utils/misc/postgresql.conf.sample |   8 +
 src/include/postmaster/postmaster.h           |   4 +
 5 files changed, 308 insertions(+)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index f4a0191c55b..5774d78740f 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1077,6 +1077,84 @@ include_dir 'conf.d'
      </variablelist>
      </sect2>
 
+     <sect2 id="runtime-config-expose-settings">
+     <title>Expose Settings</title>
+
+     <variablelist>
+
+     <varlistentry id="guc-expose-recovery" xreflabel="expose_recovery">
+      <term><varname>expose_recovery</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_recovery</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting if the server is in recovery mode without requiring
+        an authenticated login. Clients can send the string <literal>GET /replica</literal>
+        and will receive a 1 or 0. This is equivalent to logging in and running
+        <literal>SELECT pg_is_in_recovery()</literal>. A client can also send the
+        string <literal>HEAD /replica</literal> which will solely return an HTTP literal:
+        <literal>200</literal> if the server is in recovery, <literal>503</literal> if not.
+        (This allows a drop-in replacement to the same Patroni functionality)
+        Finally, a client can issue <literal>GET /info</literal> and receive the string
+        <literal>RECOVERY: </literal> followed by a 1 or 0.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="guc-expose-sysid" xreflabel="expose_sysid">
+      <term><varname>expose_sysid</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_sysid</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting the system identifier of the cluster without requiring
+        an authenticated login. Clients can send the string <literal>GET /sysid</literal>
+        and will receive the numeric system identifier. This is a unique number generated
+        by each cluster when initdb is run.
+       </para>
+       <para>
+        A client can issue <literal>GET /info</literal> and receive the string
+        <literal>SYSID: </literal> followed by the numeric system identifier.
+       </para>
+       <para>
+        This feature is useful for determining if the server is the same server as previously
+        encountered. Note than primary and replica servers will share the same system
+        identifier.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="guc-expose-version" xreflabel="expose_version">
+      <term><varname>expose_version</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_version</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting the numeric version of the Postgres cluster without requiring
+        an authenticated login. Clients can send the string <literal>GET /version</literal>
+        and will receive an integer representing the version.
+       </para>
+       <para>
+        A client can issue <literal>GET /info</literal> and receive the string
+        <literal>VERSION: </literal> followed by the numeric version.
+       </para>
+       <para>
+        This is particularly useful for non-Postgres systems (esp. security scanners) that
+        need a way to easily determine the version of Postgres in use without requiring
+        a Postgres client - or without needing any knowledge of the Postgres protocol at all.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     </variablelist>
+     </sect2>
+
      <sect2 id="runtime-config-connection-authentication">
      <title>Authentication</title>
 
diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c
index a7d1fec981f..ce8a5087ec6 100644
--- a/src/backend/tcop/backend_startup.c
+++ b/src/backend/tcop/backend_startup.c
@@ -46,6 +46,10 @@
 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;
+
 
 /* Other globals */
 
@@ -65,6 +69,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 +153,15 @@ BackendInitialize(ClientSocket *client_sock, CAC_state cac)
 	StringInfoData ps_data;
 	MemoryContext oldcontext;
 
+	/*
+	 * Possibly 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))
+		proc_exit(0);
+	/* Should we do exit(0) here, despite the warnings in ipc.c? */
+
 	/* Tell fd.c about the long-lived FD associated with the client_sock */
 	ReserveExternalFD();
 
@@ -1113,3 +1127,180 @@ assign_log_connections(const char *newval, void *extra)
 {
 	log_connections = *((int *) extra);
 }
+
+
+static
+bool
+ExposeInformation(pgsocket fd)
+{
+
+/*
+ * 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 endpoint is recognized.
+ */
+
+#define EXPOSE_MIN_QUERY 9		/* Shortest possible line: "Get /info" */
+#define EXPOSE_MAX_QUERY 16		/* Longest possible GET line */
+
+/* What information is being returned */
+	typedef enum
+	{
+		EXPOSE_NOTHING,
+		EXPOSE_HEAD_REPLICA,
+		EXPOSE_GET_ALL,
+		EXPOSE_GET_REPLICA,
+		EXPOSE_GET_SYSID,
+		EXPOSE_GET_VERSION,
+	}			ReturnType;
+
+	typedef struct
+	{
+		const char *endpoint;
+		const bool *require;
+		ReturnType	type;
+	}			endpoint_action;
+
+	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];
+	int			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 not chance we are interested. 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 (
+			strncmp(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%lu\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_tables.c b/src/backend/utils/misc/guc_tables.c
index 2f8cbd86759..38bea719011 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -1701,6 +1701,33 @@ struct config_bool ConfigureNamesBool[] =
 		true,
 		NULL, NULL, NULL
 	},
+	{
+		{"expose_recovery", PGC_SIGHUP, CLIENT_CONN_STATEMENT,
+			gettext_noop("Exposes if the server is in recovery without a login."),
+			NULL
+		},
+		&expose_recovery,
+		false,
+		NULL, NULL, NULL
+	},
+	{
+		{"expose_sysid", PGC_SIGHUP, CLIENT_CONN_STATEMENT,
+			gettext_noop("Exposes the system identifier without a login."),
+			NULL
+		},
+		&expose_sysid,
+		false,
+		NULL, NULL, NULL
+	},
+	{
+		{"expose_version", PGC_SIGHUP, CLIENT_CONN_STATEMENT,
+			gettext_noop("Exposes the version without a login."),
+			NULL
+		},
+		&expose_version,
+		false,
+		NULL, NULL, NULL
+	},
 	{
 		{"array_nulls", PGC_USERSET, COMPAT_OPTIONS_PREVIOUS,
 			gettext_noop("Enables input of NULL elements in arrays."),
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 87ce76b18f4..a16d3726824 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -91,6 +91,14 @@
 					# 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 92497cd6a0f..15662ce0059 100644
--- a/src/include/postmaster/postmaster.h
+++ b/src/include/postmaster/postmaster.h
@@ -70,6 +70,10 @@ 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;
-- 
2.30.2



^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2025-10-03 02:23  Greg Sabino Mullane <[email protected]>
  parent: Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Greg Sabino Mullane @ 2025-10-03 02:23 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Antonin Houska <[email protected]>; [email protected] <[email protected]>

Please find attached version 3, rebased for PG 19 and now featuring some
tests.

Cheers,
Greg


Attachments:

  [application/octet-stream] 0003-Allow-specific-information-to-be-output-directly-by-Postgres.patch (16.2K, 3-0003-Allow-specific-information-to-be-output-directly-by-Postgres.patch)
  download | inline diff:
From 424d55f3a3164dabcfb8cdf9fe6cd8b7d339b9d5 Mon Sep 17 00:00:00 2001
From: Greg Sabino Mullane <[email protected]>
Date: Sat, 31 May 2025 00:38:26 -0400
Subject: [PATCH] Allow specific information to be output directly by Postgres.

---
 doc/src/sgml/config.sgml                      |  78 +++++++
 src/backend/tcop/backend_startup.c            | 191 ++++++++++++++++++
 src/backend/utils/misc/guc_parameters.dat     |  18 ++
 src/backend/utils/misc/postgresql.conf.sample |   8 +
 src/include/postmaster/postmaster.h           |   4 +
 src/test/modules/test_misc/meson.build        |  1 +
 src/test/modules/test_misc/t/009_expose.pl    | 91 ++++++++++++++++++++++
 7 files changed, 391 insertions(+)
 create mode 100644 src/test/modules/test_misc/t/009_expose.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index e9b420f3ddb..a3d4b388856 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1077,6 +1077,84 @@ include_dir 'conf.d'
      </variablelist>
      </sect2>
 
+     <sect2 id="runtime-config-expose-settings">
+     <title>Expose Settings</title>
+
+     <variablelist>
+
+     <varlistentry id="guc-expose-recovery" xreflabel="expose_recovery">
+      <term><varname>expose_recovery</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_recovery</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting if the server is in recovery mode without requiring
+        an authenticated login. Clients can send the string <literal>GET /replica</literal>
+        and will receive a 1 or 0. This is equivalent to logging in and running
+        <literal>SELECT pg_is_in_recovery()</literal>. A client can also send the
+        string <literal>HEAD /replica</literal> which will solely return an HTTP literal:
+        <literal>200</literal> if the server is in recovery, <literal>503</literal> if not.
+        (This allows a drop-in replacement to the same Patroni functionality)
+        Finally, a client can issue <literal>GET /info</literal> and receive the string
+        <literal>RECOVERY: </literal> followed by a 1 or 0.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="guc-expose-sysid" xreflabel="expose_sysid">
+      <term><varname>expose_sysid</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_sysid</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting the system identifier of the cluster without requiring
+        an authenticated login. Clients can send the string <literal>GET /sysid</literal>
+        and will receive the numeric system identifier. This is a unique number generated
+        by each cluster when initdb is run.
+       </para>
+       <para>
+        A client can issue <literal>GET /info</literal> and receive the string
+        <literal>SYSID: </literal> followed by the numeric system identifier.
+       </para>
+       <para>
+        This feature is useful for determining if the server is the same server as previously
+        encountered. Note than primary and replica servers will share the same system
+        identifier.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="guc-expose-version" xreflabel="expose_version">
+      <term><varname>expose_version</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_version</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting the numeric version of the Postgres cluster without requiring
+        an authenticated login. Clients can send the string <literal>GET /version</literal>
+        and will receive an integer representing the version.
+       </para>
+       <para>
+        A client can issue <literal>GET /info</literal> and receive the string
+        <literal>VERSION: </literal> followed by the numeric version.
+       </para>
+       <para>
+        This is particularly useful for non-Postgres systems (esp. security scanners) that
+        need a way to easily determine the version of Postgres in use without requiring
+        a Postgres client - or without needing any knowledge of the Postgres protocol at all.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     </variablelist>
+     </sect2>
+
      <sect2 id="runtime-config-connection-authentication">
      <title>Authentication</title>
 
diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c
index 14d5fc0b196..c69a0727693 100644
--- a/src/backend/tcop/backend_startup.c
+++ b/src/backend/tcop/backend_startup.c
@@ -46,6 +46,10 @@
 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;
+
 
 /* Other globals */
 
@@ -65,6 +69,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 +153,15 @@ 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))
+		proc_exit(0);
+	/* Should we do exit(0) here, despite the warnings in ipc.c? */
+
 	/* Tell fd.c about the long-lived FD associated with the client_sock */
 	ReserveExternalFD();
 
@@ -1126,3 +1140,180 @@ assign_log_connections(const char *newval, void *extra)
 {
 	log_connections = *((int *) extra);
 }
+
+
+static
+bool
+ExposeInformation(pgsocket fd)
+{
+
+/*
+ * 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.
+ */
+
+#define EXPOSE_MIN_QUERY 9		/* Shortest possible line: "Get /info" */
+#define EXPOSE_MAX_QUERY 16		/* Longest possible GET line */
+
+/* What information is being returned */
+	typedef enum
+	{
+		EXPOSE_NOTHING,
+		EXPOSE_HEAD_REPLICA,
+		EXPOSE_GET_ALL,
+		EXPOSE_GET_REPLICA,
+		EXPOSE_GET_SYSID,
+		EXPOSE_GET_VERSION,
+	}			ReturnType;
+
+	typedef struct
+	{
+		const char *endpoint;
+		const bool *require;
+		ReturnType	type;
+	}			endpoint_action;
+
+	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];
+	int			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
+	 * 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 (
+			strncmp(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%lu\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 6bc6be13d2a..3accf6bec9f 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -637,6 +637,24 @@
   boot_val => 'true',
 },
 
+{ name => 'expose_recovery', type => 'bool', context => 'PGC_SIGHUP', group => 'CLIENT_CONN_STATEMENT',
+  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 => 'CLIENT_CONN_STATEMENT',
+  short_desc => 'Exposes the system identifier without a login.',
+  variable => 'expose_sysid',
+  boot_val => 'false',
+},
+
+{ name => 'expose_version', type => 'bool', context => 'PGC_SIGHUP', group => 'CLIENT_CONN_STATEMENT',
+  short_desc => 'Exposes the server version without a login.',
+  variable => 'expose_version',
+  boot_val => 'false',
+},
+
 { name => 'array_nulls', type => 'bool', context => 'PGC_USERSET', group => 'COMPAT_OPTIONS_PREVIOUS',
   short_desc => 'Enables input of NULL elements in arrays.',
   long_desc => 'When turned on, unquoted NULL in an array input value means a null value; otherwise it is taken literally.',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index c36fcb9ab61..8e8be2cf3af 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -91,6 +91,14 @@
 					# 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 753871071ac..ee1ca2fca36 100644
--- a/src/include/postmaster/postmaster.h
+++ b/src/include/postmaster/postmaster.h
@@ -70,6 +70,10 @@ 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 6b1e730bf46..6fe8ac4a9d1 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -17,6 +17,7 @@ tests += {
       't/006_signal_autovacuum.pl',
       't/007_catcache_inval.pl',
       't/008_replslot_single_user.pl',
+      't/009_expose.pl',
     ],
   },
 }
diff --git a/src/test/modules/test_misc/t/009_expose.pl b/src/test/modules/test_misc/t/009_expose.pl
new file mode 100644
index 00000000000..f9363d0bc04
--- /dev/null
+++ b/src/test/modules/test_misc/t/009_expose.pl
@@ -0,0 +1,91 @@
+# Copyright (c) 2025, 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;
+use LWP::UserAgent;
+
+my $node = PostgreSQL::Test::Cluster->new('node1');
+# Setting as logical here simply to avoid wal_level minimal so we can restart as a replica
+$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 $port = $node->port;
+my $URI="http://localhost:$port";
+my $ua = LWP::UserAgent->new(timeout => 2);
+
+my ($response, $code, $content);
+
+$response = $ua->get("$URI/info");
+$code = $response->code;
+is ($code, 500, "GET /info returns HTTP code 500 when nothing is listening");
+
+$response = $ua->head("$URI/replica");
+$code = $response->code;
+is ($code, 500, "HEAD /replica returns HTTP code 500 when nothing is listening");
+
+$node->append_conf('postgresql.conf', 'expose_recovery=on');
+$node->reload();
+
+$response = $ua->get("$URI/replica");
+$code = $response->code;
+is ($code, 200, "GET /replica returns HTTP code 200 when expose_recovery is on (primary)");
+is ($response->content, "0\r\n", "GET /replica returns '0' when expose_recovery is on (primary)");
+
+$response = $ua->head("$URI/replica");
+$code = $response->code;
+is ($code, 503, "HEAD /info returns HTTP code 503 when expose_recovery is on (primary)");
+
+$response = $ua->get("$URI/info");
+$code = $response->code;
+is ($code, 200, "GET /info returns HTTP code 200 when expose_recovery is on");
+is ($response->content, "RECOVERY: 0\r\n", "GET /info returns 'RECOVERY:0' when expose_recovery is on (primary)");
+
+$node->append_conf('postgresql.conf', 'expose_version=on');
+$node->append_conf('postgresql.conf', 'expose_sysid=on');
+$node->reload();
+
+$response = $ua->get("$URI/info");
+$content = $response->content;
+like ($content, qr/RECOVERY: 0/, "GET /info returns 'RECOVERY: 0' when expose_recovery is on (primary)");
+like ($content, qr/VERSION: $server_version/, "GET /info returns 'VERSION: $server_version' when expose_version is on");
+like ($content, qr/SYSID: $sysid/, "GET /info returns correct SYSID when expose_sysid is on");
+
+$response = $ua->get("$URI/sysid");
+$content = $response->content;
+is ($content, "$sysid\r\n", "GET /sysid returns correct value when expose_sysid is on");
+
+$response = $ua->get("$URI/version");
+$content = $response->content;
+is ($content, "$server_version\r\n", "GET /version returns correct value when expose_version is on");
+
+$node->set_standby_mode();
+$node->restart();
+
+$response = $ua->get("$URI/replica");
+$code = $response->code;
+is ($code, 200, "GET /replica returns HTTP code 200 when expose_recovery is on (replica)");
+is ($response->content, "1\r\n", "GET /replica returns '0' when expose_recovery is on (replica)");
+
+$response = $ua->head("$URI/replica");
+$code = $response->code;
+is ($code, 200, "HEAD /info returns HTTP code 200 when expose_recovery is on (replica)");
+
+$response = $ua->get("$URI/info");
+$content = $response->content;
+like ($content, qr/RECOVERY: 1/, "GET /info returns 'RECOVERY: 1' when expose_recovery is on (replica)");
+
+done_testing();
-- 
2.30.2


^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2025-10-23 09:57  Greg Sabino Mullane <[email protected]>
  parent: Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Greg Sabino Mullane @ 2025-10-23 09:57 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Antonin Houska <[email protected]>; [email protected] <[email protected]>

Version 4 attached, rebased to account for new tests, plus a new
instra-test check to make sure LWP::UserAgent is available before running.

Cheers,
Greg


Attachments:

  [application/octet-stream] 0004-Allow-specific-information-to-be-output-directly-by-Postgres.patch (16.3K, 3-0004-Allow-specific-information-to-be-output-directly-by-Postgres.patch)
  download | inline diff:
From d72be5518e7cc4750ef4986dd25677e764c9b27b Mon Sep 17 00:00:00 2001
From: Greg Sabino Mullane <[email protected]>
Date: Sat, 31 May 2025 00:38:26 -0400
Subject: [PATCH] Allow specific information to be output directly by Postgres.

---
 doc/src/sgml/config.sgml                      |  78 +++++++
 src/backend/tcop/backend_startup.c            | 191 ++++++++++++++++++
 src/backend/utils/misc/guc_parameters.dat     |  18 ++
 src/backend/utils/misc/postgresql.conf.sample |   8 +
 src/include/postmaster/postmaster.h           |   4 +
 src/test/modules/test_misc/meson.build        |   1 +
 src/test/modules/test_misc/t/010_expose.pl    |  91 +++++++++
 7 files changed, 391 insertions(+)
 create mode 100644 src/test/modules/test_misc/t/010_expose.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 0a2a8b49fdb..f2002a0607c 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1077,6 +1077,84 @@ include_dir 'conf.d'
      </variablelist>
      </sect2>
 
+     <sect2 id="runtime-config-expose-settings">
+     <title>Expose Settings</title>
+
+     <variablelist>
+
+     <varlistentry id="guc-expose-recovery" xreflabel="expose_recovery">
+      <term><varname>expose_recovery</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_recovery</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting if the server is in recovery mode without requiring
+        an authenticated login. Clients can send the string <literal>GET /replica</literal>
+        and will receive a 1 or 0. This is equivalent to logging in and running
+        <literal>SELECT pg_is_in_recovery()</literal>. A client can also send the
+        string <literal>HEAD /replica</literal> which will solely return an HTTP literal:
+        <literal>200</literal> if the server is in recovery, <literal>503</literal> if not.
+        (This allows a drop-in replacement to the same Patroni functionality)
+        Finally, a client can issue <literal>GET /info</literal> and receive the string
+        <literal>RECOVERY: </literal> followed by a 1 or 0.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="guc-expose-sysid" xreflabel="expose_sysid">
+      <term><varname>expose_sysid</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_sysid</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting the system identifier of the cluster without requiring
+        an authenticated login. Clients can send the string <literal>GET /sysid</literal>
+        and will receive the numeric system identifier. This is a unique number generated
+        by each cluster when initdb is run.
+       </para>
+       <para>
+        A client can issue <literal>GET /info</literal> and receive the string
+        <literal>SYSID: </literal> followed by the numeric system identifier.
+       </para>
+       <para>
+        This feature is useful for determining if the server is the same server as previously
+        encountered. Note than primary and replica servers will share the same system
+        identifier.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="guc-expose-version" xreflabel="expose_version">
+      <term><varname>expose_version</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>expose_version</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Enables reporting the numeric version of the Postgres cluster without requiring
+        an authenticated login. Clients can send the string <literal>GET /version</literal>
+        and will receive an integer representing the version.
+       </para>
+       <para>
+        A client can issue <literal>GET /info</literal> and receive the string
+        <literal>VERSION: </literal> followed by the numeric version.
+       </para>
+       <para>
+        This is particularly useful for non-Postgres systems (esp. security scanners) that
+        need a way to easily determine the version of Postgres in use without requiring
+        a Postgres client - or without needing any knowledge of the Postgres protocol at all.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     </variablelist>
+     </sect2>
+
      <sect2 id="runtime-config-connection-authentication">
      <title>Authentication</title>
 
diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c
index 14d5fc0b196..b5d96d88307 100644
--- a/src/backend/tcop/backend_startup.c
+++ b/src/backend/tcop/backend_startup.c
@@ -46,6 +46,10 @@
 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;
+
 
 /* Other globals */
 
@@ -65,6 +69,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 +153,15 @@ 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))
+		proc_exit(0);
+	/* Should we do exit(0) here, despite the warnings in ipc.c? */
+
 	/* Tell fd.c about the long-lived FD associated with the client_sock */
 	ReserveExternalFD();
 
@@ -1126,3 +1140,180 @@ assign_log_connections(const char *newval, void *extra)
 {
 	log_connections = *((int *) extra);
 }
+
+
+static
+bool
+ExposeInformation(pgsocket fd)
+{
+
+/*
+ * 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.
+ */
+
+#define EXPOSE_MIN_QUERY 9		/* Shortest possible line: "Get /info" */
+#define EXPOSE_MAX_QUERY 16		/* Longest possible GET line */
+
+/* What information is being returned */
+	typedef enum
+	{
+		EXPOSE_NOTHING,
+		EXPOSE_HEAD_REPLICA,
+		EXPOSE_GET_ALL,
+		EXPOSE_GET_REPLICA,
+		EXPOSE_GET_SYSID,
+		EXPOSE_GET_VERSION,
+	}			ReturnType;
+
+	typedef struct
+	{
+		const char *endpoint;
+		const bool *require;
+		ReturnType	type;
+	}			endpoint_action;
+
+	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];
+	int			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
+	 * 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 (
+			strncmp(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%lu\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 d6fc8333850..86292b6708e 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -644,6 +644,24 @@
   boot_val => 'true',
 },
 
+{ name => 'expose_recovery', type => 'bool', context => 'PGC_SIGHUP', group => 'CLIENT_CONN_STATEMENT',
+  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 => 'CLIENT_CONN_STATEMENT',
+  short_desc => 'Exposes the system identifier without a login.',
+  variable => 'expose_sysid',
+  boot_val => 'false',
+},
+
+{ name => 'expose_version', type => 'bool', context => 'PGC_SIGHUP', group => 'CLIENT_CONN_STATEMENT',
+  short_desc => 'Exposes the server version without a login.',
+  variable => 'expose_version',
+  boot_val => 'false',
+},
+
 { name => 'array_nulls', type => 'bool', context => 'PGC_USERSET', group => 'COMPAT_OPTIONS_PREVIOUS',
   short_desc => 'Enables input of NULL elements in arrays.',
   long_desc => 'When turned on, unquoted NULL in an array input value means a null value; otherwise it is taken literally.',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index f62b61967ef..19437471c80 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -91,6 +91,14 @@
 					# 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 753871071ac..ee1ca2fca36 100644
--- a/src/include/postmaster/postmaster.h
+++ b/src/include/postmaster/postmaster.h
@@ -70,6 +70,10 @@ 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 f258bf1ccd9..726bd93908f 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -18,6 +18,7 @@ tests += {
       't/007_catcache_inval.pl',
       't/008_replslot_single_user.pl',
       't/009_log_temp_files.pl',
+      't/010_expose.pl',
     ],
   },
 }
diff --git a/src/test/modules/test_misc/t/010_expose.pl b/src/test/modules/test_misc/t/010_expose.pl
new file mode 100644
index 00000000000..f9363d0bc04
--- /dev/null
+++ b/src/test/modules/test_misc/t/010_expose.pl
@@ -0,0 +1,97 @@
+# Copyright (c) 2025, 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;
+
+eval {
+    require LWP::UserAgent;
+};
+if ($@) {
+    plan skip_all => 'LWP::UserAgent is needed to run this test';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('node1');
+# Setting as logical here simply to avoid wal_level minimal so we can restart as a replica
+$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 $port = $node->port;
+my $URI="http://localhost:$port";
+my $ua = LWP::UserAgent->new(timeout => 2);
+
+my ($response, $code, $content);
+
+$response = $ua->get("$URI/info");
+$code = $response->code;
+is ($code, 500, "GET /info returns HTTP code 500 when nothing is listening");
+
+$response = $ua->head("$URI/replica");
+$code = $response->code;
+is ($code, 500, "HEAD /replica returns HTTP code 500 when nothing is listening");
+
+$node->append_conf('postgresql.conf', 'expose_recovery=on');
+$node->reload();
+
+$response = $ua->get("$URI/replica");
+$code = $response->code;
+is ($code, 200, "GET /replica returns HTTP code 200 when expose_recovery is on (primary)");
+is ($response->content, "0\r\n", "GET /replica returns '0' when expose_recovery is on (primary)");
+
+$response = $ua->head("$URI/replica");
+$code = $response->code;
+is ($code, 503, "HEAD /info returns HTTP code 503 when expose_recovery is on (primary)");
+
+$response = $ua->get("$URI/info");
+$code = $response->code;
+is ($code, 200, "GET /info returns HTTP code 200 when expose_recovery is on");
+is ($response->content, "RECOVERY: 0\r\n", "GET /info returns 'RECOVERY:0' when expose_recovery is on (primary)");
+
+$node->append_conf('postgresql.conf', 'expose_version=on');
+$node->append_conf('postgresql.conf', 'expose_sysid=on');
+$node->reload();
+
+$response = $ua->get("$URI/info");
+$content = $response->content;
+like ($content, qr/RECOVERY: 0/, "GET /info returns 'RECOVERY: 0' when expose_recovery is on (primary)");
+like ($content, qr/VERSION: $server_version/, "GET /info returns 'VERSION: $server_version' when expose_version is on");
+like ($content, qr/SYSID: $sysid/, "GET /info returns correct SYSID when expose_sysid is on");
+
+$response = $ua->get("$URI/sysid");
+$content = $response->content;
+is ($content, "$sysid\r\n", "GET /sysid returns correct value when expose_sysid is on");
+
+$response = $ua->get("$URI/version");
+$content = $response->content;
+is ($content, "$server_version\r\n", "GET /version returns correct value when expose_version is on");
+
+$node->set_standby_mode();
+$node->restart();
+
+$response = $ua->get("$URI/replica");
+$code = $response->code;
+is ($code, 200, "GET /replica returns HTTP code 200 when expose_recovery is on (replica)");
+is ($response->content, "1\r\n", "GET /replica returns '0' when expose_recovery is on (replica)");
+
+$response = $ua->head("$URI/replica");
+$code = $response->code;
+is ($code, 200, "HEAD /info returns HTTP code 200 when expose_recovery is on (replica)");
+
+$response = $ua->get("$URI/info");
+$content = $response->content;
+like ($content, qr/RECOVERY: 1/, "GET /info returns 'RECOVERY: 1' when expose_recovery is on (replica)");
+
+done_testing();
-- 
2.47.3



^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2026-01-09 13:56  Antonin Houska <[email protected]>
  parent: Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Antonin Houska @ 2026-01-09 13:56 UTC (permalink / raw)
  To: Greg Sabino Mullane <[email protected]>; +Cc: Tom Lane <[email protected]>; [email protected] <[email protected]>

Greg Sabino Mullane <[email protected]> wrote:

> Version 4 attached, rebased to account for new tests, plus a new instra-test
> check to make sure LWP::UserAgent is available before running.

I'm still not sure it's necessary to handle the problem at socket level. I
imagine it can be implemented this way:

1. Add a new field to the PGconn structure, indicating that the client is only
requesting the server status information, and adjust pg_isready so it sets
this option.

2. Adjust libpq frontend (pqBuildStartupPacket3) so it adds the corresponding
option to the startup packet.

3. On server, if ProcessStartupPacket() sees that option, call ereport(FATAL)
with a specific error code, and let the appropriate GUCs control the contents
of the error message. pg_isready would then just print out the message.

I haven't tried to write any code, so it's possible that I'm missing
something.

Regarding configuration, I'd prefer a single GUC. The value can be a
comma-separated list of keywords, each representing particular piece of
information to be exposed.

-- 
Antonin Houska
Web: https://www.cybertec-postgresql.com






^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2026-01-09 14:15  Greg Sabino Mullane <[email protected]>
  parent: Antonin Houska <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Greg Sabino Mullane @ 2026-01-09 14:15 UTC (permalink / raw)
  To: Antonin Houska <[email protected]>; +Cc: Tom Lane <[email protected]>; [email protected] <[email protected]>

On Fri, Jan 9, 2026 at 8:56 AM Antonin Houska <[email protected]> wrote:

> 1. Add a new field to the PGconn structure


This kind of defeats one of the major strengths of this patch, which is
allowing systems that don't speak the protocol to get at this information.


> Regarding configuration, I'd prefer a single GUC. The value can be a
> comma-separated list of keywords, each representing particular piece of
> information to be exposed.
>

Yes, I could see some advantages to that, although I still like the
simplicity of separate boolean values. I've no strong feelings either way.
Let's see if others weigh in.

Thanks for looking over this patch!

Cheers,
Greg


^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2026-02-17 19:42  Greg Sabino Mullane <[email protected]>
  parent: Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Greg Sabino Mullane @ 2026-02-17 19:42 UTC (permalink / raw)
  To: Antonin Houska <[email protected]>; +Cc: Tom Lane <[email protected]>; [email protected] <[email protected]>

Please find attached a rebased and lightly reworked version of this patch.
The most significant change is the test file now uses IO::Socket::INET via
$node->raw_connect. Also changed to allow case-insensitive calls, moved to
a better docs group, moved the defines and typedefs up, and changed the
exit to just a simple _exit()

Cheers,
Greg


Attachments:

  [application/octet-stream] 0005-Allow-specific-information-to-be-output-directly-by-Postgres.patch (13.1K, 3-0005-Allow-specific-information-to-be-output-directly-by-Postgres.patch)
  download | inline diff:
From 438d935387a9b58c85c2d566ca18a1ca60910832 Mon Sep 17 00:00:00 2001
From: Greg Sabino Mullane <[email protected]>
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



^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2026-02-17 19:58  Andres Freund <[email protected]>
  parent: Greg Sabino Mullane <[email protected]>
  0 siblings, 1 reply; 12+ messages in thread

From: Andres Freund @ 2026-02-17 19:58 UTC (permalink / raw)
  To: Greg Sabino Mullane <[email protected]>; +Cc: Antonin Houska <[email protected]>; Tom Lane <[email protected]>; [email protected] <[email protected]>

Hi,

On 2026-02-17 14:42:48 -0500, Greg Sabino Mullane wrote:
> Subject: [PATCH] Allow specific information to be output directly by Postgres.

I strongly encourage you to include a justification for why this is desirable,
so a casual reviewer doesn't have to reread the thread.


> @@ -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();
>

What about direct TLS connections?

How can a cluster coordinator trust unauthenticated plain text communication
that can just be man-in-the-middled?


It's not obvious that it's a good idea to expose this on the same socket as
normal client connections.  IMO you'd want to limit this to a smaller set of
interfaces than normal client connections.



> +/*
> + * 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;

IIRC the socket is in blocking mode at this point (that's only changed in
pq_init()), therefore this might actually block?  While it's unlikely, I don't
see any guarantee that a single receive would actually get the whole message
from the client either, so this seems like it might fail spuriously.




> 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',
> +},
> +

If we were to do this, I'd recommend a single expose GUC that has the
different values as a comma separated list, instead a growing list of GUCs.

Greetings,

Andres Freund






^ permalink  raw  reply  [nested|flat] 12+ messages in thread

* Re: POC: Carefully exposing information without authentication
@ 2026-03-25 01:59  Greg Sabino Mullane <[email protected]>
  parent: Andres Freund <[email protected]>
  0 siblings, 0 replies; 12+ messages in thread

From: Greg Sabino Mullane @ 2026-03-25 01:59 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; +Cc: Antonin Houska <[email protected]>; Tom Lane <[email protected]>; [email protected] <[email protected]>

Thank you for looking over this. New version attached.

On Tue, Feb 17, 2026 at 2:58 PM Andres Freund <[email protected]> wrote:

> What about direct TLS connections?


Not handled.

How can a cluster coordinator trust unauthenticated plain text
> communication that can just be man-in-the-middled?
>

They cannot. But that's why this is only exposing non-critical information.
Right now the security scanners that are banging on port 5432 and scraping
the returned error lines are not worried about man-in-the-middle. :)
Obviously, if your threat model is people capturing and modifying
non-encrypted traffic to your Postgres server, you would not use this.

It's not obvious that it's a good idea to expose this on the same socket as
> normal client connections.  IMO you'd want to limit this to a smaller set
> of interfaces than normal client connections.
>

I'm not entirely clear what that smaller set would mean in practice.


> IIRC the socket is in blocking mode at this point (that's only changed in
> pq_init()), therefore this might actually block?  While it's unlikely, I
> don't see any guarantee that a single receive would actually get the whole
> message from the client either, so this seems like it might fail spuriously.
>

Yes, there are some very unlikely edge cases, but this is meant to be good
enough, not a perfectly bulletproof HTTP server. Clients should try again
on failures. Which if they do occur for this trivial amount of traffic
probably indicates much bigger problems.

If we were to do this, I'd recommend a single expose GUC that has the
> different values as a comma separated list, instead a growing list of GUCs.
>

Done - see attached for a new version which consolidates the bools into a
single comma-separated GUC called "expose_information". I also added some
docs, and changed the "replica" to return "REPLICA" instead of "RECOVERY".
I like the latter better, but replica lines up better with existing tools.

-- 
Cheers,
Greg


Attachments:

  [application/octet-stream] 0006-Allow-specific-information-to-be-output-directly-by-Postgres.patch (19.1K, 3-0006-Allow-specific-information-to-be-output-directly-by-Postgres.patch)
  download | inline diff:
From 257aaa706cb272148d877d0f67fce2bc1e49a39b Mon Sep 17 00:00:00 2001
From: Greg Sabino Mullane <[email protected]>
Date: Tue, 24 Mar 2026 21:15:23 -0400
Subject: [PATCH] Allow-specific-information-to-be-output-directly-by-Postgres

---
 doc/src/sgml/config.sgml                      |  61 ++++
 src/backend/tcop/backend_startup.c            | 262 ++++++++++++++++++
 src/backend/utils/misc/guc_parameters.dat     |  10 +
 src/backend/utils/misc/postgresql.conf.sample |   7 +
 src/include/tcop/backend_startup.h            |   2 +
 src/include/utils/guc_hooks.h                 |   2 +
 src/test/modules/test_misc/meson.build        |   1 +
 src/test/modules/test_misc/t/011_expose.pl    | 121 ++++++++
 8 files changed, 466 insertions(+)
 create mode 100644 src/test/modules/test_misc/t/011_expose.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 8cdd826fbd3..a32c7e7f6a0 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -643,6 +643,67 @@ include_dir 'conf.d'
 
      <variablelist>
 
+     <varlistentry id="guc-expose-information" xreflabel="expose_information">
+      <term><varname>expose_information</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>expose_information</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Allows for specific information to be returned from the servers without
+        requiring a login. Requests should come in as a simple HTTP request as a
+        GET or HEAD to the PostgreSQL port.
+        The default is the empty string, <literal>''</literal>, which
+        prevents any information from being output. The following options may be
+        specified alone or in a comma-separated list:
+       </para>
+
+       <table id="expose-information-options">
+        <title>Expose Information Options</title>
+        <tgroup cols="2">
+         <colspec colname="col1" colwidth="1*"/>
+         <colspec colname="col2" colwidth="2*"/>
+         <thead>
+          <row>
+           <entry>Name</entry>
+           <entry>Description</entry>
+          </row>
+         </thead>
+         <tbody>
+          <row>
+           <entry><literal>replica</literal></entry>
+           <entry>Reports if the server is a replica (i.e. is in recovery mode) or not. If the request is <literal>HEAD /replica</literal>,
+             then an HTTP response code of 200 (yes it is a replica) or 503 (not a replica) is returned. This
+             can be used as a drop-in replacement for the same functionality provided by the Patroni program.
+             For the request <literal>GET /replica</literal> or <literal>GET /info</literal>,
+             the string <literal>REPLICA: 1</literal> or <literal>REPLICA: 0</literal> is returned.</entry>
+          </row>
+
+          <row>
+           <entry><literal>sysid</literal></entry>
+           <entry>Returns the system identifier of the server. This can be useful to determine if the underlying
+             server has changed, as the initdb program will always generate a new system identifier.
+             For the request <literal>GET /sysid</literal> or <literal>GET /info</literal>,
+             the string <literal>SYSID: 12345</literal> is returned, in which "12345" will be
+             the specific system identifier (typically a 20-digit number)</entry>
+          </row>
+
+          <row>
+           <entry><literal>version</literal></entry>
+           <entry>Returns the current version of the server. Specifically, the value of
+             <literal>server_version_num</literal>.
+             For the request <literal>GET /version</literal> or <literal>GET /info</literal>,
+             the string <literal>VERSION: 190000</literal> is returned (for this example,
+             the version of Postgres is 19.0)</entry>
+          </row>
+         </tbody>
+        </tgroup>
+       </table>
+
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-listen-addresses" xreflabel="listen_addresses">
       <term><varname>listen_addresses</varname> (<type>string</type>)
       <indexterm>
diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c
index 5abf276c898..271ea9ddcb8 100644
--- a/src/backend/tcop/backend_startup.c
+++ b/src/backend/tcop/backend_startup.c
@@ -46,6 +46,33 @@
 bool		Trace_connection_negotiation = false;
 uint32		log_connections = 0;
 char	   *log_connections_string = NULL;
+int			Expose_information = 0;
+char	   *Expose_information_string = NULL;
+
+/* Expose information bitmap */
+#define EXPOSE_INFO_REPLICA	 1
+#define EXPOSE_INFO_SYSID	 2
+#define EXPOSE_INFO_VERSION  4
+
+#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;
+	int			require;
+	ExposeReturnType type;
+}			endpoint_action;
 
 /* Other globals */
 
@@ -65,6 +92,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 +176,15 @@ 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_information > 0)
+		&& 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();
 
@@ -1075,6 +1112,72 @@ next:	;
 }
 
 
+/*
+ * GUC check_hook for expose_information
+ */
+bool
+check_expose_information(char **newval, void **extra, GucSource source)
+{
+	char	   *rawstring;
+	List	   *elemlist;
+	ListCell   *l;
+	int			newexpose = 0;
+	int		   *myextra;
+
+	/* Need a modifiable copy of string */
+	rawstring = pstrdup(*newval);
+
+	/* Parse string into list of identifiers */
+	if (!SplitIdentifierString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		GUC_check_errdetail("List syntax is invalid.");
+		pfree(rawstring);
+		list_free(elemlist);
+		return false;
+	}
+
+	foreach(l, elemlist)
+	{
+		char	   *tok = (char *) lfirst(l);
+
+		if (pg_strcasecmp(tok, "replica") == 0)
+			newexpose |= EXPOSE_INFO_REPLICA;
+		else if (pg_strcasecmp(tok, "sysid") == 0)
+			newexpose |= EXPOSE_INFO_SYSID;
+		else if (pg_strcasecmp(tok, "version") == 0)
+			newexpose |= EXPOSE_INFO_VERSION;
+		else
+		{
+			GUC_check_errdetail("Unrecognized key word: \"%s\".", tok);
+			pfree(rawstring);
+			list_free(elemlist);
+			return false;
+		}
+	}
+
+	pfree(rawstring);
+	list_free(elemlist);
+
+	myextra = (int *) guc_malloc(LOG, sizeof(int));
+	if (!myextra)
+		return false;
+	*myextra = newexpose;
+	*extra = myextra;
+
+	return true;
+}
+
+/*
+ * GUC assign_hook for expose_information
+ */
+void
+assign_expose_information(const char *newval, void *extra)
+{
+	Expose_information = *((int *) extra);
+}
+
+
 /*
  * GUC check hook for log_connections
  */
@@ -1127,3 +1230,162 @@ 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 the expose_information GUC to be non-empty
+ *
+ * Returns true if any endpoint is recognized.
+ */
+
+static bool
+ExposeInformation(pgsocket fd)
+{
+	static const endpoint_action endpoint_actions[] =
+	{
+		{
+			"HEAD /replica", EXPOSE_INFO_REPLICA, EXPOSE_HEAD_REPLICA
+		},
+		{
+			"GET /replica", EXPOSE_INFO_REPLICA, EXPOSE_GET_REPLICA
+		},
+		{
+			"GET /sysid", EXPOSE_INFO_SYSID, EXPOSE_GET_SYSID
+		},
+		{
+			"GET /version", EXPOSE_INFO_VERSION, EXPOSE_GET_VERSION
+		},
+		{
+			"GET /info", 0, EXPOSE_GET_ALL
+		}
+	};
+
+	ssize_t		n;
+	char		buf[EXPOSE_MAX_QUERY + 1];
+	ExposeReturnType type;
+
+	Assert(Expose_information > 0);
+
+	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. Note that we 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 == 0)
+			 ||
+			 (Expose_information & 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_information & EXPOSE_INFO_REPLICA)
+				&&
+				(type == EXPOSE_GET_ALL || type == EXPOSE_GET_REPLICA))
+				appendStringInfo(&content, "%s%d\r\n",
+								 type == EXPOSE_GET_ALL ? "REPLICA: " : "",
+								 RecoveryInProgress() ? 1 : 0);
+			if ((Expose_information & EXPOSE_INFO_SYSID)
+				&&
+				(type == EXPOSE_GET_ALL || type == EXPOSE_GET_SYSID))
+				appendStringInfo(&content, "%s" UINT64_FORMAT "\r\n",
+								 type == EXPOSE_GET_ALL ? "SYSID: " : "",
+								 GetSystemIdentifier());
+			if ((Expose_information & EXPOSE_INFO_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 0c9854ad8fc..8c8ad0d8d0a 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -1010,6 +1010,16 @@
   boot_val => 'false',
 },
 
+{ name => 'expose_information', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_AUTH',
+  short_desc => 'Expose limited information without needing to login',
+  long_desc => 'Valid values are combinations of "replica", "sysid", and "version"',
+  flags => 'GUC_LIST_INPUT',
+  variable => 'Expose_information_string',
+  boot_val => '""',
+  check_hook => 'check_expose_information',
+  assign_hook => 'assign_expose_information',
+},
+
 { 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 e4abe6c0077..5e361fd4b58 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -93,6 +93,13 @@
                                         # disconnection while running queries;
                                         # 0 for never
 
+# - Expose information -
+
+expose_information = '' # comma-separated list of items to expose
+                        # replica = if the server is in recovery or not
+                        # sysid = the current system identifier for this server
+                        # version = the current version of this server
+
 # - Authentication -
 
 #authentication_timeout = 1min          # 1s-600s
diff --git a/src/include/tcop/backend_startup.h b/src/include/tcop/backend_startup.h
index d486f926319..6204dd98e81 100644
--- a/src/include/tcop/backend_startup.h
+++ b/src/include/tcop/backend_startup.h
@@ -20,6 +20,8 @@
 extern PGDLLIMPORT bool Trace_connection_negotiation;
 extern PGDLLIMPORT uint32 log_connections;
 extern PGDLLIMPORT char *log_connections_string;
+extern PGDLLIMPORT int Expose_information;
+extern PGDLLIMPORT char *Expose_information_string;
 
 /* Other globals */
 extern PGDLLIMPORT struct ConnectionTiming conn_timing;
diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h
index b01697c1f60..04d9f024d26 100644
--- a/src/include/utils/guc_hooks.h
+++ b/src/include/utils/guc_hooks.h
@@ -62,6 +62,8 @@ extern void assign_default_text_search_config(const char *newval, void *extra);
 extern bool check_default_with_oids(bool *newval, void **extra,
 									GucSource source);
 extern const char *show_effective_wal_level(void);
+extern bool check_expose_information(char **newval, void **extra, GucSource source);
+extern void assign_expose_information(const char *newval, void *extra);
 extern bool check_huge_page_size(int *newval, void **extra, GucSource source);
 extern void assign_io_method(int newval, void *extra);
 extern bool check_io_max_concurrency(int *newval, void **extra, GucSource source);
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..df97b98096b
--- /dev/null
+++ b/src/test/modules/test_misc/t/011_expose.pl
@@ -0,0 +1,121 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test gathering information before authentication via expose_* variables
+
+# Force use of TCP/IP - must be called before the 'use' section
+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_information = 'replica'");
+$node->reload();
+
+$test = q{GET /replica returns HTTP code 200 when expose_information contains 'replica' (primary)};
+call_socket('GET /replica');
+like ($response, qr{^HTTP/1.1 200 }, $test);
+
+$test = q{GET /replica returns "0" when expose_information contains 'replica' (primary)};
+like ($response, qr{\r\n0\r\n}, $test);
+
+$test = q{HEAD /replica returns HTTP code 503 when expose_information contains 'replica' (primary)};
+call_socket('HEAD /replica');
+like ($response, qr{^HTTP/1.1 503 }, $test);
+
+$test = q{GET /info returns "REPLICA: 0" when expose_information contains 'replica' (primary)};
+call_socket('GET /info');
+like ($response, qr{REPLICA: 0\r\n}, $test);
+
+$test = q{GET /info does not return version information when expose_information does not contain 'version'};
+unlike ($response, qr{VERSION}, $test);
+
+$test = q{GET /info does not return sysid information when expose_information does not contain 'sysid'};
+unlike ($response, qr{SYSID}, $test);
+
+$node->append_conf('postgresql.conf', "expose_information= 'replica,sysid,version'");
+$node->reload();
+
+$test = q{GET /info returns correct version when expose_information contains 'version'};
+call_socket('GET /info');
+like ($response, qr/VERSION: $server_version/, $test);
+
+$test = q{GET /info returns correct value when expose_information contains 'sysid'};
+like ($response, qr/SYSID: $sysid/, $test);
+
+$test = q{Get /sysid returns correct value when expose_information contains 'sysid'};
+call_socket('Get /sysid'); ## Not required to be all uppercase according to the spec!
+like ($response, qr/^$sysid\r\n/m, $test);
+
+$test = q{GET /version returns correct value when expose_information contains 'version'};
+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 = q{GET /replica returns HTTP code 200 when expose_information contains 'replica' (replica)};
+call_socket('GET /replica');
+like ($response, qr{^HTTP/1.1 200 }, $test);
+
+$test = q{GET /replica returns "1" when expose_information contains 'replica' (replica)};
+like ($response, qr{^1\r\n}m, $test);
+
+$test = q{HEAD /replica returns HTTP code 200 when expose_information contains 'replica' (replica)};
+call_socket('HEAD /replica');
+like ($response, qr{^HTTP/1.1 200 }, $test);
+
+$test = q{GET /info returns "REPLICA: 1" when expose_information contains 'replica' (replica)};
+call_socket('GET /info');
+like ($response, qr/REPLICA: 1/, $test);
+
+$node->append_conf('postgresql.conf', "expose_information=''");
+$node->reload();
+
+$test = q{GET /version returns nothing after expose_information no longer has 'version'};
+call_socket('GET /version');
+is ($response, '', $test);
+
+$socket->close();
+
+done_testing();
-- 
2.47.3



^ permalink  raw  reply  [nested|flat] 12+ messages in thread


end of thread, other threads:[~2026-03-25 01:59 UTC | newest]

Thread overview: 12+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2025-05-29 14:32 POC: Carefully exposing information without authentication Greg Sabino Mullane <[email protected]>
2025-05-30 15:02 ` Antonin Houska <[email protected]>
2025-05-31 00:13   ` Greg Sabino Mullane <[email protected]>
2025-05-31 01:34     ` Tom Lane <[email protected]>
2025-05-31 04:48       ` Greg Sabino Mullane <[email protected]>
2025-10-03 02:23         ` Greg Sabino Mullane <[email protected]>
2025-10-23 09:57           ` Greg Sabino Mullane <[email protected]>
2026-01-09 13:56             ` Antonin Houska <[email protected]>
2026-01-09 14:15               ` Greg Sabino Mullane <[email protected]>
2026-02-17 19:42                 ` Greg Sabino Mullane <[email protected]>
2026-02-17 19:58                   ` Andres Freund <[email protected]>
2026-03-25 01:59                     ` Greg Sabino Mullane <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox