Skip to content

Commit b357c97

Browse files
committed
Enforce TLS 1.2 minimum across all connections
Add comprehensive TLS version enforcement to protect against downgrade attacks and vulnerabilities in deprecated TLS 1.0 and 1.1 protocols. This applies to both the agent API server (inbound from Ironic) and all client connections (to Ironic API, Inspector, and image servers). Operators can configure the minimum TLS version (1.2 or 1.3) and customize cipher suites for their security requirements. Default configuration enforces TLS 1.2 with forward-secret AEAD ciphers, balancing security and compatibility with existing infrastructure. New configuration options: - tls_min_version: Minimum TLS protocol version (default: 1.2) - tls_cipher_suites: Custom cipher suite configuration for TLS 1.2 Both options support kernel parameters (ipa-tls-min-version and ipa-tls-cipher-suites) for deployment flexibility. Assisted-By: Claude Code - Claude Sonnet 4.5 Change-Id: Id6bafa3e34e79fb0b64d5a0b1e3868c82af6647c Signed-off-by: Julia Kreger <juliaashleykreger@gmail.com>
1 parent 3bfbee7 commit b357c97

File tree

17 files changed

+946
-335
lines changed

17 files changed

+946
-335
lines changed

doc/source/install/index.rst

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,67 @@ keyfile
9292
Currently a single set of cafile/certfile/keyfile options is used for all
9393
HTTP requests to the other services.
9494

95+
TLS Protocol Version Enforcement
96+
---------------------------------
97+
98+
IPA enforces minimum TLS protocol versions for all connections (both client
99+
and server) to protect against downgrade attacks and known vulnerabilities
100+
in older TLS versions. By default, TLS 1.2 is the minimum supported version,
101+
which removes support for the deprecated and insecure TLS 1.0 and TLS 1.1
102+
protocols (as per RFC 8996).
103+
104+
Available options in the ``[DEFAULT]`` config file section are:
105+
106+
tls_min_version
107+
Minimum TLS protocol version for both the agent API server (inbound
108+
connections from Ironic) and all client connections (to Ironic API,
109+
Inspector, and image servers). Supported values are ``1.2`` and ``1.3``.
110+
Default is ``1.2`` for broad compatibility.
111+
When not specified explicitly, defaults to the value of
112+
``ipa-tls-min-version`` kernel command line argument.
113+
114+
Setting this to ``1.3`` provides enhanced security and performance but
115+
requires all services (Ironic conductor, Inspector, image servers) to
116+
support TLS 1.3.
117+
118+
Example to enforce TLS 1.3 only:
119+
120+
.. code-block:: ini
121+
122+
[DEFAULT]
123+
tls_min_version = 1.3
124+
125+
Or via kernel parameter::
126+
127+
ipa-tls-min-version=1.3
128+
129+
tls_cipher_suites
130+
Colon-separated list of TLS cipher suites to allow for TLS 1.2
131+
connections. If not specified, uses secure defaults that provide forward
132+
secrecy:
133+
``ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256``.
134+
TLS 1.3 cipher suites are automatically selected by the TLS library and
135+
cannot be configured.
136+
When not specified explicitly, defaults to the value of
137+
``ipa-tls-cipher-suites`` kernel command line argument.
138+
139+
Example to restrict to specific cipher suites:
140+
141+
.. code-block:: ini
142+
143+
[DEFAULT]
144+
tls_cipher_suites = ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305
145+
146+
Or via kernel parameter::
147+
148+
ipa-tls-cipher-suites=ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305
149+
150+
.. note::
151+
TLS 1.2 is enforced as the minimum version by default. Operators using
152+
legacy infrastructure that only supports TLS 1.0 or 1.1 must upgrade
153+
their services before deploying this version of ironic-python-agent.
154+
All actively maintained versions of OpenStack Ironic support TLS 1.2.
155+
95156
Server Configuration
96157
--------------------
97158

