Skip to content

Commit 021e0a6

Browse files
committed
Generate a TLS certificate and send it to ironic
Adds a new flag (on by default) that enables generating a TLS certificate and sending it to ironic via heartbeat. Whether ironic supports auto-generated certificates is determined by checking its API version. Change-Id: I01f83dd04cfec2adc9e2a6b9c531391773ed36e5 Depends-On: https://review.opendev.org/747136 Depends-On: https://review.opendev.org/749975 Story: #2007214 Task: #40604
1 parent 6a80564 commit 021e0a6

12 files changed

Lines changed: 331 additions & 6 deletions

File tree

ironic_python_agent/agent.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def do_heartbeat(self):
135135
uuid=self.agent.get_node_uuid(),
136136
advertise_address=self.agent.advertise_address,
137137
advertise_protocol=self.agent.advertise_protocol,
138+
generated_cert=self.agent.generated_cert,
138139
)
139140
self.error_delay = self.initial_delay
140141
LOG.info('heartbeat successful')
@@ -216,6 +217,7 @@ def __init__(self, api_url, advertise_address, listen_address,
216217
# got upgraded somewhere along the way.
217218
self.agent_token_required = cfg.CONF.agent_token_required
218219
self.iscsi_started = False
220+
self.generated_cert = None
219221

220222
def get_status(self):
221223
"""Retrieve a serializable status.
@@ -370,9 +372,31 @@ def _wait_for_interface(self):
370372
LOG.warning("No valid network interfaces found. "
371373
"Node lookup will probably fail.")
372374

375+
def _start_auto_tls(self):
376+
# NOTE(dtantsur): if listen_tls is True, assume static TLS
377+
# configuration and don't auto-generate anything.
378+
if cfg.CONF.listen_tls or not cfg.CONF.enable_auto_tls:
379+
LOG.debug('Automated TLS is disabled')
380+
return None, None
381+
382+
if not self.api_url or not self.api_client.supports_auto_tls():
383+
LOG.warning('Ironic does not support automated TLS')
384+
return None, None
385+
386+
self.set_agent_advertise_addr()
387+
388+
LOG.info('Generating TLS parameters automatically for IP %s',
389+
self.advertise_address.hostname)
390+
tls_info = hardware.dispatch_to_managers(
391+
'generate_tls_certificate', self.advertise_address.hostname)
392+
self.generated_cert = tls_info.text
393+
self.advertise_protocol = 'https'
394+
return tls_info.path, tls_info.private_key_path
395+
373396
def serve_ipa_api(self):
374397
"""Serve the API until an extension terminates it."""
375-
self.api.start()
398+
cert_file, key_file = self._start_auto_tls()
399+
self.api.start(cert_file, key_file)
376400
if not self.standalone and self.api_url:
377401
# Don't start heartbeating until the server is listening
378402
self.heartbeater.start()

ironic_python_agent/api/app.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from ironic_lib import metrics_utils
1818
from oslo_log import log
19+
from oslo_service import sslutils
1920
from oslo_service import wsgi
2021
import werkzeug
2122
from werkzeug import exceptions as http_exc
@@ -126,12 +127,20 @@ def __call__(self, environ, start_response):
126127
response = self.handle_exception(environ, exc)
127128
return response(environ, start_response)
128129

129-
def start(self):
130+
def start(self, tls_cert_file=None, tls_key_file=None):
130131
"""Start the API service in the background."""
132+
if tls_cert_file and tls_key_file:
133+
sslutils.register_opts(self._conf)
134+
self._conf.set_override('cert_file', tls_cert_file, group='ssl')
135+
self._conf.set_override('key_file', tls_key_file, group='ssl')
136+
use_tls = True
137+
else:
138+
use_tls = self._conf.listen_tls
139+
131140
self.service = wsgi.Server(self._conf, 'ironic-python-agent', app=self,
132141
host=self.agent.listen_address.hostname,
133142
port=self.agent.listen_address.port,
134-
use_ssl=self._conf.listen_tls)
143+
use_ssl=use_tls)
135144
self.service.start()
136145
LOG.info('Started API service on port %s',
137146
self.agent.listen_address.port)

ironic_python_agent/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@
6666
'key_file, and, if desired, ca_file to validate client '
6767
'certificates.'),
6868

69+
cfg.BoolOpt('enable_auto_tls',
70+
default=True,
71+
help='Enables auto-generating TLS parameters when listen_tls '
72+
'is False and ironic API version indicates support for '
73+
'automatic agent TLS.'),
74+
6975
cfg.StrOpt('advertise_host',
7076
default=APARAMS.get('ipa-advertise-host', None),
7177
help='The host to tell Ironic to reply and send '

ironic_python_agent/hardware.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from ironic_python_agent.extensions import base as ext_base
4141
from ironic_python_agent import netutils
4242
from ironic_python_agent import raid_utils
43+
from ironic_python_agent import tls_utils
4344
from ironic_python_agent import utils
4445

4546
_global_managers = None
@@ -648,6 +649,9 @@ def get_boot_info(self):
648649
def get_interface_info(self, interface_name):
649650
raise errors.IncompatibleHardwareMethodError()
650651

652+
def generate_tls_certificate(self, ip_address):
653+
raise errors.IncompatibleHardwareMethodError()
654+
651655
def erase_block_device(self, node, block_device):
652656
"""Attempt to erase a block device.
653657
@@ -2091,6 +2095,10 @@ def write_image(self, node, ports, image_info, configdrive=None):
20912095
# The result is asynchronous, wait here.
20922096
cmd.join()
20932097

2098+
def generate_tls_certificate(self, ip_address):
2099+
"""Generate a TLS certificate for the IP address."""
2100+
return tls_utils.generate_tls_certificate(ip_address)
2101+
20942102

20952103
def _compare_extensions(ext1, ext2):
20962104
mgr1 = ext1.obj

ironic_python_agent/ironic_api_client.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@
3232
MIN_IRONIC_VERSION = (1, 31)
3333
AGENT_VERSION_IRONIC_VERSION = (1, 36)
3434
AGENT_TOKEN_IRONIC_VERSION = (1, 62)
35+
AGENT_VERIFY_CA_IRONIC_VERSION = (1, 68)
3536
# NOTE(dtantsur): change this constant every time you add support for more
3637
# versions to ensure that we send the highest version we know about.
37-
MAX_KNOWN_VERSION = AGENT_TOKEN_IRONIC_VERSION
38+
MAX_KNOWN_VERSION = AGENT_VERIFY_CA_IRONIC_VERSION
3839

3940

4041
class APIClient(object):
@@ -101,7 +102,11 @@ def _get_ironic_api_version(self):
101102
return MIN_IRONIC_VERSION
102103
return self._ironic_api_version
103104

104-
def heartbeat(self, uuid, advertise_address, advertise_protocol='http'):
105+
def supports_auto_tls(self):
106+
return self._get_ironic_api_version() >= AGENT_VERIFY_CA_IRONIC_VERSION
107+
108+
def heartbeat(self, uuid, advertise_address, advertise_protocol='http',
109+
generated_cert=None):
105110
path = self.heartbeat_api.format(uuid=uuid)
106111

107112
data = {'callback_url': self._get_agent_url(advertise_address,
@@ -115,9 +120,14 @@ def heartbeat(self, uuid, advertise_address, advertise_protocol='http'):
115120
if api_ver >= AGENT_VERSION_IRONIC_VERSION:
116121
data['agent_version'] = version.version_info.release_string()
117122

123+
if api_ver >= AGENT_VERIFY_CA_IRONIC_VERSION and generated_cert:
124+
data['agent_verify_ca'] = generated_cert
125+
118126
api_ver = min(MAX_KNOWN_VERSION, api_ver)
119127
headers = self._get_ironic_api_version_header(api_ver)
120128

129+
LOG.debug('Heartbeat: announcing callback URL %s, API version is '
130+
'%d.%d', data['callback_url'], *api_ver)
121131
try:
122132
response = self._request('POST', path, data=data, headers=headers)
123133
except requests.exceptions.ConnectionError as e:

ironic_python_agent/tests/unit/test_agent.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from ironic_python_agent import inspector
3232
from ironic_python_agent import netutils
3333
from ironic_python_agent.tests.unit import base as ironic_agent_base
34+
from ironic_python_agent import tls_utils
3435
from ironic_python_agent import utils
3536

3637
EXPECTED_ERROR = RuntimeError('command execution failed')
@@ -858,12 +859,55 @@ def setUp(self):
858859
@mock.patch(
859860
'ironic_python_agent.hardware_managers.cna._detect_cna_card',
860861
mock.Mock())
862+
@mock.patch.object(hardware, 'dispatch_to_managers', autospec=True)
861863
@mock.patch('oslo_service.wsgi.Server', autospec=True)
862864
@mock.patch.object(hardware.HardwareManager, 'list_hardware_info',
863865
autospec=True)
864866
@mock.patch.object(hardware, 'get_managers', autospec=True)
865867
def test_run(self, mock_get_managers, mock_list_hardware,
866-
mock_wsgi):
868+
mock_wsgi, mock_dispatch):
869+
wsgi_server_request = mock_wsgi.return_value
870+
871+
def set_serve_api():
872+
self.agent.serve_api = False
873+
874+
wsgi_server_request.start.side_effect = set_serve_api
875+
876+
mock_dispatch.return_value = tls_utils.TlsCertificate(
877+
'I am a cert', '/path/to/cert', '/path/to/key')
878+
879+
self.agent.heartbeater = mock.Mock()
880+
self.agent.api_client = mock.Mock()
881+
self.agent.api_client.lookup_node = mock.Mock()
882+
883+
self.agent.run()
884+
885+
self.assertTrue(mock_get_managers.called)
886+
mock_wsgi.assert_called_once_with(CONF, 'ironic-python-agent',
887+
app=self.agent.api,
888+
host=mock.ANY, port=9999,
889+
use_ssl=True)
890+
wsgi_server_request.start.assert_called_once_with()
891+
mock_dispatch.assert_called_once_with('generate_tls_certificate',
892+
mock.ANY)
893+
894+
self.assertEqual('/path/to/cert', CONF.ssl.cert_file)
895+
self.assertEqual('/path/to/key', CONF.ssl.key_file)
896+
self.assertEqual('https', self.agent.advertise_protocol)
897+
898+
self.assertFalse(self.agent.heartbeater.called)
899+
self.assertFalse(self.agent.api_client.lookup_node.called)
900+
901+
@mock.patch(
902+
'ironic_python_agent.hardware_managers.cna._detect_cna_card',
903+
mock.Mock())
904+
@mock.patch('oslo_service.wsgi.Server', autospec=True)
905+
@mock.patch.object(hardware.HardwareManager, 'list_hardware_info',
906+
autospec=True)
907+
@mock.patch.object(hardware, 'get_managers', autospec=True)
908+
def test_run_no_tls(self, mock_get_managers, mock_list_hardware,
909+
mock_wsgi):
910+
CONF.set_override('enable_auto_tls', False)
867911
wsgi_server_request = mock_wsgi.return_value
868912

869913
def set_serve_api():
@@ -883,6 +927,7 @@ def set_serve_api():
883927
host=mock.ANY, port=9999,
884928
use_ssl=False)
885929
wsgi_server_request.start.assert_called_once_with()
930+
self.assertEqual('http', self.agent.advertise_protocol)
886931

887932
self.assertFalse(self.agent.heartbeater.called)
888933
self.assertFalse(self.agent.api_client.lookup_node.called)

ironic_python_agent/tests/unit/test_ironic_api_client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,38 @@ def test_heartbeat_agent_version_unsupported(self):
205205
'callback_url': 'http://[fc00:1111::4]:9999'}
206206
self.assertEqual(jsonutils.dumps(expected_data), data)
207207

