From: Chuck Fouts Date: Thu, 4 Jun 2015 14:28:23 +0000 (-0400) Subject: NetApp: Support iSCSI CHAP Uni-directional Auth X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=ce3052a867771875f8f472438bcc187caa3021e7;p=openstack-build%2Fcinder-build.git NetApp: Support iSCSI CHAP Uni-directional Auth This change adds iSCSI CHAP uni-directional authentication support for NetApp cDOT and 7-Mode iSCSI driver. Enabling CHAP authentication does not impact an existing iSCSI session. The iSCSI session needs to be reestablished before CHAP authentication is initiated. Co-Authored-By: Dustin Schoenbrun Co-Authored-By: Alex Meade Implements: blueprint netapp-add-chap-authentication-iscsi DocImpact Change-Id: I8c481fa09aee02b5472f02819b1a342a3c3e7f71 --- diff --git a/cinder/tests/unit/test_netapp.py b/cinder/tests/unit/test_netapp.py index bea686a0d..bfaca9e6c 100644 --- a/cinder/tests/unit/test_netapp.py +++ b/cinder/tests/unit/test_netapp.py @@ -578,6 +578,7 @@ class NetAppDirectCmodeISCSIDriverTestCase(test.TestCase): FakeDirectCmodeHTTPConnection) driver.do_setup(context='') self.driver = driver + self.mock_object(self.driver.library.zapi_client, '_init_ssh_client') self.driver.ssc_vols = self.ssc_map def _set_config(self, configuration): diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/fakes.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/fakes.py index b21858fd7..5dcf5bd35 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/fakes.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/fakes.py @@ -91,6 +91,10 @@ FAKE_RESULT_SUCCESS = netapp_api.NaElement('result') FAKE_RESULT_SUCCESS.add_attr('status', 'passed') FAKE_HTTP_OPENER = urllib.request.build_opener() +INITIATOR_IQN = 'iqn.2015-06.com.netapp:fake_iqn' +USER_NAME = 'fake_user' +PASSWORD = 'passw0rd' +ENCRYPTED_PASSWORD = 'B351F145DA527445' NO_RECORDS_RESPONSE = etree.XML(""" @@ -676,3 +680,12 @@ SYSTEM_GET_INFO_RESPONSE = etree.XML(""" """ % {'node': NODE_NAME}) + +ISCSI_INITIATOR_GET_AUTH_ELEM = etree.XML(""" + + %s +""" % INITIATOR_IQN) + +ISCSI_INITIATOR_AUTH_LIST_INFO_FAILURE = etree.XML(""" +""" % INITIATOR_IQN) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_api.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_api.py index f577c9cea..783d148c0 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_api.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_api.py @@ -21,6 +21,7 @@ Tests for NetApp API layer import ddt from lxml import etree import mock +import paramiko import six from six.moves import urllib @@ -507,3 +508,104 @@ class NetAppApiInvokeTests(test.TestCase): self.assertEqual(zapi_fakes.FAKE_API_NAME_ELEMENT.to_string(), netapp_api.create_api_request(**params).to_string()) + + +@ddt.ddt +class SSHUtilTests(test.TestCase): + """Test Cases for SSH API invocation.""" + + def setUp(self): + super(SSHUtilTests, self).setUp() + self.mock_object(netapp_api.SSHUtil, '_init_ssh_pool') + self.sshutil = netapp_api.SSHUtil('127.0.0.1', + 'fake_user', + 'fake_password') + + def test_execute_command(self): + ssh = mock.Mock(paramiko.SSHClient) + stdin, stdout, stderr = self._mock_ssh_channel_files( + paramiko.ChannelFile) + self.mock_object(ssh, 'exec_command', + mock.Mock(return_value=(stdin, + stdout, + stderr))) + + wait_on_stdout = self.mock_object(self.sshutil, '_wait_on_stdout') + stdout_read = self.mock_object(stdout, 'read', + mock.Mock(return_value='')) + self.sshutil.execute_command(ssh, 'ls') + + wait_on_stdout.assert_called_once_with(stdout, + netapp_api.SSHUtil.RECV_TIMEOUT) + stdout_read.assert_called_once_with() + + def test_execute_read_exception(self): + ssh = mock.Mock(paramiko.SSHClient) + exec_command = self.mock_object(ssh, 'exec_command') + exec_command.side_effect = paramiko.SSHException('Failure') + wait_on_stdout = self.mock_object(self.sshutil, '_wait_on_stdout') + + self.assertRaises(paramiko.SSHException, + self.sshutil.execute_command, ssh, 'ls') + wait_on_stdout.assert_not_called() + + @ddt.data('Password:', + 'Password: ', + 'Password: \n\n') + def test_execute_command_with_prompt(self, response): + ssh = mock.Mock(paramiko.SSHClient) + stdin, stdout, stderr = self._mock_ssh_channel_files(paramiko.Channel) + stdout_read = self.mock_object(stdout.channel, 'recv', + mock.Mock(return_value=response)) + stdin_write = self.mock_object(stdin, 'write') + self.mock_object(ssh, 'exec_command', + mock.Mock(return_value=(stdin, + stdout, + stderr))) + + wait_on_stdout = self.mock_object(self.sshutil, '_wait_on_stdout') + self.sshutil.execute_command_with_prompt(ssh, 'sudo ls', + 'Password:', 'easypass') + + wait_on_stdout.assert_called_once_with(stdout, + netapp_api.SSHUtil.RECV_TIMEOUT) + stdout_read.assert_called_once_with(999) + stdin_write.assert_called_once_with('easypass' + '\n') + + def test_execute_command_unexpected_response(self): + ssh = mock.Mock(paramiko.SSHClient) + stdin, stdout, stderr = self._mock_ssh_channel_files(paramiko.Channel) + stdout_read = self.mock_object(stdout.channel, 'recv', + mock.Mock(return_value='bad response')) + self.mock_object(ssh, 'exec_command', + mock.Mock(return_value=(stdin, + stdout, + stderr))) + + wait_on_stdout = self.mock_object(self.sshutil, '_wait_on_stdout') + self.assertRaises(exception.VolumeBackendAPIException, + self.sshutil.execute_command_with_prompt, + ssh, 'sudo ls', 'Password:', 'easypass') + + wait_on_stdout.assert_called_once_with(stdout, + netapp_api.SSHUtil.RECV_TIMEOUT) + stdout_read.assert_called_once_with(999) + + def test_wait_on_stdout(self): + stdout = mock.Mock() + stdout.channel = mock.Mock(paramiko.Channel) + + exit_status = self.mock_object(stdout.channel, 'exit_status_ready', + mock.Mock(return_value=False)) + self.sshutil._wait_on_stdout(stdout, 1) + exit_status.assert_any_call() + self.assertTrue(exit_status.call_count > 2) + + def _mock_ssh_channel_files(self, channel): + stdin = mock.Mock() + stdin.channel = mock.Mock(channel) + stdout = mock.Mock() + stdout.channel = mock.Mock(channel) + stderr = mock.Mock() + stderr.channel = mock.Mock(channel) + return stdin, stdout, stderr diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_7mode.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_7mode.py index b111033d5..2b072cc8f 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_7mode.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_7mode.py @@ -18,8 +18,10 @@ import uuid from lxml import etree import mock +import paramiko import six +from cinder import ssh_utils from cinder import test from cinder.tests.unit.volume.drivers.netapp.dataontap.client import ( fakes as fake_client) @@ -42,12 +44,14 @@ class NetApp7modeClientTestCase(test.TestCase): self.fake_volume = six.text_type(uuid.uuid4()) + self.mock_object(client_7mode.Client, '_init_ssh_client') with mock.patch.object(client_7mode.Client, 'get_ontapi_version', return_value=(1, 20)): self.client = client_7mode.Client([self.fake_volume], **CONNECTION_INFO) + self.client.ssh_client = mock.MagicMock() self.client.connection = mock.MagicMock() self.connection = self.client.connection self.fake_lun = six.text_type(uuid.uuid4()) @@ -729,3 +733,38 @@ class NetApp7modeClientTestCase(test.TestCase): result = self.client.get_system_name() self.assertEqual(fake_client.NODE_NAME, result) + + def test_check_iscsi_initiator_exists_when_no_initiator_exists(self): + self.connection.invoke_successfully = mock.Mock( + side_effect=netapp_api.NaApiError) + initiator = fake_client.INITIATOR_IQN + + initiator_exists = self.client.check_iscsi_initiator_exists(initiator) + + self.assertFalse(initiator_exists) + + def test_check_iscsi_initiator_exists_when_initiator_exists(self): + self.connection.invoke_successfully = mock.Mock() + initiator = fake_client.INITIATOR_IQN + + initiator_exists = self.client.check_iscsi_initiator_exists(initiator) + + self.assertTrue(initiator_exists) + + def test_set_iscsi_chap_authentication(self): + ssh = mock.Mock(paramiko.SSHClient) + sshpool = mock.Mock(ssh_utils.SSHPool) + self.client.ssh_client.ssh_pool = sshpool + self.mock_object(self.client.ssh_client, 'execute_command') + sshpool.item().__enter__ = mock.Mock(return_value=ssh) + sshpool.item().__exit__ = mock.Mock(return_value=False) + + self.client.set_iscsi_chap_authentication(fake_client.INITIATOR_IQN, + fake_client.USER_NAME, + fake_client.PASSWORD) + + command = ('iscsi security add -i iqn.2015-06.com.netapp:fake_iqn ' + '-s CHAP -p passw0rd -n fake_user') + self.client.ssh_client.execute_command.assert_has_calls( + [mock.call(ssh, command)] + ) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_base.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_base.py index 7c08df3f4..b492828cb 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_base.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_base.py @@ -41,8 +41,10 @@ class NetAppBaseClientTestCase(test.TestCase): super(NetAppBaseClientTestCase, self).setUp() self.mock_object(client_base, 'LOG') + self.mock_object(client_base.Client, '_init_ssh_client') self.client = client_base.Client(**CONNECTION_INFO) self.client.connection = mock.MagicMock() + self.client.ssh_client = mock.MagicMock() self.connection = self.client.connection self.fake_volume = six.text_type(uuid.uuid4()) self.fake_lun = six.text_type(uuid.uuid4()) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py index 7dd075fcd..13f295972 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py @@ -18,9 +18,11 @@ import uuid from lxml import etree import mock +import paramiko import six from cinder import exception +from cinder import ssh_utils from cinder import test from cinder.tests.unit.volume.drivers.netapp.dataontap.client import ( fakes as fake_client) @@ -43,13 +45,16 @@ class NetAppCmodeClientTestCase(test.TestCase): def setUp(self): super(NetAppCmodeClientTestCase, self).setUp() + self.mock_object(client_cmode.Client, '_init_ssh_client') with mock.patch.object(client_cmode.Client, 'get_ontapi_version', return_value=(1, 20)): self.client = client_cmode.Client(**CONNECTION_INFO) + self.client.ssh_client = mock.MagicMock() self.client.connection = mock.MagicMock() self.connection = self.client.connection + self.vserver = CONNECTION_INFO['vserver'] self.fake_volume = six.text_type(uuid.uuid4()) self.fake_lun = six.text_type(uuid.uuid4()) @@ -1159,3 +1164,85 @@ class NetAppCmodeClientTestCase(test.TestCase): self.mock_send_request.assert_called_once_with( 'perf-object-get-instances', perf_object_get_instances_args, enable_tunneling=False) + + def test_check_iscsi_initiator_exists_when_no_initiator_exists(self): + self.connection.invoke_successfully = mock.Mock( + side_effect=netapp_api.NaApiError) + initiator = fake_client.INITIATOR_IQN + + initiator_exists = self.client.check_iscsi_initiator_exists(initiator) + + self.assertFalse(initiator_exists) + + def test_check_iscsi_initiator_exists_when_initiator_exists(self): + self.connection.invoke_successfully = mock.Mock() + initiator = fake_client.INITIATOR_IQN + + initiator_exists = self.client.check_iscsi_initiator_exists(initiator) + + self.assertTrue(initiator_exists) + + def test_set_iscsi_chap_authentication_no_previous_initiator(self): + self.connection.invoke_successfully = mock.Mock() + self.mock_object(self.client, 'check_iscsi_initiator_exists', + mock.Mock(return_value=False)) + + ssh = mock.Mock(paramiko.SSHClient) + sshpool = mock.Mock(ssh_utils.SSHPool) + self.client.ssh_client.ssh_pool = sshpool + self.mock_object(self.client.ssh_client, 'execute_command_with_prompt') + sshpool.item().__enter__ = mock.Mock(return_value=ssh) + sshpool.item().__exit__ = mock.Mock(return_value=False) + + self.client.set_iscsi_chap_authentication(fake_client.INITIATOR_IQN, + fake_client.USER_NAME, + fake_client.PASSWORD) + + command = ('iscsi security create -vserver fake_vserver ' + '-initiator-name iqn.2015-06.com.netapp:fake_iqn ' + '-auth-type CHAP -user-name fake_user') + self.client.ssh_client.execute_command_with_prompt.assert_has_calls( + [mock.call(ssh, command, 'Password:', fake_client.PASSWORD)] + ) + + def test_set_iscsi_chap_authentication_with_preexisting_initiator(self): + self.connection.invoke_successfully = mock.Mock() + self.mock_object(self.client, 'check_iscsi_initiator_exists', + mock.Mock(return_value=True)) + + ssh = mock.Mock(paramiko.SSHClient) + sshpool = mock.Mock(ssh_utils.SSHPool) + self.client.ssh_client.ssh_pool = sshpool + self.mock_object(self.client.ssh_client, 'execute_command_with_prompt') + sshpool.item().__enter__ = mock.Mock(return_value=ssh) + sshpool.item().__exit__ = mock.Mock(return_value=False) + + self.client.set_iscsi_chap_authentication(fake_client.INITIATOR_IQN, + fake_client.USER_NAME, + fake_client.PASSWORD) + + command = ('iscsi security modify -vserver fake_vserver ' + '-initiator-name iqn.2015-06.com.netapp:fake_iqn ' + '-auth-type CHAP -user-name fake_user') + self.client.ssh_client.execute_command_with_prompt.assert_has_calls( + [mock.call(ssh, command, 'Password:', fake_client.PASSWORD)] + ) + + def test_set_iscsi_chap_authentication_with_ssh_exception(self): + self.connection.invoke_successfully = mock.Mock() + self.mock_object(self.client, 'check_iscsi_initiator_exists', + mock.Mock(return_value=True)) + + ssh = mock.Mock(paramiko.SSHClient) + sshpool = mock.Mock(ssh_utils.SSHPool) + self.client.ssh_client.ssh_pool = sshpool + sshpool.item().__enter__ = mock.Mock(return_value=ssh) + sshpool.item().__enter__.side_effect = paramiko.SSHException( + 'Connection Failure') + sshpool.item().__exit__ = mock.Mock(return_value=False) + + self.assertRaises(exception.VolumeBackendAPIException, + self.client.set_iscsi_chap_authentication, + fake_client.INITIATOR_IQN, + fake_client.USER_NAME, + fake_client.PASSWORD) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py index 64d970d65..4df62ad33 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py @@ -130,9 +130,12 @@ ISCSI_SERVICE_IQN = 'fake_iscsi_service_iqn' ISCSI_CONNECTION_PROPERTIES = { 'data': { - 'auth_method': 'fake', + 'auth_method': 'fake_method', 'auth_password': 'auth', 'auth_username': 'provider', + 'discovery_auth_method': 'fake_method', + 'discovery_auth_username': 'provider', + 'discovery_auth_password': 'auth', 'target_discovered': False, 'target_iqn': ISCSI_SERVICE_IQN, 'target_lun': 42, diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_7mode.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_7mode.py index 7f1bde789..60668a699 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_7mode.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_7mode.py @@ -78,6 +78,8 @@ class NetAppBlockStorage7modeLibraryTestCase(test.TestCase): @mock.patch.object(block_base.NetAppBlockStorageLibrary, 'do_setup') def test_do_setup(self, super_do_setup, mock_do_partner_setup, mock_get_root_volume_name): + + self.mock_object(client_base.Client, '_init_ssh_client') mock_get_root_volume_name.return_value = 'vol0' context = mock.Mock() @@ -90,6 +92,7 @@ class NetAppBlockStorage7modeLibraryTestCase(test.TestCase): @mock.patch.object(client_base.Client, 'get_ontapi_version', mock.MagicMock(return_value=(1, 20))) def test_do_partner_setup(self): + self.mock_object(client_base.Client, '_init_ssh_client') self.library.configuration.netapp_partner_backend_name = 'partner' self.library._do_partner_setup() @@ -99,7 +102,7 @@ class NetAppBlockStorage7modeLibraryTestCase(test.TestCase): @mock.patch.object(client_base.Client, 'get_ontapi_version', mock.MagicMock(return_value=(1, 20))) def test_do_partner_setup_no_partner(self): - + self.mock_object(client_base.Client, '_init_ssh_client') self.library._do_partner_setup() self.assertFalse(hasattr(self.library, 'partner_zapi_client')) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py index 4ff3c4a3d..17e3eab88 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py @@ -3,6 +3,7 @@ # Copyright (c) 2014 Andrew Kerr. All rights reserved. # Copyright (c) 2015 Tom Barron. All rights reserved. # Copyright (c) 2015 Goutham Pacha Ravi. All rights reserved. +# Copyright (c) 2015 Dustin Schoenbrun. All rights reserved. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -524,6 +525,27 @@ class NetAppBlockStorageLibraryTestCase(test.TestCase): target_info = self.library.initialize_connection_iscsi(volume, connector) + self.assertEqual( + fake.ISCSI_CONNECTION_PROPERTIES['data']['auth_method'], + target_info['data']['auth_method']) + self.assertEqual( + fake.ISCSI_CONNECTION_PROPERTIES['data']['auth_password'], + target_info['data']['auth_password']) + self.assertTrue('auth_password' in target_info['data']) + + self.assertEqual( + fake.ISCSI_CONNECTION_PROPERTIES['data']['discovery_auth_method'], + target_info['data']['discovery_auth_method']) + self.assertEqual( + fake.ISCSI_CONNECTION_PROPERTIES['data'] + ['discovery_auth_password'], + target_info['data']['discovery_auth_password']) + self.assertTrue('auth_password' in target_info['data']) + self.assertEqual( + fake.ISCSI_CONNECTION_PROPERTIES['data'] + ['discovery_auth_username'], + target_info['data']['discovery_auth_username']) + self.assertEqual(fake.ISCSI_CONNECTION_PROPERTIES, target_info) block_base.NetAppBlockStorageLibrary._map_lun.assert_called_once_with( fake.ISCSI_VOLUME['name'], [fake.ISCSI_CONNECTOR['initiator']], @@ -666,8 +688,10 @@ class NetAppBlockStorageLibraryTestCase(test.TestCase): self.library.configuration.netapp_lun_ostype = 'linux' self.library.configuration.netapp_host_type = 'future_os' self.library.do_setup(mock.Mock()) + self.assertRaises(exception.NetAppDriverException, self.library.check_for_setup_error) + msg = _("Invalid value for NetApp configuration" " option netapp_host_type.") block_base.LOG.error.assert_called_once_with(msg) @@ -998,3 +1022,28 @@ class NetAppBlockStorageLibraryTestCase(test.TestCase): self.assertFalse(mock_get_lun_geometry.called) self.assertFalse(mock_do_direct_resize.called) self.assertFalse(mock_do_sub_clone_resize.called) + + def test_configure_chap_generate_username_and_password(self): + """Ensure that a CHAP username and password are generated.""" + initiator_name = fake.ISCSI_CONNECTOR['initiator'] + + username, password = self.library._configure_chap(initiator_name) + + self.assertEqual(na_utils.DEFAULT_CHAP_USER_NAME, username) + self.assertIsNotNone(password) + self.assertEqual(len(password), na_utils.CHAP_SECRET_LENGTH) + + def test_add_chap_properties(self): + """Ensure that CHAP properties are added to the properties dictionary + + """ + properties = {'data': {}} + self.library._add_chap_properties(properties, 'user1', 'pass1') + + data = properties['data'] + self.assertEqual('CHAP', data['auth_method']) + self.assertEqual('user1', data['auth_username']) + self.assertEqual('pass1', data['auth_password']) + self.assertEqual('CHAP', data['discovery_auth_method']) + self.assertEqual('user1', data['discovery_auth_username']) + self.assertEqual('pass1', data['discovery_auth_password']) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py index 3cf0855cb..a4c79094c 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py @@ -81,6 +81,7 @@ class NetAppBlockStorageCmodeLibraryTestCase(test.TestCase): @mock.patch.object(na_utils, 'check_flags') @mock.patch.object(block_base.NetAppBlockStorageLibrary, 'do_setup') def test_do_setup(self, super_do_setup, mock_check_flags): + self.mock_object(client_base.Client, '_init_ssh_client') context = mock.Mock() self.library.do_setup(context) diff --git a/cinder/volume/drivers/netapp/dataontap/block_base.py b/cinder/volume/drivers/netapp/dataontap/block_base.py index cf85ddc79..d28705c00 100644 --- a/cinder/volume/drivers/netapp/dataontap/block_base.py +++ b/cinder/volume/drivers/netapp/dataontap/block_base.py @@ -6,6 +6,8 @@ # Copyright (c) 2014 Andrew Kerr. All rights reserved. # Copyright (c) 2014 Jeff Applewhite. All rights reserved. # Copyright (c) 2015 Tom Barron. All rights reserved. +# Copyright (c) 2015 Chuck Fouts. All rights reserved. +# Copyright (c) 2015 Dustin Schoenbrun. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -42,7 +44,6 @@ from cinder.volume.drivers.netapp import utils as na_utils from cinder.volume import utils as volume_utils from cinder.zonemanager import utils as fczm_utils - LOG = logging.getLogger(__name__) @@ -778,8 +779,32 @@ class NetAppBlockStorageLibrary(object): properties = na_utils.get_iscsi_connection_properties(lun_id, volume, iqn, address, port) + + if self.configuration.use_chap_auth: + chap_username, chap_password = self._configure_chap(initiator_name) + self._add_chap_properties(properties, chap_username, chap_password) + return properties + def _configure_chap(self, initiator_name): + password = volume_utils.generate_password(na_utils.CHAP_SECRET_LENGTH) + username = na_utils.DEFAULT_CHAP_USER_NAME + + self.zapi_client.set_iscsi_chap_authentication(initiator_name, + username, + password) + LOG.debug("Set iSCSI CHAP authentication.") + + return username, password + + def _add_chap_properties(self, properties, username, password): + properties['data']['auth_method'] = 'CHAP' + properties['data']['auth_username'] = username + properties['data']['auth_password'] = password + properties['data']['discovery_auth_method'] = 'CHAP' + properties['data']['discovery_auth_username'] = username + properties['data']['discovery_auth_password'] = password + def _get_preferred_target_from_list(self, target_details_list, filter=None): preferred_target = None diff --git a/cinder/volume/drivers/netapp/dataontap/client/api.py b/cinder/volume/drivers/netapp/dataontap/client/api.py index e8847bda4..2b22a14bb 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/api.py +++ b/cinder/volume/drivers/netapp/dataontap/client/api.py @@ -22,14 +22,18 @@ Contains classes required to issue API calls to Data ONTAP and OnCommand DFM. """ import copy +from eventlet import greenthread +from eventlet import semaphore from lxml import etree from oslo_log import log as logging +import random import six from six.moves import urllib from cinder import exception from cinder.i18n import _ +from cinder import ssh_utils from cinder import utils LOG = logging.getLogger(__name__) @@ -393,7 +397,7 @@ class NaElement(object): return attributes.keys() def add_new_child(self, name, content, convert=False): - """Add child with tag name and context. + """Add child with tag name and content. Convert replaces entity refs to chars. """ @@ -434,6 +438,12 @@ class NaElement(object): xml = xml.decode('utf-8') return xml + def __eq__(self, other): + return str(self) == str(other) + + def __hash__(self): + return hash(str(self)) + def __repr__(self): return str(self) @@ -617,3 +627,84 @@ def create_api_request(api_name, query=None, des_result=None, if tag: api_el.add_new_child('tag', tag, True) return api_el + + +class SSHUtil(object): + """Encapsulates connection logic and command execution for SSH client.""" + + MAX_CONCURRENT_SSH_CONNECTIONS = 5 + RECV_TIMEOUT = 3 + CONNECTION_KEEP_ALIVE = 600 + WAIT_ON_STDOUT_TIMEOUT = 3 + + def __init__(self, host, username, password, port=22): + self.ssh_pool = self._init_ssh_pool(host, port, username, password) + + # Note(cfouts) Number of SSH connections made to the backend need to be + # limited. Use of SSHPool allows connections to be cached and reused + # instead of creating a new connection each time a command is executed + # via SSH. + self.ssh_connect_semaphore = semaphore.Semaphore( + self.MAX_CONCURRENT_SSH_CONNECTIONS) + + def _init_ssh_pool(self, host, port, username, password): + return ssh_utils.SSHPool(host, + port, + self.CONNECTION_KEEP_ALIVE, + username, + password) + + def execute_command(self, client, command_text, timeout=RECV_TIMEOUT): + LOG.debug("execute_command() - Sending command.") + stdin, stdout, stderr = client.exec_command(command_text) + stdin.close() + self._wait_on_stdout(stdout, timeout) + output = stdout.read() + LOG.debug("Output of length %(size)d received.", + {'size': len(output)}) + stdout.close() + stderr.close() + return output + + def execute_command_with_prompt(self, + client, + command, + expected_prompt_text, + prompt_response, + timeout=RECV_TIMEOUT): + LOG.debug("execute_command_with_prompt() - Sending command.") + stdin, stdout, stderr = client.exec_command(command) + self._wait_on_stdout(stdout, timeout) + response = stdout.channel.recv(999) + if response.strip() != expected_prompt_text: + msg = _("Unexpected output. Expected [%(expected)s] but " + "received [%(output)s]") % { + 'expected': expected_prompt_text, + 'output': response.strip(), + } + LOG.error(msg) + stdin.close() + stdout.close() + stderr.close() + raise exception.VolumeBackendAPIException(msg) + else: + LOG.debug("execute_command_with_prompt() - Sending answer") + stdin.write(prompt_response + '\n') + stdin.flush() + stdin.close() + stdout.close() + stderr.close() + + def _wait_on_stdout(self, stdout, timeout=WAIT_ON_STDOUT_TIMEOUT): + wait_time = 0.0 + # NOTE(cfouts): The server does not always indicate when EOF is reached + # for stdout. The timeout exists for this reason and an attempt is made + # to read from stdout. + while not stdout.channel.exit_status_ready(): + # period is 10 - 25 centiseconds + period = random.randint(10, 25) / 100.0 + greenthread.sleep(period) + wait_time += period + if wait_time > timeout: + LOG.debug("Timeout exceeded while waiting for exit status.") + break diff --git a/cinder/volume/drivers/netapp/dataontap/client/client_7mode.py b/cinder/volume/drivers/netapp/dataontap/client/client_7mode.py index f57c093ad..7e099bbc2 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/client_7mode.py +++ b/cinder/volume/drivers/netapp/dataontap/client/client_7mode.py @@ -109,6 +109,18 @@ class Client(client_base.Client): tgt_list.append(d) return tgt_list + def check_iscsi_initiator_exists(self, iqn): + """Returns True if initiator exists.""" + initiator_exists = True + try: + auth_list = netapp_api.NaElement('iscsi-initiator-auth-list-info') + auth_list.add_new_child('initiator', iqn) + self.connection.invoke_successfully(auth_list, True) + except netapp_api.NaApiError: + initiator_exists = False + + return initiator_exists + def get_fc_target_wwpns(self): """Gets the FC target details.""" wwpns = [] @@ -127,6 +139,31 @@ class Client(client_base.Client): result = self.connection.invoke_successfully(iscsi_service_iter, True) return result.get_child_content('node-name') + def set_iscsi_chap_authentication(self, iqn, username, password): + """Provides NetApp host's CHAP credentials to the backend.""" + + command = ("iscsi security add -i %(iqn)s -s CHAP " + "-p %(password)s -n %(username)s") % { + 'iqn': iqn, + 'password': password, + 'username': username, + } + + LOG.debug('Updating CHAP authentication for %(iqn)s.', {'iqn': iqn}) + + try: + ssh_pool = self.ssh_client.ssh_pool + with ssh_pool.item() as ssh: + self.ssh_client.execute_command(ssh, command) + except Exception as e: + msg = _('Failed to set CHAP authentication for target IQN ' + '%(iqn)s. Details: %(ex)s') % { + 'iqn': iqn, + 'ex': e, + } + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + def get_lun_list(self): """Gets the list of LUNs on filer.""" lun_list = [] diff --git a/cinder/volume/drivers/netapp/dataontap/client/client_base.py b/cinder/volume/drivers/netapp/dataontap/client/client_base.py index eaead9930..265aa3bf3 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/client_base.py +++ b/cinder/volume/drivers/netapp/dataontap/client/client_base.py @@ -38,12 +38,23 @@ LOG = logging.getLogger(__name__) class Client(object): def __init__(self, **kwargs): + host = kwargs['hostname'] + username = kwargs['username'] + password = kwargs['password'] self.connection = netapp_api.NaServer( - host=kwargs['hostname'], + host=host, transport_type=kwargs['transport_type'], port=kwargs['port'], - username=kwargs['username'], - password=kwargs['password']) + username=username, + password=password) + + self.ssh_client = self._init_ssh_client(host, username, password) + + def _init_ssh_client(self, host, username, password): + return netapp_api.SSHUtil( + host=host, + username=username, + password=password) def _init_features(self): """Set up the repository of available Data ONTAP features.""" @@ -231,6 +242,14 @@ class Client(object): """Returns iscsi iqn.""" raise NotImplementedError() + def check_iscsi_initiator_exists(self, iqn): + """Returns True if initiator exists.""" + raise NotImplementedError() + + def set_iscsi_chap_authentication(self, iqn, username, password): + """Provides NetApp host's CHAP credentials to the backend.""" + raise NotImplementedError() + def get_lun_list(self): """Gets the list of LUNs on filer.""" raise NotImplementedError() diff --git a/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py b/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py index 2b1b41d2b..db177cdc5 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py +++ b/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py @@ -91,6 +91,62 @@ class Client(client_base.Client): tgt_list.append(d) return tgt_list + def set_iscsi_chap_authentication(self, iqn, username, password): + """Provides NetApp host's CHAP credentials to the backend.""" + initiator_exists = self.check_iscsi_initiator_exists(iqn) + + command_template = ('iscsi security %(mode)s -vserver %(vserver)s ' + '-initiator-name %(iqn)s -auth-type CHAP ' + '-user-name %(username)s') + + if initiator_exists: + LOG.debug('Updating CHAP authentication for %(iqn)s.', + {'iqn': iqn}) + command = command_template % { + 'mode': 'modify', + 'vserver': self.vserver, + 'iqn': iqn, + 'username': username, + } + else: + LOG.debug('Adding initiator %(iqn)s with CHAP authentication.', + {'iqn': iqn}) + command = command_template % { + 'mode': 'create', + 'vserver': self.vserver, + 'iqn': iqn, + 'username': username, + } + + try: + with self.ssh_client.ssh_connect_semaphore: + ssh_pool = self.ssh_client.ssh_pool + with ssh_pool.item() as ssh: + self.ssh_client.execute_command_with_prompt(ssh, + command, + 'Password:', + password) + except Exception as e: + msg = _('Failed to set CHAP authentication for target IQN %(iqn)s.' + ' Details: %(ex)s') % { + 'iqn': iqn, + 'ex': e, + } + LOG.error(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def check_iscsi_initiator_exists(self, iqn): + """Returns True if initiator exists.""" + initiator_exists = True + try: + auth_list = netapp_api.NaElement('iscsi-initiator-get-auth') + auth_list.add_new_child('initiator', iqn) + self.connection.invoke_successfully(auth_list, True) + except netapp_api.NaApiError: + initiator_exists = False + + return initiator_exists + def get_fc_target_wwpns(self): """Gets the FC target details.""" wwpns = [] diff --git a/cinder/volume/drivers/netapp/utils.py b/cinder/volume/drivers/netapp/utils.py index 8c6f36d40..88697e342 100644 --- a/cinder/volume/drivers/netapp/utils.py +++ b/cinder/volume/drivers/netapp/utils.py @@ -53,6 +53,10 @@ DEPRECATED_SSC_SPECS = {'netapp_unmirrored': 'netapp_mirrored', QOS_KEYS = frozenset(['maxIOPS', 'maxIOPSperGiB', 'maxBPS', 'maxBPSperGiB']) BACKEND_QOS_CONSUMERS = frozenset(['back-end', 'both']) +# Secret length cannot be less than 96 bits. http://tools.ietf.org/html/rfc3723 +CHAP_SECRET_LENGTH = 16 +DEFAULT_CHAP_USER_NAME = 'NetApp_iSCSI_CHAP_Username' + def validate_instantiation(**kwargs): """Checks if a driver is instantiated other than by the unified driver. diff --git a/releasenotes/notes/netapp-chap-iscsi-auth-264cd942b2a76094.yaml b/releasenotes/notes/netapp-chap-iscsi-auth-264cd942b2a76094.yaml new file mode 100644 index 000000000..f1026b0ec --- /dev/null +++ b/releasenotes/notes/netapp-chap-iscsi-auth-264cd942b2a76094.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added iSCSI CHAP uni-directional authentication for NetApp drivers.