From 7a0f8f81fe78cc4489fe272827e0328e1db29910 Mon Sep 17 00:00:00 2001 From: Greg Sabino Mullane Date: Sun, 6 Sep 2026 21:44:10 -0400 Subject: [PATCH] Allow specific information to be output directly by Postgres. --- doc/src/sgml/config.sgml | 62 ++++ src/backend/tcop/backend_startup.c | 334 ++++++++++++++++++ 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 + .../test_misc/t/016_expose_information.pl | 179 ++++++++++ 8 files changed, 597 insertions(+) create mode 100644 src/test/modules/test_misc/t/016_expose_information.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 0165eb9ec02..7977ee252cc 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -661,6 +661,68 @@ 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 + + + + + role + Determine if the server 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. A request of HEAD /primary + returns the opposite values. This can be used as a drop-in replacement for the same + functionality provided by the Patroni program. For the request GET /replica + and GET /primary the string 1 or 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 the string 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 + the string 200002 is returned (for this example, + the version of Postgres is 20.2) + + + +
+ +
+
+ listen_addresses (string) diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c index 912ad7dc957..a03af3d20ac 100644 --- a/src/backend/tcop/backend_startup.c +++ b/src/backend/tcop/backend_startup.c @@ -46,6 +46,39 @@ bool Trace_connection_negotiation = false; uint32 log_connections = 0; char *log_connections_string = NULL; +uint32 expose_information = 0; +char *expose_information_string = NULL; + +/* Expose information bitmap */ +#define EXPOSE_INFO_ROLE 1 +#define EXPOSE_INFO_SYSID 2 +#define EXPOSE_INFO_VERSION 4 + +#define EXPOSE_MIN_QUERY 10 /* Shortest possible line: "GET /sysid" */ +#define EXPOSE_MAX_QUERY 16 /* Longest possible GET line */ + +#define EXPOSE_SEND_TIMEOUT_MS 2000 /* Maximum time to wait for clients to + * read info (milliseconds) */ +#define EXPOSE_SEND_RETRY_SLEEP_US 10000 /* How long to sleep between + * send() calls (microseconds) */ + +typedef enum +{ + EXPOSE_TYPE_NOTHING, + EXPOSE_TYPE_HEAD_REPLICA, + EXPOSE_TYPE_GET_REPLICA, + EXPOSE_TYPE_HEAD_PRIMARY, + EXPOSE_TYPE_GET_PRIMARY, + EXPOSE_TYPE_GET_SYSID, + EXPOSE_TYPE_GET_VERSION, +} ExposeReturnType; + +typedef struct +{ + const char *endpoint; + int require; + ExposeReturnType type; +} ExposeEndpointAction; /* Other globals */ @@ -65,6 +98,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 +182,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(); @@ -1108,6 +1151,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, "role") == 0) + newexpose |= EXPOSE_INFO_ROLE; + 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 */ @@ -1160,3 +1269,228 @@ 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 /replica) + * + * Requires the expose_information GUC to be non-empty + * + * Returns true if any endpoint is recognized. + */ + +static bool +ExposeInformation(pgsocket fd) +{ + ssize_t n; + char buf[EXPOSE_MAX_QUERY + 1]; + ExposeReturnType type; + bool result = false; +#ifdef WIN32 + int save_win32_noblock = pgwin32_noblock; +#endif + + /* Matching string, required setting, type of response */ + static const ExposeEndpointAction endpoint_actions[] = + { + { + "HEAD /replica", EXPOSE_INFO_ROLE, EXPOSE_TYPE_HEAD_REPLICA + }, + { + "GET /replica", EXPOSE_INFO_ROLE, EXPOSE_TYPE_GET_REPLICA + }, + { + "HEAD /primary", EXPOSE_INFO_ROLE, EXPOSE_TYPE_HEAD_PRIMARY + }, + { + "GET /primary", EXPOSE_INFO_ROLE, EXPOSE_TYPE_GET_PRIMARY + }, + { + "GET /sysid", EXPOSE_INFO_SYSID, EXPOSE_TYPE_GET_SYSID + }, + { + "GET /version", EXPOSE_INFO_VERSION, EXPOSE_TYPE_GET_VERSION + }, + }; + + Assert(expose_information > 0); + +#ifdef WIN32 + pgwin32_noblock = true; +#else + if (!pg_set_noblock(fd)) + goto cleanup; +#endif + + do + { + n = recv(fd, buf, EXPOSE_MAX_QUERY, MSG_PEEK); + } while (n < 0 && errno == EINTR); + + /* + * If there was a problem (n == -1), or the input is too short, we simply + * leave and let the normal flow continue. + */ + if (n < EXPOSE_MIN_QUERY) + goto cleanup; + + buf[n] = '\0'; + + type = EXPOSE_TYPE_NOTHING; + for (int i = 0; i < lengthof(endpoint_actions); i++) + { + size_t endpoint_len = strlen(endpoint_actions[i].endpoint); + + if ( + (expose_information & endpoint_actions[i].require) + && + strncmp(buf, endpoint_actions[i].endpoint, endpoint_len) == 0 + && + (buf[endpoint_len] == ' ' || buf[endpoint_len] == '\r' || buf[endpoint_len] == '\0') + ) + { + type = endpoint_actions[i].type; + break; + } + } + if (type == EXPOSE_TYPE_NOTHING) + goto cleanup; + + /* From this point onwards, we return true, as we have found a match */ + result = true; + + { + 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"; + + /* Total bytes of above: 8 + 24 + 17 + 14 = 63 bytes */ + + StringInfoData msg; + + TimestampTz start_time = 0; + size_t sent = 0; + bool send_failed = false; + + if (type == EXPOSE_TYPE_HEAD_REPLICA || type == EXPOSE_TYPE_HEAD_PRIMARY) + { + /* + * Caller only cares about the HTTP response code, so no content + * needed + */ + + bool recovery_in_progress = RecoveryInProgress(); + + initStringInfoExt(&msg, 90); + + appendStringInfo(&msg, + "%s %s\r\n" + "%s\r\n" + "%s\r\n\r\n", + http_version, + (((recovery_in_progress && type == EXPOSE_TYPE_HEAD_REPLICA) + || (!recovery_in_progress && type == EXPOSE_TYPE_HEAD_PRIMARY)) + ? "200 OK" : "503 Service Unavailable"), + http_type, + http_conn + ); + } + else + { + StringInfoData content; + + initStringInfoExt(&content, MAXINT8LEN + 3); + + switch (type) + { + + case EXPOSE_TYPE_GET_SYSID: + appendStringInfo(&content, UINT64_FORMAT "\r\n", + GetSystemIdentifier()); + break; + case EXPOSE_TYPE_GET_VERSION: + appendStringInfo(&content, "%d\r\n", + PG_VERSION_NUM); + break; + case EXPOSE_TYPE_GET_REPLICA: + appendStringInfo(&content, "%d\r\n", + RecoveryInProgress() ? 1 : 0); + break; + case EXPOSE_TYPE_GET_PRIMARY: + appendStringInfo(&content, "%d\r\n", + RecoveryInProgress() ? 0 : 1); + break; + default: + elog(ERROR, "unrecognized ExposeReturnType: %d", (int) type); + } + + initStringInfoExt(&msg, 128); + 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); + } + + /* + * Send the response. As this is at an early and important point, we + * want to quickly close the connection if the client stops reading, + * so we add a timeout. + */ + + while (sent < (size_t) msg.len && !send_failed) + { + + n = send(fd, msg.data + sent, msg.len - sent, 0); + + if (n > 0) + { + sent += n; + continue; + } + + /* While n==0 should not happen, we treat as a retry as well */ + if (n == 0 || + (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))) + { + if (start_time == 0) + start_time = GetCurrentTimestamp(); + else if (TimestampDifferenceExceeds(start_time, GetCurrentTimestamp(), + EXPOSE_SEND_TIMEOUT_MS)) + send_failed = true; + else + pg_usleep(EXPOSE_SEND_RETRY_SLEEP_US); + + continue; + } + + /* The send() call failed in some way we cannot handle */ + elog(LOG, "failed to send information to client: %m"); + send_failed = true; + + } + + pfree(msg.data); + + } + +cleanup: +#ifdef WIN32 + pgwin32_noblock = save_win32_noblock; +#else + (void) pg_set_block(fd); +#endif + return result; +} diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 3c5e16ad1e7..84fabc54795 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -1066,6 +1066,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 "role", "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 e759f06b50f..2de210567f3 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 + # role = 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..feedc1f8a4e 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 uint32 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 6a76f8d5ed6..40d31c9fe16 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 5d81f5b13be..7e9aeb0e804 100644 --- a/src/test/modules/test_misc/meson.build +++ b/src/test/modules/test_misc/meson.build @@ -24,6 +24,7 @@ tests += { 't/013_temp_obj_multisession.pl', 't/014_log_statement_max_length.pl', 't/015_temp_schema_exit_deferrable.pl', + 't/016_expose_information.pl', ], # The injection points are cluster-wide, so disable installcheck 'runningcheck': false, diff --git a/src/test/modules/test_misc/t/016_expose_information.pl b/src/test/modules/test_misc/t/016_expose_information.pl new file mode 100644 index 00000000000..07d3b435c3c --- /dev/null +++ b/src/test/modules/test_misc/t/016_expose_information.pl @@ -0,0 +1,179 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test gathering information before authentication via expose_* variables + +# Force use of TCP/IP sockets via raw_connect +INIT{ $PostgreSQL::Test::Utils::use_unix_sockets = 0; } + +use strict; +use warnings; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +plan tests => 31; + +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->timeout(1); + $socket->send($string); + $response = ''; + select(undef, undef, undef, 0.1); + $socket->recv($response, $receive_length); + return; +} + +## Technically, this 'returns nothing' is a protocol error and a closed connection. +## But for purposes of this test, we just need to make sure nothing is returned. + +$test = q{GET /foobar returns nothing when expose_information = ''}; +call_socket('GET /foobar'); +is ($response, '', $test); + +$test = q{HEAD /foobar returns nothing when expose_information = ''}; +call_socket('HEAD /foobar'); +is ($response, '', $test); + +$test = q{GET /replica returns nothing when expose_information=''}; +call_socket('GET /replica'); +is ($response, '', $test); + +$test = q{HEAD /replica returns nothing when expose_information=''}; +call_socket('HEAD /replica'); +is ($response, '', $test); + +$test = q{GET /primary returns nothing when expose_information=''}; +call_socket('GET /primary'); +is ($response, '', $test); + +$test = q{HEAD /primary returns nothing when expose_information=''}; +call_socket('HEAD /primary'); +is ($response, '', $test); + +$node->append_conf('postgresql.conf', "expose_information = 'role'"); +$node->reload(); + +$test = q{GET /replica returns HTTP code 200 when expose_information = 'role' (primary)}; +call_socket('GET /replica'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /replica returns string "0" when expose_information = 'role' (primary)}; +like ($response, qr{\r\n0\r\n}, $test); + +$test = q{HEAD /replica returns HTTP code 503 when expose_information = 'role' (primary)}; +call_socket('HEAD /replica'); +like ($response, qr{^HTTP/1.1 503 }, $test); + +$test = q{GET /primary returns HTTP code 200 when expose_information = 'role' (primary)}; +call_socket('GET /primary'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /primary returns string "1" when expose_information = 'role' (primary)}; +like ($response, qr{\r\n1\r\n}, $test); + +$test = q{HEAD /primary returns HTTP code 200 when expose_information = 'role' (primary)}; +call_socket('HEAD /primary'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /version returns nothing when expose_information = 'role'}; +call_socket('GET /version'); +is ($response, '', $test); + +$test = q{GET /sysid returns nothing when expose_information = 'role'}; +call_socket('GET /sysid'); +is ($response, '', $test); + +$node->append_conf('postgresql.conf', "expose_information= 'sysid'"); +$node->reload(); + +$test = q{GET /replica returns nothing when expose_information = 'sysid'}; +call_socket('GET /replica'); +is ($response, '', $test); + +$test = q{HEAD /replica returns nothing when expose_information = 'sysid'}; +call_socket('HEAD /replica'); +is ($response, '', $test); + +$node->append_conf('postgresql.conf', "expose_information= 'sysid,role,version'"); +$node->reload(); + +$test = q{GET /sysid returns correct value when expose_information contains 'sysid'}; +call_socket('GET /sysid'); +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 = q{GET /version\r\n returns correct value when expose_information contains 'version'}; +call_socket("GET /version\r\n"); +like ($response, qr/^$server_version\r\n/m, $test); + +$test = q{GET /version HTTP/1.0 returns correct value when expose_information contains 'version'}; +call_socket("GET /version HTTP/1.0"); +like ($response, qr/^$server_version\r\n/m, $test); + +$test = q{GET /ve returns nothing}; +call_socket('GET /ve'); +is ($response, '', $test); + +$test = q{GET /versio returns nothing}; +call_socket('GET /versio'); +is ($response, '', $test); + +$test = q{GET /versionx returns nothing}; +call_socket('GET /versionx'); +is ($response, '', $test); + +$node->set_standby_mode(); +$node->restart(); + +$test = q{GET /replica returns HTTP code 200 when expose_information contains 'role' (replica)}; +call_socket('GET /replica'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /replica returns string "1" when expose_information contains 'role' (replica)}; +like ($response, qr{^1\r\n}m, $test); + +$test = q{HEAD /replica returns HTTP code 200 when expose_information contains 'role' (replica)}; +call_socket('HEAD /replica'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /primary returns HTTP code 200 when expose_information contains 'role' (replica)}; +call_socket('GET /primary'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /primary returns string "0" when expose_information contains 'role' (replica)}; +like ($response, qr{^0\r\n}m, $test); + +$test = q{HEAD /primary returns HTTP code 503 when expose_information contains 'role' (replica)}; +call_socket('HEAD /primary'); +like ($response, qr{^HTTP/1.1 503 }, $test); + +$test = q{Regular connection still works after expose_information is enabled}; +is ($node->safe_psql('postgres', 'select 42'), '42', $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(); -- 2.47.3