208+
def test_successful_heartbeat_with_verify_ca(self):
209+
response = FakeResponse(status_code=202)
210+
211+
self.api_client.session.request = mock.Mock()
212+
self.api_client.session.request.return_value = response
213+
self.api_client._ironic_api_version = (
214+
ironic_api_client.AGENT_VERIFY_CA_IRONIC_VERSION)
215+
self.api_client.agent_token = 'magical'
216+
217+
self.api_client.heartbeat(
218+
uuid='deadbeef-dabb-ad00-b105-f00d00bab10c',
219+
advertise_address=('192.0.2.1', '9999'),
220+
advertise_protocol='https',
221+
generated_cert='I am a cert',
222+
)
223+
224+
heartbeat_path = 'v1/heartbeat/deadbeef-dabb-ad00-b105-f00d00bab10c'
225+
request_args = self.api_client.session.request.call_args[0]
226+
data = self.api_client.session.request.call_args[1]['data']
227+
self.assertEqual('POST', request_args[0])
228+
self.assertEqual(API_URL + heartbeat_path, request_args[1])
229+
expected_data = {
230+
'callback_url': 'https://192.0.2.1:9999',
231+
'agent_token': 'magical',
232+
'agent_version': version.version_info.release_string(),
233+
'agent_verify_ca': 'I am a cert'}
234+
self.assertEqual(jsonutils.dumps(expected_data), data)
235+
headers = self.api_client.session.request.call_args[1]['headers']
236+
self.assertEqual(
237+
'%d.%d' % ironic_api_client.AGENT_VERIFY_CA_IRONIC_VERSION,
238+
headers['X-OpenStack-Ironic-API-Version'])
239+
208240
def test_heartbeat_requests_exception(self):
209241
self.api_client.session.request = mock.Mock()
210242
self.api_client.session.request.side_effect = Exception('api is down!')
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright 2020 Red Hat, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import ipaddress
16+
import os
17+
import tempfile
18+
from unittest import mock
19+
20+
from cryptography.hazmat import backends
21+
from cryptography import x509
22+
23+
from ironic_python_agent.tests.unit import base as ironic_agent_base
24+
from ironic_python_agent import tls_utils
25+
26+
27+
class GenerateTestCase(ironic_agent_base.IronicAgentTest):
28+
29+
def setUp(self):
30+
super().setUp()
31+
tempdir = tempfile.mkdtemp()
32+
self.crt_file = os.path.join(tempdir, 'localhost.crt')
33+
self.key_file = os.path.join(tempdir, 'localhost.key')
34+
35+
def test__generate(self):
36+
result = tls_utils._generate_tls_certificate(self.crt_file,
37+
self.key_file,
38+
'localhost', '127.0.0.1')
39+
self.assertTrue(result.startswith("-----BEGIN CERTIFICATE-----\n"),
40+
result)
41+
self.assertTrue(result.endswith("\n-----END CERTIFICATE-----\n"),
42+
result)
43+
self.assertTrue(os.path.exists(self.key_file))
44+
with open(self.crt_file, 'rt') as fp:
45+
self.assertEqual(result, fp.read())
46+
47+
cert = x509.load_pem_x509_certificate(result.encode(),
48+
backends.default_backend())
49+
self.assertEqual([(x509.NameOID.COMMON_NAME, 'localhost')],
50+
[(item.oid, item.value) for item in cert.subject])
51+
subject_alt_name = cert.extensions.get_extension_for_oid(
52+
x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
53+
self.assertTrue(subject_alt_name.critical)
54+
self.assertEqual(
55+
[ipaddress.IPv4Address('127.0.0.1')],
56+
subject_alt_name.value.get_values_for_type(x509.IPAddress))
57+
self.assertEqual(
58+
[], subject_alt_name.value.get_values_for_type(x509.DNSName))
59+
60+
@mock.patch('ironic_python_agent.netutils.get_hostname', autospec=True)
61+
@mock.patch('os.makedirs', autospec=True)
62+
@mock.patch.object(tls_utils, '_generate_tls_certificate', autospec=True)
63+
def test_generate(self, mock_generate, mock_makedirs, mock_hostname):
64+
result = tls_utils.generate_tls_certificate('127.0.0.1')
65+
mock_generate.assert_called_once_with(result.path,
66+
result.private_key_path,
67+
mock_hostname.return_value,
68+
'127.0.0.1')

0 commit comments

Comments
 (0)