From c2aae1f4022e2d845ceb4a71079afc255133687e Mon Sep 17 00:00:00 2001 From: Ganesh Murthy Date: Mon, 6 Jun 2016 15:50:46 -0400 Subject: [PATCH] DISPATCH-233 - Added ability to specifly sasl-mechanisms, sasl-username and sasl-password when using qdstat/qdmanage --- python/qpid_dispatch/management/client.py | 14 ++- .../qpid_dispatch_internal/tools/command.py | 51 ++++++++++- tests/ssl_certs/client-password-file.txt | 1 + tests/system_tests_qdmanage.py | 54 ++++++++++- tests/system_tests_qdstat.py | 33 ++++++- tests/system_tests_sasl_plain.py | 89 ++++++++++++++++++- tools/qdmanage | 11 ++- tools/qdstat | 9 +- 8 files changed, 241 insertions(+), 21 deletions(-) create mode 100644 tests/ssl_certs/client-password-file.txt diff --git a/python/qpid_dispatch/management/client.py b/python/qpid_dispatch/management/client.py index 6b42436ebd..24f723a1fd 100644 --- a/python/qpid_dispatch/management/client.py +++ b/python/qpid_dispatch/management/client.py @@ -76,7 +76,7 @@ class Node(object): """Client proxy for an AMQP management node""" @staticmethod - def connection(url=None, router=None, timeout=10, ssl_domain=None): + def connection(url=None, router=None, timeout=10, ssl_domain=None, sasl=None): """Return a BlockingConnection suitable for connecting to a management node @param url: URL of the management node. @param router: If address does not contain a path, use the management node for this router ID. @@ -90,12 +90,18 @@ def connection(url=None, router=None, timeout=10, ssl_domain=None): else: url.path = u'$management' - return BlockingConnection(url, timeout=timeout, ssl_domain=ssl_domain) + # if sasl_mechanism is unicode, convert it to python string + return BlockingConnection(url, + timeout=timeout, + ssl_domain=ssl_domain, + allowed_mechs=str(sasl.mechs) if sasl else None, + user=str(sasl.user) if sasl else None, + password=str(sasl.password) if sasl else None) @staticmethod - def connect(url=None, router=None, timeout=10, ssl_domain=None): + def connect(url=None, router=None, timeout=10, ssl_domain=None, sasl=None): """Return a Node connected with the given parameters, see L{connection}""" - return Node(Node.connection(url, router, timeout, ssl_domain)) + return Node(Node.connection(url, router, timeout, ssl_domain, sasl)) def __init__(self, connection, locales=None): """ diff --git a/python/qpid_dispatch_internal/tools/command.py b/python/qpid_dispatch_internal/tools/command.py index 7b941ef30d..bd43344596 100644 --- a/python/qpid_dispatch_internal/tools/command.py +++ b/python/qpid_dispatch_internal/tools/command.py @@ -87,8 +87,41 @@ def connection_options(options, title="Connection Options"): help="Trusted Certificate Authority Database file (PEM Format)") group.add_option("--ssl-password", action="store", type="string", metavar="PASSWORD", help="Certificate password, will be prompted if not specifed.") + # Use the --ssl-password-file option to avoid having the --ssl-password in history or scripts. + group.add_option("--ssl-password-file", action="store", type="string", metavar="SSL-PASSWORD-FILE", + help="Certificate password, will be prompted if not specifed.") + + group.add_option("--sasl-mechanisms", action="store", type="string", metavar="SASL-MECHANISMS", + help="Allowed sasl mechanisms to be supplied during the sasl handshake.") + group.add_option("--sasl-username", action="store", type="string", metavar="SASL-USERNAME", + help="User name for SASL plain authentication") + group.add_option("--sasl-password", action="store", type="string", metavar="SASL-PASSWORD", + help="Password for SASL plain authentication") + # Use the --sasl-password-file option to avoid having the --sasl-password in history or scripts. + group.add_option("--sasl-password-file", action="store", type="string", metavar="SASL-PASSWORD-FILE", + help="Password for SASL plain authentication") + return group + +def get_password(file=None): + if file: + with open(file, 'r') as password_file: + return str(password_file.read()).strip() # Remove leading and trailing characters + return None + +class Sasl(object): + """ + A simple object to hold sasl mechanisms, sasl username and password + """ + def __init__(self, mechs=None, user=None, password=None, sasl_password_file=None): + self.mechs = mechs + self.user = user + self.password = password + self.sasl_password_file = sasl_password_file + if self.sasl_password_file: + self.password = get_password(self.sasl_password_file) + def opts_url(opts): """Fix up default URL settings based on options""" url = Url(opts.bus) @@ -99,12 +132,26 @@ def opts_url(opts): return url +def opts_sasl(opts): + mechs, user, password, sasl_password_file = opts.sasl_mechanisms, opts.sasl_username, opts.sasl_password, opts.sasl_password_file + if not (mechs or user or password or sasl_password_file): + return None + + return Sasl(mechs, user, password, sasl_password_file) + def opts_ssl_domain(opts, mode=SSLDomain.MODE_CLIENT): """Return proton.SSLDomain from command line options or None if no SSL options specified. @param opts: Parsed optoins including connection_options() """ - certificate, key, trustfile, password = opts.ssl_certificate, opts.ssl_key, opts.ssl_trustfile, opts.ssl_password - if not (certificate or trustfile): return None + certificate, key, trustfile, password, password_file = opts.ssl_certificate, opts.ssl_key, opts.ssl_trustfile, \ + opts.ssl_password, opts.ssl_password_file + + if not (certificate or trustfile): + return None + + if password_file: + password = get_password(password_file) + domain = SSLDomain(mode) if trustfile: domain.set_trusted_ca_db(str(trustfile)) diff --git a/tests/ssl_certs/client-password-file.txt b/tests/ssl_certs/client-password-file.txt new file mode 100644 index 0000000000..d437001683 --- /dev/null +++ b/tests/ssl_certs/client-password-file.txt @@ -0,0 +1 @@ +client-password diff --git a/tests/system_tests_qdmanage.py b/tests/system_tests_qdmanage.py index b8837b3edf..6c28dae0a5 100644 --- a/tests/system_tests_qdmanage.py +++ b/tests/system_tests_qdmanage.py @@ -218,7 +218,8 @@ def test_create_delete_connector(self): created = False for result in results: name = result['name'] - if 'connection/0.0.0.0:%s:' % QdmanageTest.inter_router_port in name: + conn_name = 'connection/0.0.0.0:%s:' % QdmanageTest.inter_router_port + if conn_name in name: created = True self.assertTrue(created) @@ -310,5 +311,56 @@ def test_zzz_create_delete_listener(self): self.assertTrue(exception_occurred) +class QdmanageTestSsl(QdmanageTest): + + @classmethod + def setUpClass(cls): + super(QdmanageTestSsl, cls).setUpClass() + + def address(self): + return self.router_1.addresses[1] + + def run_qdmanage(self, cmd, input=None, expect=Process.EXIT_OK, address=None): + p = self.popen( + ['qdmanage'] + cmd.split(' ') + ['--bus', address or self.address(), + '--indent=-1', + '--ssl-trustfile=' + self.ssl_file('ca-certificate.pem'), + '--ssl-certificate=' + self.ssl_file('client-certificate.pem'), + '--ssl-key=' + self.ssl_file('client-private-key.pem'), + '--ssl-password=client-password', + '--timeout', str(TIMEOUT)], + stdin=PIPE, stdout=PIPE, stderr=STDOUT, expect=expect) + out = p.communicate(input)[0] + try: + p.teardown() + except Exception, e: + raise Exception("%s\n%s" % (e, out)) + return out + + def test_create_delete_connector(self): + long_type = 'org.apache.qpid.dispatch.connector' + query_command = 'QUERY --type=' + long_type + output = json.loads(self.run_qdmanage(query_command)) + name = output[0]['name'] + + # Delete an existing connector + delete_command = 'DELETE --type=' + long_type + ' --name=' + name + self.run_qdmanage(delete_command) + output = json.loads(self.run_qdmanage(query_command)) + self.assertEqual(output, []) + + # Re-create the connector and then try wait_connectors + self.create(long_type, name, str(QdmanageTestSsl.inter_router_port)) + + results = json.loads(self.run_qdmanage('QUERY --type=org.apache.qpid.dispatch.connection')) + + created = False + for result in results: + name = result['name'] + conn_name = 'connection/0.0.0.0:%s:' % QdmanageTestSsl.inter_router_port + if conn_name in name: + created = True + self.assertTrue(created) + if __name__ == '__main__': unittest.main(main_module()) diff --git a/tests/system_tests_qdstat.py b/tests/system_tests_qdstat.py index 057aeb0088..97bc17d400 100644 --- a/tests/system_tests_qdstat.py +++ b/tests/system_tests_qdstat.py @@ -122,10 +122,7 @@ def run_qdstat(self, args, regexp=None, address=None): if regexp: assert re.search(regexp, out, re.I), "Can't find '%s' in '%s'" % (regexp, out) return out - def ssl_test(self, url_name, arg_names): - """Run simple SSL connection test with supplied parameters. - See test_ssl_* below. - """ + def get_ssl_args(self): args = dict( trustfile = ['--ssl-trustfile', self.ssl_file('ca-certificate.pem')], bad_trustfile = ['--ssl-trustfile', self.ssl_file('bad-ca-certificate.pem')], @@ -134,6 +131,13 @@ def ssl_test(self, url_name, arg_names): client_pass = ['--ssl-password', 'client-password']) args['client_cert_all'] = args['client_cert'] + args['client_key'] + args['client_pass'] + return args + + def ssl_test(self, url_name, arg_names): + """Run simple SSL connection test with supplied parameters. + See test_ssl_* below. + """ + args = self.get_ssl_args() addrs = [self.router.addresses[i] for i in xrange(4)]; urls = dict(zip(['none', 'strict', 'unsecured', 'auth'], addrs) + zip(['none_s', 'strict_s', 'unsecured_s', 'auth_s'], @@ -220,6 +224,27 @@ class QdstatSslTest(system_test.TestCase): def test_skip(self): self.skipTest("Proton SSL support unavailable.") +try: + SSLDomain(SSLDomain.MODE_CLIENT) + class QdstatSslTestSslPasswordFile(QdstatSslTest): + """ + Tests the --ssl-password-file command line parameter + """ + def get_ssl_args(self): + args = dict( + trustfile = ['--ssl-trustfile', self.ssl_file('ca-certificate.pem')], + bad_trustfile = ['--ssl-trustfile', self.ssl_file('bad-ca-certificate.pem')], + client_cert = ['--ssl-certificate', self.ssl_file('client-certificate.pem')], + client_key = ['--ssl-key', self.ssl_file('client-private-key.pem')], + client_pass = ['--ssl-password-file', self.ssl_file('client-password-file.txt')]) + args['client_cert_all'] = args['client_cert'] + args['client_key'] + args['client_pass'] + + return args + +except SSLUnavailable: + class QdstatSslTest(system_test.TestCase): + def test_skip(self): + self.skipTest("Proton SSL support unavailable.") try: SSLDomain(SSLDomain.MODE_CLIENT) diff --git a/tests/system_tests_sasl_plain.py b/tests/system_tests_sasl_plain.py index bfde8cc6dd..ff04313da1 100644 --- a/tests/system_tests_sasl_plain.py +++ b/tests/system_tests_sasl_plain.py @@ -86,6 +86,8 @@ def setUpClass(cls): # This unauthenticated listener is for qdstat to connect to it. ('listener', {'host': '0.0.0.0', 'role': 'normal', 'port': cls.tester.get_port(), 'authenticatePeer': 'no'}), + ('listener', {'host': '0.0.0.0', 'role': 'normal', 'port': cls.tester.get_port(), + 'saslMechanisms':'PLAIN', 'authenticatePeer': 'yes'}), ('router', {'workerThreads': 1, 'id': 'QDR.X', 'mode': 'interior', @@ -126,6 +128,55 @@ def test_inter_router_plain_exists(self): self.assertIn("inter-router", out) self.assertIn("test@domain.com(PLAIN)", out) + def test_qdstat_connect_sasl(self): + """ + Make qdstat use sasl plain authentication. + """ + p = self.popen( + ['qdstat', '-b', str(self.routers[0].addresses[2]), '-c', '--sasl-mechanisms=PLAIN', + '--sasl-username=test@domain.com', '--sasl-password=password'], + name='qdstat-'+self.id(), stdout=PIPE, expect=None) + + out = p.communicate()[0] + assert p.returncode == 0, \ + "qdstat exit status %s, output:\n%s" % (p.returncode, out) + + split_list = out.split() + + # There will be 2 connections that have authenticated using SASL PLAIN. One inter-router connection + # and the other connection that this qdstat client is making + self.assertEqual(2, split_list.count("test@domain.com(PLAIN)")) + self.assertEqual(1, split_list.count("inter-router")) + self.assertEqual(1, split_list.count("normal")) + + def test_qdstat_connect_sasl_password_file(self): + """ + Make qdstat use sasl plain authentication with client password specified in a file. + """ + password_file = os.getcwd() + '/sasl-client-password-file.txt' + # Create a SASL configuration file. + with open(password_file, 'w') as sasl_client_password_file: + sasl_client_password_file.write("password") + + sasl_client_password_file.close() + + p = self.popen( + ['qdstat', '-b', str(self.routers[0].addresses[2]), '-c', '--sasl-mechanisms=PLAIN', + '--sasl-username=test@domain.com', '--sasl-password-file=' + password_file], + name='qdstat-'+self.id(), stdout=PIPE, expect=None) + + out = p.communicate()[0] + assert p.returncode == 0, \ + "qdstat exit status %s, output:\n%s" % (p.returncode, out) + + split_list = out.split() + + # There will be 2 connections that have authenticated using SASL PLAIN. One inter-router connection + # and the other connection that this qdstat client is making + self.assertEqual(2, split_list.count("test@domain.com(PLAIN)")) + self.assertEqual(1, split_list.count("inter-router")) + self.assertEqual(1, split_list.count("normal")) + class RouterTestPlainSaslOverSsl(RouterTestPlainSaslCommon): @@ -156,9 +207,11 @@ def setUpClass(cls): ('listener', {'host': '0.0.0.0', 'role': 'inter-router', 'port': x_listener_port, 'sslProfile':'server-ssl-profile', 'saslMechanisms':'PLAIN', 'authenticatePeer': 'yes'}), - # This unauthenticated listener is for qdstat to connect to it. ('listener', {'host': '0.0.0.0', 'role': 'normal', 'port': cls.tester.get_port(), 'authenticatePeer': 'no'}), + ('listener', {'host': '0.0.0.0', 'role': 'normal', 'port': cls.tester.get_port(), + 'sslProfile':'server-ssl-profile', + 'saslMechanisms':'PLAIN', 'authenticatePeer': 'yes'}), ('sslProfile', {'name': 'server-ssl-profile', 'cert-db': cls.ssl_file('ca-certificate.pem'), 'cert-file': cls.ssl_file('server-certificate.pem'), @@ -194,6 +247,35 @@ def setUpClass(cls): cls.routers[1].wait_router_connected('QDR.X') + def test_aaa_qdstat_connect_sasl_over_ssl(self): + """ + Make qdstat use sasl plain authentication over ssl. + """ + p = self.popen( + ['qdstat', '-b', str(self.routers[0].addresses[2]), '-c', + # The following are SASL args + '--sasl-mechanisms=PLAIN', + '--sasl-username=test@domain.com', + '--sasl-password=password', + # The following are SSL args + '--ssl-trustfile=' + self.ssl_file('ca-certificate.pem'), + '--ssl-certificate=' + self.ssl_file('client-certificate.pem'), + '--ssl-key=' + self.ssl_file('client-private-key.pem'), + '--ssl-password=client-password'], + name='qdstat-'+self.id(), stdout=PIPE, expect=None) + + out = p.communicate()[0] + assert p.returncode == 0, \ + "qdstat exit status %s, output:\n%s" % (p.returncode, out) + + split_list = out.split() + + # There will be 2 connections that have authenticated using SASL PLAIN. One inter-router connection + # and the other connection that this qdstat client is making + self.assertEqual(2, split_list.count("test@domain.com(PLAIN)")) + self.assertEqual(1, split_list.count("inter-router")) + self.assertEqual(1, split_list.count("normal")) + def test_inter_router_plain_over_ssl_exists(self): """The setUpClass sets up two routers with SASL PLAIN enabled over TLS/SSLv3. @@ -219,8 +301,8 @@ def test_inter_router_plain_over_ssl_exists(self): # user must be test@domain.com self.assertEqual(u'test@domain.com', local_node.query(type='org.apache.qpid.dispatch.connection').results[0][16]) -class RouterTestVerifyHostNameYes(RouterTestPlainSaslCommon): +class RouterTestVerifyHostNameYes(RouterTestPlainSaslCommon): @staticmethod def ssl_file(name): return os.path.join(DIR, 'ssl_certs', name) @@ -266,7 +348,8 @@ def setUpClass(cls): 'ssl-profile': 'client-ssl-profile', 'verifyHostName': 'yes', 'saslMechanisms': 'PLAIN', - 'saslUsername': 'test@domain.com', 'saslPassword': 'password'}), + 'saslUsername': 'test@domain.com', + 'saslPassword': 'password'}), ('router', {'workerThreads': 1, 'mode': 'interior', 'routerId': 'QDR.Y'}), diff --git a/tools/qdmanage b/tools/qdmanage index 8572411185..a534eb6944 100755 --- a/tools/qdmanage +++ b/tools/qdmanage @@ -24,7 +24,8 @@ import qpid_dispatch_site from qpid_dispatch.management.client import Node, Url from collections import Mapping, Sequence from optparse import OptionGroup -from qpid_dispatch_internal.tools.command import OptionParser, Option, UsageError, connection_options, check_args, main, opts_ssl_domain, opts_url +from qpid_dispatch_internal.tools.command import OptionParser, Option, UsageError, connection_options, check_args, \ + main, opts_ssl_domain, opts_url, opts_sasl def attr_split(attrstr): """Split an attribute string of the form name=value or name to indicate None""" @@ -70,9 +71,11 @@ class QdManage(): self.clean_opts() if self.opts.indent == -1: self.opts.indent = None - if len(self.args) == 0: raise UsageError("No operation specified") - self.node = Node.connect( - opts_url(self.opts), self.opts.router, self.opts.timeout, opts_ssl_domain(self.opts)) + if len(self.args) == 0: + raise UsageError("No operation specified") + self.node = Node.connect(opts_url(self.opts), self.opts.router, self.opts.timeout, + opts_ssl_domain(self.opts), + opts_sasl(self.opts)) operation = self.args.pop(0) method = operation.lower().replace('-','_') if operation.upper() in self.operations and hasattr(self, method): diff --git a/tools/qdstat b/tools/qdstat index 7aaad99107..5b0085bb2c 100755 --- a/tools/qdstat +++ b/tools/qdstat @@ -30,7 +30,8 @@ import qpid_dispatch_site from qpid_dispatch.management.client import Url, Node, Entity from qpid_dispatch_internal.management.qdrouter import QdSchema from qpid_dispatch_internal.tools import Display, Header, Sorter, YN, Commas, TimeLong -from qpid_dispatch_internal.tools.command import connection_options, main, OptionParser, opts_ssl_domain, opts_url +from qpid_dispatch_internal.tools.command import connection_options, main, OptionParser, opts_ssl_domain, opts_sasl, \ + opts_url def parse_args(argv): """ Set global variables for options, return arguments """ @@ -68,8 +69,10 @@ class BusManager(Node): def __init__(self, opts): self.opts = opts super(BusManager, self).__init__( - Node.connection(opts_url(opts), opts.router, timeout=opts.timeout, - ssl_domain=opts_ssl_domain(opts))) + Node.connection(opts_url(opts), opts.router, + timeout=opts.timeout, + ssl_domain=opts_ssl_domain(opts), + sasl=opts_sasl(self.opts))) def query(self, entity_type): return super(BusManager, self).query(entity_type).get_entities()