From 438d935387a9b58c85c2d566ca18a1ca60910832 Mon Sep 17 00:00:00 2001 From: Greg Sabino Mullane 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