From 257aaa706cb272148d877d0f67fce2bc1e49a39b Mon Sep 17 00:00:00 2001 From: Greg Sabino Mullane 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' + + expose_information (string) + + expose_information configuration parameter + + + + + 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, '', which + prevents any information from being output. The following options may be + specified alone or in a comma-separated list: + + + + Expose Information Options + + + + + + Name + Description + + + + + replica + Reports if the server is a replica (i.e. is in recovery mode) or not. If the request is HEAD /replica, + 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 GET /replica or GET /info, + the string REPLICA: 1 or REPLICA: 0 is returned. + + + + sysid + 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 GET /sysid or GET /info, + the string SYSID: 12345 is returned, in which "12345" will be + the specific system identifier (typically a 20-digit number) + + + + version + Returns the current version of the server. Specifically, the value of + server_version_num. + For the request GET /version or GET /info, + the string VERSION: 190000 is returned (for this example, + the version of Postgres is 19.0) + + + +
+ +
+
+ listen_addresses (string) 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