diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 023b3f03ba93..e7420d889095 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -1077,6 +1077,84 @@ include_dir 'conf.d' + + Expose Settings + + + + + expose_recovery (boolean) + + expose_recovery configuration parameter + + + + + Enables reporting if the server is in recovery mode without requiring + an authenticated login. Clients can send the string GET /replica + and will receive a 1 or 0. This is equivalent to logging in and running + SELECT pg_is_in_recovery(). A client can also send the + string HEAD /replica which will solely return an HTTP literal: + 200 if the server is in recovery, 503 if not. + (This allows a drop-in replacement to the same Patroni functionality) + Finally, a client can issue GET /info and receive the string + RECOVERY: followed by a 1 or 0. + + + + + + expose_sysid (boolean) + + expose_sysid configuration parameter + + + + + Enables reporting the system identifier of the cluster without requiring + an authenticated login. Clients can send the string GET /sysid + and will receive the numeric system identifier. This is a unique number generated + by each cluster when initdb is run. + + + A client can issue GET /info and receive the string + SYSID: followed by the numeric system identifier. + + + 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. + + + + + + expose_version (boolean) + + expose_version configuration parameter + + + + + Enables reporting the numeric version of the Postgres cluster without requiring + an authenticated login. Clients can send the string GET /version + and will receive an integer representing the version. + + + A client can issue GET /info and receive the string + VERSION: followed by the numeric version. + + + 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. + + + + + + + Authentication diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c index 14d5fc0b1965..b5d96d883078 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 1128167c0251..dd1be66ea2d1 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -106,6 +106,24 @@ max => 'INT_MAX / 2', }, +{ 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 dc9e2255f8a7..a9cdcd62fae0 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 753871071ac3..ee1ca2fca36c 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 f258bf1ccd94..726bd93908fb 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 000000000000..af19a102f630 --- /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();