]> review.fuel-infra Code Review - openstack-build/cinder-build.git/commitdiff
NetApp: Support iSCSI CHAP Uni-directional Auth
authorChuck Fouts <fchuck@netapp.com>
Thu, 4 Jun 2015 14:28:23 +0000 (10:28 -0400)
committerChuck Fouts <fchuck@netapp.com>
Wed, 24 Feb 2016 16:29:16 +0000 (16:29 +0000)
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 <dustin.schoenbrun@netapp.com>
Co-Authored-By: Alex Meade <mr.alex.meade@gmail.com>
Implements: blueprint netapp-add-chap-authentication-iscsi

DocImpact
Change-Id: I8c481fa09aee02b5472f02819b1a342a3c3e7f71

17 files changed:
cinder/tests/unit/test_netapp.py
cinder/tests/unit/volume/drivers/netapp/dataontap/client/fakes.py
cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_api.py
cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_7mode.py
cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_base.py
cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py
cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py
cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_7mode.py
cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py
cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py
cinder/volume/drivers/netapp/dataontap/block_base.py
cinder/volume/drivers/netapp/dataontap/client/api.py
cinder/volume/drivers/netapp/dataontap/client/client_7mode.py
cinder/volume/drivers/netapp/dataontap/client/client_base.py
cinder/volume/drivers/netapp/dataontap/client/client_cmode.py
cinder/volume/drivers/netapp/utils.py
releasenotes/notes/netapp-chap-iscsi-auth-264cd942b2a76094.yaml [new file with mode: 0644]

index bea686a0d4958124e798bf28adf651896dc2536f..bfaca9e6c486f09c7786437c678ff92c7282fff6 100644 (file)
@@ -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):
index b21858fd76447b8f8c11fde2b983067aebaef331..5dcf5bd35ac6237bbed893ffb76cf54d531e3a3a 100644 (file)
@@ -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("""
   <results status="passed">
@@ -676,3 +680,12 @@ SYSTEM_GET_INFO_RESPONSE = etree.XML("""
     </system-info>
   </results>
 """ % {'node': NODE_NAME})
+
+ISCSI_INITIATOR_GET_AUTH_ELEM = etree.XML("""
+<iscsi-initiator-get-auth>
+  <initiator>%s</initiator>
+</iscsi-initiator-get-auth>""" % INITIATOR_IQN)
+
+ISCSI_INITIATOR_AUTH_LIST_INFO_FAILURE = etree.XML("""
+<results status="failed" errno="13112" reason="Initiator %s not found,
+ please use default authentication." />""" % INITIATOR_IQN)
index f577c9cea10bfdb7472f78fbc854f18826ed24ab..783d148c006f0d03eb642c1358d98389b91dae3f 100644 (file)
@@ -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
index b111033d50faabd5327ca34848c9f7193637596f..2b072cc8feebe507a2bb6746f6b6d0c4bdd92195 100644 (file)
@@ -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)]
+        )
index 7c08df3f4d45a6ea373f47d637c9b1b4d229f5d8..b492828cb342d6d3c2390a34652f5b807a8f3e62 100644 (file)
@@ -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())
index 7dd075fcdf14ff373dc65c3da30c50ae73038eff..13f2959722b367d45ec346db9e31b865e2e02930 100644 (file)
@@ -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)
index 64d970d659f20fed73e6be5f9642ff77eab69b2e..4df62ad338e414ced3d1cb3cc6799c36217d6e9e 100644 (file)
@@ -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,
index 7f1bde789ddfe72e6086dd1ed6cd4ee56ed9fdcd..60668a699ac88339d2372522cb1a3eada6391c07 100644 (file)
@@ -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'))
index 4ff3c4a3d5fbb79f4cc8cf8117e7f8a6b69f4e38..17e3eab888fd1e721611a18d257a0432322591bc 100644 (file)
@@ -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'])
index 3cf0855cb13112f8550d58019b071d6357ff3d23..a4c79094c2906aebc6a2ce8021b61d43ce14f064 100644 (file)
@@ -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)
index cf85ddc79ff98a192e9e0ab8cac863b225d3f489..d28705c00f9f3b2927e2ffa21b3dc62c6aec4179 100644 (file)
@@ -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
index e8847bda4650b9307b35c8597f486cdbb84b9967..2b22a14bbe09d6e8578c90681954f56eed54493e 100644 (file)
@@ -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
index f57c093ad4a6b59fedd64c06c9516431161ae204..7e099bbc270399f428abff3a6332a2fe49729d4a 100644 (file)
@@ -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 = []
index eaead99300f9d6a686e2ffcb388205cd55a18ac9..265aa3bf3e9566ae21a2872dc711d2f939d6aeec 100644 (file)
@@ -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()
index 2b1b41d2bc926504b21efe37164d0b207f6c1de1..db177cdc59e296c0548dfa6d782f01b369f9cc2c 100644 (file)
@@ -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 = []
index 8c6f36d404507a3edf4db6f7826cd737dbd8bdc0..88697e342248f615c079f60b536da2bda2784c90 100644 (file)
@@ -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 (file)
index 0000000..f1026b0
--- /dev/null
@@ -0,0 +1,3 @@
+---
+features:
+  - Added iSCSI CHAP uni-directional authentication for NetApp drivers.