@@ -104,6 +165,12 @@ Automatic TLS
104165
generated in runtime and sent to ironic on heartbeat.
105166

106167
No special configuration is required on the ironic side.
168+
169+
.. note::
170+
TLS protocol version enforcement (see `TLS Protocol Version Enforcement`_)
171+
applies to automatic TLS. By default, only TLS 1.2 and above are
172+
accepted for connections from Ironic conductors.
173+
107174
Manual TLS
108175
If you need to provide your own TLS certificate, you can configure it when
109176
building an image. Set the following options in the ironic-python-agent

ironic_python_agent/api/app.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import json
16+
import ssl
1617
import threading
1718

1819
from cheroot.ssl import builtin
@@ -25,9 +26,47 @@
2526
from ironic_python_agent.api import request_log
2627
from ironic_python_agent import encoding
2728
from ironic_python_agent.metrics_lib import metrics_utils
29+
from ironic_python_agent import utils
2830

2931

3032
LOG = log.getLogger(__name__)
33+
34+
35+
class TLSEnforcingSSLAdapter(builtin.BuiltinSSLAdapter):
36+
"""SSL adapter that enforces TLS version requirements.
37+
38+
This adapter extends cheroot's BuiltinSSLAdapter to support
39+
pre-configured SSL contexts with custom TLS version enforcement.
40+
"""
41+
42+
def __init__(self, certificate, private_key, ssl_context=None,
43+
certificate_chain=None):
44+
"""Initialize the adapter with optional SSL context.
45+
46+
:param certificate: Path to certificate file
47+
:param private_key: Path to private key file
48+
:param ssl_context: Optional pre-configured SSL context
49+
:param certificate_chain: Optional certificate chain file
50+
"""
51+
super().__init__(certificate, private_key,
52+
certificate_chain=certificate_chain)
53+
self._custom_context = ssl_context
54+
55+
def wrap(self, sock):
56+
"""Wrap socket with SSL using custom context if provided.
57+
58+
:param sock: The socket to wrap
59+
:returns: Tuple of (SSL-wrapped socket, SSL environ dict)
60+
"""
61+
if self._custom_context:
62+
s = self._custom_context.wrap_socket(
63+
sock, server_side=True,
64+
do_handshake_on_connect=True
65+
)
66+
return s, self.get_environ(s)
67+
return super().wrap(sock)
68+
69+
3170
_CUSTOM_MEDIA_TYPE = 'application/vnd.openstack.ironic-python-agent.v1+json'
3271
_DOCS_URL = 'https://docs.openstack.org/ironic-python-agent'
3372

@@ -145,20 +184,38 @@ def start(self, tls_cert_file=None, tls_key_file=None):
145184
server_name='ironic-python-agent')
146185

147186
if self.tls_cert_file and self.tls_key_file:
148-
server.ssl_adapter = builtin.BuiltinSSLAdapter(
187+
# Create SSL context with TLS version enforcement
188+
ssl_context = utils.create_ssl_context('server')
189+
190+
# Load certificate and key
191+
ssl_context.load_cert_chain(
192+
certfile=self.tls_cert_file,
193+
keyfile=self.tls_key_file
194+
)
195+
196+
# Disable client certificate verification
197+
# (agent is server, Ironic is client)
198+
ssl_context.check_hostname = False
199+
ssl_context.verify_mode = ssl.CERT_NONE
200+
201+
server.ssl_adapter = TLSEnforcingSSLAdapter(
149202
certificate=self.tls_cert_file,
150-
private_key=self.tls_key_file
203+
private_key=self.tls_key_file,
204+
ssl_context=ssl_context
151205
)
206+
LOG.info('Started API service with TLS %s on port %s',
207+
self._conf.tls_min_version,
208+
self.agent.listen_address.port)
209+
else:
210+
LOG.info('Started API service without TLS on port %s',
211+
self.agent.listen_address.port)
152212

153213
self.server = server
154214
self.server.prepare()
155215
self.server_thread = threading.Thread(target=self.server.serve)
156216
self.server_thread.daemon = True
157217
self.server_thread.start()
158218

159-
LOG.info('Started API service on port %s',
160-
self.agent.listen_address.port)
161-
162219
def stop(self):
163220
"""Stop the API service."""
164221
LOG.debug("Stopping the API service.")

ironic_python_agent/config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,26 @@
9292
help='Clock skew (in seconds) allowed in the generated TLS '
9393
'certificate.'),
9494

