Skip to content

Commit 4a5cee7

Browse files
authored
Make HTTP Server header configurable (patroni#3384)
Introduce `restapi.server_tokens` configuration parameter. Valid values of the `restapi.server_tokens` parameter are: * "Original" : will execute the original behaviour and print the "BaseHTTP/$version Python/$version" string * "ProductOnly" : prints merely "Patroni" * "Minimal" : prints "Patroni/$version" An invalid or no value will result in the original behavior being applied. Close patroni#3379
1 parent 76e8bb0 commit 4a5cee7

File tree

6 files changed

+67
-2
lines changed

6 files changed

+67
-2
lines changed

docs/ENVIRONMENT.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ REST API
208208
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
209209
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
210210
- **PATRONI\_RESTAPI\_REQUEST\_QUEUE\_SIZE**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
211+
- **PATRONI\_RESTAPI\_SERVER\_TOKENS**: (optional) Configures the value of the ``Server`` HTTP header. ``Original`` (default) will expose the original behaviour and display the BaseHTTP and Python versions, e.g. ``BaseHTTP/0.6 Python/3.12.3``. ``Minimal``: The header will contain only the Patroni version, e.g. ``Patroni/4.0.0``. ``ProductOnly``: The header will contain only the product name, e.g. ``Patroni``.
211212

212213
.. warning::
213214

docs/yaml_configuration.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,10 @@ REST API
349349
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
350350
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
351351
- **request_queue_size**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
352+
- **server_tokens**: (optional): Configures the value of the ``Server`` HTTP header.
353+
- ``Minimal``: The header will contain only the Patroni version, e.g. ``Patroni/4.0.0``.
354+
- ``ProductOnly``: The header will contain only the product name, e.g. ``Patroni``.
355+
- ``Original`` (default): The header will expose the original behaviour and display the BaseHTTP and Python versions, e.g. ``BaseHTTP/0.6 Python/3.12.3``.
352356

353357
Here is an example of both **http_extra_headers** and **https_extra_headers**:
354358

patroni/api.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ def __init__(self, request: Any,
119119
self.__start_time: float = 0.0
120120
self.path_query: Dict[str, List[str]] = {}
121121

122+
def version_string(self) -> str:
123+
"""Override the default version string to return the server header as specified in the configuration.
124+
125+
If the server header is not set, then it returns the default version string of the HTTP server.
126+
127+
:return: ``Server`` version string, which is either the server header or the default version string
128+
from the :class:`BaseHTTPRequestHandler`.
129+
"""
130+
return self.server.server_header or super().version_string()
131+
122132
def _write_status_code_only(self, status_code: int) -> None:
123133
"""Write a response that is composed only of the HTTP status.
124134
@@ -1480,6 +1490,33 @@ def __init__(self, patroni: Patroni, config: Dict[str, Any]) -> None:
14801490
self.reload_config(config)
14811491
self.daemon = True
14821492

1493+
def construct_server_tokens(self, token_config: str) -> str:
1494+
"""Construct the value for the ``Server`` HTTP header based on *server_tokens*.
1495+
1496+
:param server_tokens: the value of ``restapi.server_tokens`` configuration option.
1497+
1498+
:returns: a string to be used as the value of ``Server`` HTTP header.
1499+
"""
1500+
token = token_config.lower()
1501+
logger.debug('restapi.server_tokens is set to "%s".', token_config)
1502+
1503+
# If 'original' is set, we do not modify the Server header.
1504+
# This is useful for compatibility with existing setups that expect the original header.
1505+
if token == 'original':
1506+
return ""
1507+
1508+
# If 'productonly', or 'minimal' is set, we construct the header accordingly.
1509+
if token == 'productonly': # Show only the product name, without versions.
1510+
return 'Patroni'
1511+
elif token == 'minimal': # Show only the product name and version, without PostgreSQL version.
1512+
return f'Patroni/{self.patroni.version}'
1513+
else:
1514+
# Token is not valid (one of 'original', 'productonly', 'minimal') so report a warning and
1515+
# return an empty string.
1516+
logger.warning('restapi.server_tokens is set to "%s". Patroni will not modify the Server header. '
1517+
'Valid values are: "Minimal", "ProductOnly".', token_config)
1518+
return ""
1519+
14831520
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
14841521
"""Execute *sql* query with *params* and optionally return results.
14851522
@@ -1858,6 +1895,9 @@ def reload_config(self, config: Dict[str, Any]) -> None:
18581895
assert isinstance(self.__listen, str)
18591896
self.connection_string = uri(self.__protocol, config.get('connect_address') or self.__listen, 'patroni')
18601897

1898+
# Define the Server header response using the server_tokens option.
1899+
self.server_header = self.construct_server_tokens(config.get('server_tokens', 'original'))
1900+
18611901
def handle_error(self, request: Union[socket.socket, Tuple[bytes, socket.socket]],
18621902
client_address: Tuple[str, int]) -> None:
18631903
"""Handle any exception that is thrown while handling a request to the REST API.

patroni/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ def _set_section_values(section: str, params: List[str]) -> None:
536536
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
537537
'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
538538
'https_extra_headers', 'allowlist', 'allowlist_include_members',
539-
'request_queue_size'])
539+
'request_queue_size', 'server_tokens'])
540540
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
541541
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
542542
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])

patroni/validator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1032,7 +1032,9 @@ def validate_watchdog_mode(value: Any) -> None:
10321032
Optional("allowlist_include_members"): bool,
10331033
Optional("http_extra_headers"): dict,
10341034
Optional("https_extra_headers"): dict,
1035-
Optional("request_queue_size"): IntValidator(min=0, max=4096, expected_type=int, raise_assert=True)
1035+
Optional("request_queue_size"): IntValidator(min=0, max=4096, expected_type=int, raise_assert=True),
1036+
Optional("server_tokens"): EnumValidator(('minimal', 'productonly', 'original'),
1037+
case_sensitive=False, raise_assert=True)
10361038
},
10371039
Optional("bootstrap"): {
10381040
"dcs": {

tests/test_api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,3 +811,21 @@ def test_query(self):
811811
patch.object(MockConnection, 'query') as mock_query:
812812
self.srv.query('SELECT 1')
813813
mock_query.assert_called_once_with('SELECT 1')
814+
815+
def test_construct_server_tokens(self):
816+
#
817+
# Test cases (case insensitive values):
818+
# 1. 'original' server token - should return empty string
819+
self.assertEqual(self.srv.construct_server_tokens('original'), '')
820+
self.assertEqual(self.srv.construct_server_tokens('oriGINal'), '')
821+
822+
# 2. 'productonly' server token - should return 'Patroni'
823+
self.assertEqual(self.srv.construct_server_tokens('productonly'), 'Patroni')
824+
self.assertEqual(self.srv.construct_server_tokens('prodUCTOnly'), 'Patroni')
825+
826+
# 3. 'minimal' server token - should return 'Patroni/$version'
827+
self.assertEqual(self.srv.construct_server_tokens('minimal'), 'Patroni/0.00')
828+
self.assertEqual(self.srv.construct_server_tokens('miNIMal'), 'Patroni/0.00')
829+
830+
# 4. Invalid server token - should exhibit 'original' behaviour and return an empty string.
831+
self.assertEqual(self.srv.construct_server_tokens('foobar'), '')

0 commit comments

Comments
 (0)