From cbb7f19a344e53ccd83b375892f30bd343e72414 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 23 Aug 2021 13:12:39 +0200 Subject: [PATCH] Basic support for vmedia TLS certificates from version 1.4.0 Adds two new VirtualMedia fields: Certificates and VerifyCertificate. The former currently points at an empty collection, which cannot be updated. The latter can be set via PATCH on a VirtualMedia resource. TLS certificate validation has been disabled by default to match what actual hardware does. Change-Id: If0090b865d2106b90a13f9ca7329db6f8de6fc8a --- doc/source/admin/emulator.conf | 2 +- .../verify-certificate-798f84905cee03e5.yaml | 9 +++ sushy_tools/emulator/main.py | 57 +++++++++++++++ sushy_tools/emulator/resources/vmedia.py | 24 +++++-- .../templates/certificate_collection.json | 18 +++++ .../emulator/templates/virtual_media.json | 6 +- sushy_tools/error.py | 14 ++++ .../unit/emulator/resources/test_vmedia.py | 70 +++++++++++++++---- sushy_tools/tests/unit/emulator/test_main.py | 53 +++++++++++++- 9 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 releasenotes/notes/verify-certificate-798f84905cee03e5.yaml create mode 100644 sushy_tools/emulator/templates/certificate_collection.json diff --git a/doc/source/admin/emulator.conf b/doc/source/admin/emulator.conf index 8e0db8cd..167f748a 100644 --- a/doc/source/admin/emulator.conf +++ b/doc/source/admin/emulator.conf @@ -99,7 +99,7 @@ SUSHY_EMULATOR_VMEDIA_DEVICES = { # Instruct the virtual media insertion not to verify the SSL certificate # when retrieving the image. -SUSHY_EMULATOR_VMEDIA_VERIFY_SSL = True +SUSHY_EMULATOR_VMEDIA_VERIFY_SSL = False # This map contains statically configured Redfish Storage resource linked # up with the Systems resource, keyed by the UUIDs of the Systems. diff --git a/releasenotes/notes/verify-certificate-798f84905cee03e5.yaml b/releasenotes/notes/verify-certificate-798f84905cee03e5.yaml new file mode 100644 index 00000000..0e410eaf --- /dev/null +++ b/releasenotes/notes/verify-certificate-798f84905cee03e5.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Supports reading and changing ``VerifyCertificate`` in ``VirtualMedia`` + resources. +upgrade: + - | + The default value of ``SUSHY_EMULATOR_VMEDIA_VERIFY_SSL`` has been changed + to ``False`` to match the actual bare metal hardware. diff --git a/sushy_tools/emulator/main.py b/sushy_tools/emulator/main.py index 991da20d..83227822 100755 --- a/sushy_tools/emulator/main.py +++ b/sushy_tools/emulator/main.py @@ -434,9 +434,66 @@ def virtual_media_resource(identity, device): write_protected=device_info.write_protected, username=device_info.username, password=device_info.password, + verify_certificate=device_info.verify, ) +@app.route( + '/redfish/v1/Managers//VirtualMedia/', + methods=['PATCH']) +@returns_json +def virtual_media_patch(identity, device): + if not flask.request.json: + raise error.BadRequest("Empty or malformed patch") + + app.logger.debug('Updating virtual media %s at manager "%s"', + device, identity) + + verify = flask.request.json.get('VerifyCertificate') + if verify is not None: + if not isinstance(verify, bool): + raise error.BadRequest("VerifyCertificate must be a boolean") + + try: + app.vmedia.update_device_info(identity, device, verify=verify) + except error.FishyError as ex: + app.logger.warning( + 'Virtual media %s at manager %s error: ' + '%s', device, identity, ex) + raise error.NotFound("Virtual media device not found") + + return '', 204 + else: + raise error.BadRequest("Empty or malformed patch") + + +@app.route( + '/redfish/v1/Managers//VirtualMedia//Certificates', + methods=['GET']) +@returns_json +def virtual_media_certificates(identity, device): + location = \ + f'/redfish/v1/Managers/{identity}/VirtualMedia/{device}/Certificates' + return flask.render_template( + 'certificate_collection.json', + location=location, + # TODO(dtantsur): implement + certificates=[], + ) + + +@app.route( + '/redfish/v1/Managers//VirtualMedia//Certificates', + methods=['POST']) +@returns_json +def virtual_media_add_certificate(identity, device): + if not flask.request.json: + raise error.BadRequest("Empty or malformed certificate") + + # TODO(dtantsur): implement + raise error.NotSupportedError("Not implemented") + + @app.route('/redfish/v1/Managers//VirtualMedia/' '/Actions/VirtualMedia.InsertMedia', methods=['POST']) diff --git a/sushy_tools/emulator/resources/vmedia.py b/sushy_tools/emulator/resources/vmedia.py index c9b93171..e2174118 100644 --- a/sushy_tools/emulator/resources/vmedia.py +++ b/sushy_tools/emulator/resources/vmedia.py @@ -29,7 +29,7 @@ from sushy_tools import error DeviceInfo = collections.namedtuple( 'DeviceInfo', ['image_name', 'image_url', 'inserted', 'write_protected', - 'username', 'password']) + 'username', 'password', 'verify']) class StaticDriver(base.DriverBase): @@ -135,7 +135,20 @@ class StaticDriver(base.DriverBase): device_info.get('Inserted', False), device_info.get('WriteProtected', False), device_info.get('UserName', ''), - device_info.get('Password', '')) + device_info.get('Password', ''), + device_info.get('Verify', False)) + + def update_device_info(self, identity, device, verify=False): + """Update the virtual media device + + :param identity: parent resource ID + :param device: device name + :param verify: new value for VerifyCertificate + :raises: `error.FishyError` + """ + device_info = self._get_device(identity, device) + device_info['Verify'] = verify + self._devices[(identity, device)] = device_info def _write_from_response(self, image_url, rsp, tmp_file): with open(tmp_file.name, 'wb') as fl: @@ -175,8 +188,11 @@ class StaticDriver(base.DriverBase): :raises: `FishyError` if image can't be manipulated """ device_info = self._get_device(identity, device) - verify_media_cert = self._config.get( - 'SUSHY_EMULATOR_VMEDIA_VERIFY_SSL', True) + verify_media_cert = device_info.get( + 'Verify', + # NOTE(dtantsur): it's de facto standard for Redfish to default + # to no certificate validation. + self._config.get('SUSHY_EMULATOR_VMEDIA_VERIFY_SSL', False)) auth = (username, password) if (username and password) else None try: diff --git a/sushy_tools/emulator/templates/certificate_collection.json b/sushy_tools/emulator/templates/certificate_collection.json new file mode 100644 index 00000000..6eb153ce --- /dev/null +++ b/sushy_tools/emulator/templates/certificate_collection.json @@ -0,0 +1,18 @@ +{ + "@odata.type": "#CertificateCollection.CertificateCollection", + "Name": "Certificate Collection", + "Members@odata.count": {{ certificates|length }}, + "Members": [ + {% for item in certificates %} + { + "@odata.id": {{ "%s/%s"|format(location, item)|tojson }} + }{% if not loop.last %},{% endif %} + {% endfor %} + ], + "@Redfish.SupportedCertificates": [ + "PEM" + ], + "Oem": {}, + "@odata.id": {{ location|tojson }}, + "@Redfish.Copyright": "Copyright 2014-2021 DMTF. For the full DMTF copyright policy, see http://www.dmtf.org/about/policies/copyright." +} diff --git a/sushy_tools/emulator/templates/virtual_media.json b/sushy_tools/emulator/templates/virtual_media.json index a726d693..b157d02b 100644 --- a/sushy_tools/emulator/templates/virtual_media.json +++ b/sushy_tools/emulator/templates/virtual_media.json @@ -1,5 +1,5 @@ { - "@odata.type": "#VirtualMedia.v1_3_0.VirtualMedia", + "@odata.type": "#VirtualMedia.v1_4_0.VirtualMedia", "Id": {{ device|string|tojson }}, "Name": {{ name|string|tojson }}, "MediaTypes": [ @@ -23,6 +23,10 @@ }, "UserName": {{ username|string|tojson }}, "Password": "{{ '******' if password else '' }}", + "Certificates": { + "@odata.id": {{ "/redfish/v1/Managers/%s/VirtualMedia/%s/Certificates"|format(identity, device)|tojson }} + }, + "VerifyCertificate": {{ verify_certificate|tojson }}, "@odata.context": "/redfish/v1/$metadata#VirtualMedia.VirtualMedia", "@odata.id": {{ "/redfish/v1/Managers/%s/VirtualMedia/%s"|format(identity, device)|string|tojson }}, "@Redfish.Copyright": "Copyright 2014-2017 Distributed Management Task Force, Inc. (DMTF). For the full DMTF copyright policy, see http://www.dmtf.org/about/policies/copyright." diff --git a/sushy_tools/error.py b/sushy_tools/error.py index 0504dde4..f258a9a8 100644 --- a/sushy_tools/error.py +++ b/sushy_tools/error.py @@ -28,3 +28,17 @@ class AliasAccessError(FishyError): class NotSupportedError(FishyError): """Feature not supported by resource driver""" + + +class NotFound(FishyError): + """Entity not found.""" + + def __init__(self, msg, code=404): + super().__init__(msg, code) + + +class BadRequest(FishyError): + """Malformed request.""" + + def __init__(self, msg, code=400): + super().__init__(msg, code) diff --git a/sushy_tools/tests/unit/emulator/resources/test_vmedia.py b/sushy_tools/tests/unit/emulator/resources/test_vmedia.py index 9e887540..23c716ad 100644 --- a/sushy_tools/tests/unit/emulator/resources/test_vmedia.py +++ b/sushy_tools/tests/unit/emulator/resources/test_vmedia.py @@ -69,9 +69,17 @@ class StaticDriverTestCase(base.BaseTestCase): def test_get_device_image_info(self): dev_info = self.test_driver.get_device_image_info( self.UUID, 'Cd') - expected = ('', '', False, False, '', '') + expected = ('', '', False, False, '', '', False) self.assertEqual(expected, dev_info) + def test_update_device_info(self): + dev_info = self.test_driver.get_device_image_info(self.UUID, 'Cd') + self.assertFalse(dev_info.verify) + + self.test_driver.update_device_info(self.UUID, 'Cd', verify=True) + dev_info = self.test_driver.get_device_image_info(self.UUID, 'Cd') + self.assertTrue(dev_info.verify) + @mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True) @mock.patch.object(builtins, 'open', autospec=True) @mock.patch.object(vmedia.os, 'rename', autospec=True) @@ -99,7 +107,7 @@ class StaticDriverTestCase(base.BaseTestCase): self.assertEqual('/alphabet/soup/fish.iso', local_file) mock_requests.get.assert_called_once_with( - 'http://fish.it/red.iso', stream=True, verify=True, auth=None) + 'http://fish.it/red.iso', stream=True, verify=False, auth=None) mock_open.assert_called_once_with(mock.ANY, 'wb') mock_rename.assert_called_once_with( 'alphabet.soup', '/alphabet/soup/fish.iso') @@ -138,7 +146,7 @@ class StaticDriverTestCase(base.BaseTestCase): self.assertEqual('/alphabet/soup/fish.iso', local_file) mock_requests.get.assert_called_once_with( - 'http://fish.it/red.iso', stream=True, verify=True, + 'http://fish.it/red.iso', stream=True, verify=False, auth=('Admin', 'Secret')) mock_open.assert_called_once_with(mock.ANY, 'wb') mock_rename.assert_called_once_with( @@ -177,7 +185,7 @@ class StaticDriverTestCase(base.BaseTestCase): self.assertEqual('/alphabet/soup/red.iso', local_file) mock_requests.get.assert_called_once_with( - 'http://fish.it/red.iso', stream=True, verify=True, auth=None) + 'http://fish.it/red.iso', stream=True, verify=False, auth=None) mock_open.assert_called_once_with(mock.ANY, 'wb') mock_rename.assert_called_once_with( 'alphabet.soup', '/alphabet/soup/red.iso') @@ -213,7 +221,7 @@ class StaticDriverTestCase(base.BaseTestCase): self.assertEqual('/alphabet/soup/boot-abc', local_file) mock_requests.get.assert_called_once_with(full_url, stream=True, - verify=True, auth=None) + verify=False, auth=None) mock_open.assert_called_once_with(mock.ANY, 'wb') mock_rename.assert_called_once_with( 'alphabet.soup', '/alphabet/soup/boot-abc') @@ -227,9 +235,9 @@ class StaticDriverTestCase(base.BaseTestCase): @mock.patch.object(vmedia.os, 'rename', autospec=True) @mock.patch.object(vmedia, 'tempfile', autospec=True) @mock.patch.object(vmedia, 'requests', autospec=True) - def test_insert_image_no_verify_ssl(self, mock_requests, mock_tempfile, - mock_rename, mock_open, - mock_get_device): + def test_insert_image_verify_ssl(self, mock_requests, mock_tempfile, + mock_rename, mock_open, + mock_get_device): device_info = {} mock_get_device.return_value = device_info @@ -247,8 +255,7 @@ class StaticDriverTestCase(base.BaseTestCase): ssl_conf_key = 'SUSHY_EMULATOR_VMEDIA_VERIFY_SSL' default_ssl_verify = self.test_driver._config.get(ssl_conf_key) try: - self.test_driver._config[ssl_conf_key] = ( - False) + self.test_driver._config[ssl_conf_key] = True local_file = self.test_driver.insert_image( self.UUID, 'Cd', 'https://fish.it/red.iso', inserted=True, write_protected=False) @@ -257,7 +264,46 @@ class StaticDriverTestCase(base.BaseTestCase): self.assertEqual('/alphabet/soup/fish.iso', local_file) mock_requests.get.assert_called_once_with( - 'https://fish.it/red.iso', stream=True, verify=False, auth=None) + 'https://fish.it/red.iso', stream=True, verify=True, auth=None) + mock_open.assert_called_once_with(mock.ANY, 'wb') + mock_rename.assert_called_once_with( + 'alphabet.soup', '/alphabet/soup/fish.iso') + + self.assertEqual('fish.iso', device_info['Image']) + self.assertTrue(device_info['Inserted']) + self.assertFalse(device_info['WriteProtected']) + self.assertEqual(local_file, device_info['_local_file']) + + @mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True) + @mock.patch.object(builtins, 'open', autospec=True) + @mock.patch.object(vmedia.os, 'rename', autospec=True) + @mock.patch.object(vmedia, 'tempfile', autospec=True) + @mock.patch.object(vmedia, 'requests', autospec=True) + def test_insert_image_verify_ssl_changed(self, mock_requests, + mock_tempfile, + mock_rename, mock_open, + mock_get_device): + device_info = {'Verify': True} + mock_get_device.return_value = device_info + + mock_tempfile.mkdtemp.return_value = '/alphabet/soup' + mock_tempfile.gettempdir.return_value = '/tmp' + mock_tmp_file = (mock_tempfile.NamedTemporaryFile + .return_value.__enter__.return_value) + mock_tmp_file.name = 'alphabet.soup' + mock_rsp = mock_requests.get.return_value.__enter__.return_value + mock_rsp.headers = { + 'content-disposition': 'attachment; filename="fish.iso"' + } + mock_rsp.status_code = 200 + + local_file = self.test_driver.insert_image( + self.UUID, 'Cd', 'https://fish.it/red.iso', inserted=True, + write_protected=False) + + self.assertEqual('/alphabet/soup/fish.iso', local_file) + mock_requests.get.assert_called_once_with( + 'https://fish.it/red.iso', stream=True, verify=True, auth=None) mock_open.assert_called_once_with(mock.ANY, 'wb') mock_rename.assert_called_once_with( 'alphabet.soup', '/alphabet/soup/fish.iso') @@ -293,7 +339,7 @@ class StaticDriverTestCase(base.BaseTestCase): self.UUID, 'Cd', 'http://fish.it/red.iso', inserted=True, write_protected=False) mock_requests.get.assert_called_once_with( - 'http://fish.it/red.iso', stream=True, auth=None, verify=True) + 'http://fish.it/red.iso', stream=True, auth=None, verify=False) mock_open.assert_not_called() self.assertEqual({}, device_info) diff --git a/sushy_tools/tests/unit/emulator/test_main.py b/sushy_tools/tests/unit/emulator/test_main.py index 1e9d7b83..b9e0cd61 100644 --- a/sushy_tools/tests/unit/emulator/test_main.py +++ b/sushy_tools/tests/unit/emulator/test_main.py @@ -493,7 +493,7 @@ class VirtualMediaTestCase(EmulatorTestCase): vmedia_mock.get_device_media_types.return_value = [ 'CD', 'DVD'] vmedia_mock.get_device_image_info.return_value = vmedia.DeviceInfo( - 'image-of-a-fish', 'fishy.iso', True, True, '', '') + 'image-of-a-fish', 'fishy.iso', True, True, '', '', False) response = self.app.get( '/redfish/v1/Managers/%s/VirtualMedia/CD' % self.uuid) @@ -507,6 +507,10 @@ class VirtualMediaTestCase(EmulatorTestCase): self.assertTrue(response.json['WriteProtected']) self.assertEqual('', response.json['UserName']) self.assertEqual('', response.json['Password']) + self.assertFalse(response.json['VerifyCertificate']) + self.assertEqual( + '/redfish/v1/Managers/%s/VirtualMedia/CD/Certificates' % self.uuid, + response.json['Certificates']['@odata.id']) def test_virtual_media_with_auth(self, managers_mock, vmedia_mock): vmedia_mock = vmedia_mock.return_value @@ -514,7 +518,8 @@ class VirtualMediaTestCase(EmulatorTestCase): vmedia_mock.get_device_media_types.return_value = [ 'CD', 'DVD'] vmedia_mock.get_device_image_info.return_value = vmedia.DeviceInfo( - 'image-of-a-fish', 'fishy.iso', True, True, 'Admin', 'Secret') + 'image-of-a-fish', 'fishy.iso', True, True, 'Admin', 'Secret', + False) response = self.app.get( '/redfish/v1/Managers/%s/VirtualMedia/CD' % self.uuid) @@ -528,6 +533,7 @@ class VirtualMediaTestCase(EmulatorTestCase): self.assertTrue(response.json['WriteProtected']) self.assertEqual('Admin', response.json['UserName']) self.assertEqual('******', response.json['Password']) + self.assertFalse(response.json['VerifyCertificate']) def test_virtual_media_not_found(self, managers_mock, vmedia_mock): vmedia_mock.return_value.get_device_name.side_effect = error.FishyError @@ -537,6 +543,39 @@ class VirtualMediaTestCase(EmulatorTestCase): self.assertEqual(404, response.status_code) + def test_virtual_media_update(self, managers_mock, vmedia_mock): + response = self.app.patch( + '/redfish/v1/Managers/%s/VirtualMedia/CD' % self.uuid, + json={'VerifyCertificate': True}) + + self.assertEqual(204, response.status_code) + vmedia_mock = vmedia_mock.return_value + vmedia_mock.update_device_info.assert_called_once_with( + self.uuid, 'CD', verify=True) + + def test_virtual_media_update_not_found(self, managers_mock, vmedia_mock): + vmedia_mock = vmedia_mock.return_value + vmedia_mock.update_device_info.side_effect = error.FishyError + + response = self.app.patch( + '/redfish/v1/Managers/%s/VirtualMedia/DVD-ROM' % self.uuid, + json={'VerifyCertificate': True}) + + self.assertEqual(404, response.status_code) + + def test_virtual_media_update_invalid(self, managers_mock, vmedia_mock): + response = self.app.patch( + '/redfish/v1/Managers/%s/VirtualMedia/CD' % self.uuid, + json={'VerifyCertificate': 'banana'}) + + self.assertEqual(400, response.status_code) + + def test_virtual_media_update_empty(self, managers_mock, vmedia_mock): + response = self.app.patch( + '/redfish/v1/Managers/%s/VirtualMedia/CD' % self.uuid) + + self.assertEqual(400, response.status_code) + def test_virtual_media_insert(self, managers_mock, vmedia_mock): response = self.app.post( '/redfish/v1/Managers/%s/VirtualMedia/CD/Actions/' @@ -558,6 +597,16 @@ class VirtualMediaTestCase(EmulatorTestCase): vmedia_mock.return_value.eject_image.called_once_with('CD') + def test_virtual_media_certificates(self, managers_mock, vmedia_mock): + response = self.app.get( + '/redfish/v1/Managers/%s/VirtualMedia/CD/Certificates' % self.uuid) + + self.assertEqual(200, response.status_code, response.json) + self.assertEqual(0, response.json['Members@odata.count']) + self.assertEqual([], response.json['Members']) + self.assertEqual(['PEM'], + response.json['@Redfish.SupportedCertificates']) + @patch_resource('systems') class StorageTestCase(EmulatorTestCase):