95+
cfg.StrOpt('tls_min_version',
96+
default=APARAMS.get('ipa-tls-min-version', '1.2'),
97+
choices=['1.2', '1.3'],
98+
help='Minimum TLS protocol version for both server (agent API) '
99+
'and client (outbound) connections. TLS 1.2 provides '
100+
'broad compatibility with older services, while TLS 1.3 '
101+
'offers enhanced security and performance. Can be '
102+
'supplied as "ipa-tls-min-version" kernel parameter.'),
103+
104+
cfg.StrOpt('tls_cipher_suites',
105+
default=APARAMS.get('ipa-tls-cipher-suites', None),
106+
help='Colon-separated list of TLS cipher suites to allow for '
107+
'TLS 1.2 connections. If not specified, uses secure '
108+
'defaults (ECDHE-ECDSA-AES256-GCM-SHA384:'
109+
'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-'
110+
'SHA384:ECDHE-RSA-AES128-GCM-SHA256). TLS 1.3 cipher '
111+
'suites are automatically selected by the TLS library. '
112+
'Can be supplied as "ipa-tls-cipher-suites" kernel '
113+
'parameter.'),
114+
95115
cfg.StrOpt('advertise_host',
96116
default=APARAMS.get('ipa-advertise-host', None),
97117
help='The host to tell Ironic to reply and send '

ironic_python_agent/extensions/standby.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,10 @@ def _download_with_proxy(image_info, url, image_id):
185185
if no_proxy:
186186
os.environ['no_proxy'] = no_proxy
187187
proxies = image_info.get('proxies', {})
188-
verify, cert = utils.get_ssl_client_options(CONF)
189188
resp = None
190189
image_download_attributes = {
191190
"stream": True,
192191
"proxies": proxies,
193-
"verify": verify,
194-
"cert": cert,
195192
"timeout": CONF.image_download_connection_timeout
196193
}
197194
# NOTE(Adam) `image_info` is prioritized over `oslo.conf` for credential
@@ -203,6 +200,10 @@ def _download_with_proxy(image_info, url, image_id):
203200
auth_object = _gen_auth_from_oslo_conf_user_pass(image_id)
204201
if auth_object is not None:
205202
image_download_attributes['auth'] = auth_object
203+
204+
# Create TLS-enforcing session for image downloads
205+
session = utils.get_requests_session()
206+
206207
for attempt in range(CONF.image_download_connection_retries + 1):
207208
try:
208209
# NOTE(TheJulia) The get request below does the following:
@@ -219,7 +220,7 @@ def _download_with_proxy(image_info, url, image_id):
219220
# failure is more so once we've started the download and we are
220221
# processing the incoming data.
221222
# B113 issue is covered is the image_download_attributs list
222-
resp = requests.get(url, **image_download_attributes) # nosec
223+
resp = session.get(url, **image_download_attributes) # nosec
223224
if resp.status_code != 200:
224225
msg = ('Received status code {} from {}, expected 200. '
225226
'Response body: {} Response headers: {}').format(

ironic_python_agent/inspector.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ def call_inspector(data, failures):
141141

142142
encoder = encoding.RESTJSONEncoder()
143143
data = encoder.encode(data)
144-
verify, cert = utils.get_ssl_client_options(CONF)
145144

146145
headers = {
147146
'Content-Type': 'application/json',
@@ -152,6 +151,9 @@ def call_inspector(data, failures):
152151

153152
urls = _get_urls()
154153

154+
# Create TLS-enforcing session
155+
session = utils.get_requests_session()
156+
155157
@tenacity.retry(
156158
retry=tenacity.retry_if_exception_type(
157159
(requests.exceptions.ConnectionError,
@@ -164,9 +166,8 @@ def _post_to_inspector():
164166
for url in urls:
165167
LOG.info('Posting collected data to %s', url)
166168
try:
167-
inspector_resp = requests.post(
169+
inspector_resp = session.post(
168170
url, data=data, headers=headers,
169-
verify=verify, cert=cert,
170171
timeout=CONF.http_request_timeout)
171172
except requests.exceptions.ConnectionError as exc:
172173
if url == urls[-1]:

ironic_python_agent/ironic_api_client.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,9 @@ def __init__(self, api_urls):
6161

6262
# Only keep alive a maximum of 2 connections to the API. More will be
6363
# opened if they are needed, but they will be closed immediately after
64-
# use.
65-
adapter = requests.adapters.HTTPAdapter(pool_connections=2,
66-
pool_maxsize=2)
67-
self.session = requests.Session()
68-
self.session.mount('https://', adapter)
69-
self.session.mount('http://', adapter)
64+
# use. Use TLS-enforcing session.
65+
self.session = utils.get_requests_session(pool_connections=2,
66+
pool_maxsize=2)
7067

7168
self.encoder = encoding.RESTJSONEncoder()
7269

@@ -83,16 +80,13 @@ def _request(self, method, path, data=None, headers=None, **kwargs):
8380
if CONF.global_request_id:
8481
headers["X-OpenStack-Request-ID"] = CONF.global_request_id
8582

86-
verify, cert = utils.get_ssl_client_options(CONF)
8783
for idx, api_url in enumerate(self.api_urls):
8884
request_url = f'{api_url}{path}'
8985
try:
9086
resp = self.session.request(method,
9187
request_url,
9288
headers=headers,
9389
data=data,
94-
verify=verify,
95-
cert=cert,
9690
timeout=CONF.http_request_timeout,
9791
**kwargs)
9892
# Make sure the working URL is on the top, so that the next

ironic_python_agent/partition_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ def get_configdrive(configdrive, node_uuid, tempdir=None):
7070
# Check if the configdrive option is a HTTP URL or the content directly
7171
is_url = _is_http_url(configdrive)
7272
if is_url:
73-
verify, cert = utils.get_ssl_client_options(CONF)
7473
timeout = CONF.image_download_connection_timeout
7574
# TODO(dtantsur): support proxy parameters from instance_info
75+
# Create TLS-enforcing session
76+
session = utils.get_requests_session()
7677
try:
77-
resp = requests.get(configdrive, verify=verify, cert=cert,
78-
timeout=timeout)
78+
resp = session.get(configdrive, timeout=timeout)
7979
except requests.exceptions.RequestException as e:
8080
raise errors.DeploymentError(
8181
"Can't download the configdrive content for node %(node)s "

ironic_python_agent/tests/unit/extensions/test_image.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ def test__uefi_bootloader_with_entry_removal(
328328
mock_get_part_path.return_value = self.fake_efi_system_part
329329
mock_utils_efi_part.return_value = {'number': '1'}
330330
mock_efi_bl.return_value = ['EFI/BOOT/BOOTX64.EFI']
331-
stdout_msg = """
331+
stdout_msg = r"""
332332
BootCurrent: 0001
333333
Timeout: 0 seconds
334334
BootOrder: 0000,00001
@@ -390,7 +390,7 @@ def test__uefi_bootloader_with_entry_removal_lenovo(
390390
# NOTE(TheJulia): This test string was derived from a lenovo SR650
391391
# which does do some weird things with additional entries.
392392
# most notably
393-
stdout_msg = """
393+
stdout_msg = r"""
394394
BootCurrent: 0000
395395
Timeout: 1 seconds
396396
BootOrder: 0000,0003,0002,0001

0 commit comments

Comments
 (0)