From fda51c21d6f5e50c7b67e5262761c0da9e2f2681 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Feb 14 2017 23:40:00 +0000 Subject: [PATCH 1/4] Allow tests to indicate they don't play well with wrappers Signed-off-by: Patrick Uiterwijk Reviewed-by: Randy Barlow --- diff --git a/tests/helpers/common.py b/tests/helpers/common.py index 97e7658..b1a2688 100755 --- a/tests/helpers/common.py +++ b/tests/helpers/common.py @@ -73,13 +73,14 @@ KEY_TYPE = "aes256-cts-hmac-sha1-96:normal" class IpsilonTestBase(object): - def __init__(self, name, execname): + def __init__(self, name, execname, allow_wrappers=True): self.name = name self.execname = execname self.rootdir = os.getcwd() self.testdir = None self.testuser = pwd.getpwuid(os.getuid())[0] self.processes = [] + self.allow_wrappers = allow_wrappers def platform_supported(self): """This return whether the current platform supports this test. diff --git a/tests/tests.py b/tests/tests.py index 0d3afbc..af43112 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -40,8 +40,8 @@ def parse_args(): return vars(parser.parse_args()) -def try_wrappers(base, wrappers): - if wrappers == 'no': +def try_wrappers(base, wrappers, allow_wrappers): + if wrappers == 'no' or not allow_wrappers: return {} pkgcfg = subprocess.Popen(['pkg-config', '--exists', 'socket_wrapper']) @@ -96,7 +96,7 @@ if __name__ == '__main__': test.setup_base(args['path'], test) - env = try_wrappers(test.testdir, args['wrappers']) + env = try_wrappers(test.testdir, args['wrappers'], test.allow_wrappers) env['PYTHONPATH'] = test.rootdir env['TESTDIR'] = test.testdir From 955df8f32ed8902eea959d08362a7f764e1a8d96 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Feb 15 2017 00:12:36 +0000 Subject: [PATCH 2/4] Implement Etcd-based data store Signed-off-by: Patrick Uiterwijk Reviewed-by: Randy Barlow --- diff --git a/ipsilon/util/data.py b/ipsilon/util/data.py index 9713cb1..ef2b25b 100644 --- a/ipsilon/util/data.py +++ b/ipsilon/util/data.py @@ -10,8 +10,14 @@ from sqlalchemy.schema import (PrimaryKeyConstraint, Index, AddConstraint, CreateIndex) from sqlalchemy.sql import select, and_ import ConfigParser +try: + import etcd +except ImportError: + etcd = None import os +import json import uuid +from urlparse import urlparse import logging import time @@ -319,6 +325,284 @@ class FileQuery(Log): raise NotImplementedError +class EtcdStore(BaseStore): + """Etcd-based storage + + Example URI: etcd://server/rootpath?port=2379&scheme=https + The rootpath indicates at what point in the etcd key-space we will insert + our keys. + The parts after the ? are passed as key-value to the etcd client. + """ + + def __init__(self, uri): + if etcd is None: + raise NotImplementedError('Etcd client not available') + url = urlparse(uri) + self.rootpath = url.path + config = dict([cfg.split('=', 1) for cfg in url.query.split('&')]) + + if 'port' in config: + config['port'] = int(config['port']) + + self.debug('Etcd host: %s, rootpath: %s, config: %s' % + (url.netloc, url.path, config)) + + self.client = etcd.Client(host=url.netloc, **config) + + # We ignore the value, but this is a connection test + self.client.leader # pylint: disable=pointless-statement + + self.is_readonly = False + + def add_constraint(self, table): + raise NotImplementedError() + + def add_index(self, index): + raise NotImplementedError() + + def close(self): + # No-op + return + + +class EtcdQuery(Log): + """ + Class to store stuff in Etcd key-value stores. + + A row is stored in the etcd store under + /////.../ + Where rootpath is configurable,
is the name of the name of the + table, and pk_1, pk_2, ..., pk_n are the first, second and nth components + of the primary key of that table. + + This means that tables using etcd require a primary key. + + The value stored at those keys is a json document with all of the keys and + values for that object, including the primary keys. + + Cleanup of objects in etcd we leave to Etcd: when the object gets created, + we store the TTL in the key. + """ + + def __init__(self, store, table, table_def, trans=True): + """Query class initialization. + + store is a handle to a connected EtcdStore object. + table is the name of the "table" (key space) we are querying. + table_def is the table definition, look at OPTIONS_TABLE and + UNIQUE_DATA_TABLE for examples. + trans is accepted for compatibility with other Query types, but + ignored. + """ + if etcd is None: + raise NotImplementedError('Etcd client not available') + # We don't have indexes in a EtcdQuery, so drop that info + if isinstance(table_def, dict) and 'primary_key' in table_def: + columns = table_def['columns'] + if isinstance(columns[0], tuple): + columns = [column[0] for column in columns] + self._primary_key = tuple(table_def['primary_key']) + else: + # This is a custom plugin that uses tables that are incompatible + # with etcd. + raise ValueError('Etcd requires primary key') + self._table = table + self._table_def = table_def + self._store = store + self._section = table + self._columns = columns + self._con = store + + @property + def _table_dir(self): + """This returns the full path to the table key.""" + return '%s/%s' % (self._store.rootpath, self._table) + + def _get_most_specific_dir(self, kvfilter, del_kv=True, update=False): + """Get the most specific dir in which we can find stuff. + + Return a tuple with path and then the number of path levels not used. + + kvfilter is a dict with the keys we want to filter for. + del_kv is a boolean that indicates whether or not to remove used + filters from the kvfilter dict. + update is a boolean that indicates whether this is for an insert/update + operation. Those require a fully specified object path. + """ + path = self._table_dir + + if kvfilter is None: + kvfilter = {} + + pkeys_used = 0 + # We try to use as much of the primary key as we are able to to + # generate the most specific path possible. + for pkey in self._primary_key: + if pkey in kvfilter: + pkeys_used += 1 + path = os.path.join(path, kvfilter[pkey].replace(' ', '_')) + if del_kv: + del kvfilter[pkey] + else: + # Seems this next primary key value was not part of the filter + break + + levels_unused = len(self._primary_key) - pkeys_used + + if levels_unused != 0 and update: + raise Exception('Fully qualified object required for updates') + + return path, levels_unused + + def rollback(self): + """Rollback is ignored because etcd doesn't have transactions.""" + return + + def commit(self): + """Commit is ignored because etcd doesn't have transactions.""" + return + + def create(self): + """Create a directory to store the current table in.""" + try: + self._store.client.write(self._table_dir, None, dir=True) + except etcd.EtcdNotFile: + # This means that this key already contained a directory. In which + # case, we are done. + pass + + def drop(self): + """Drop the current table and everything under it.""" + self._store.client.delete(self._table_dir, recursive=True, dir=True) + + def _select_objects(self, kvfilter): + """ + Select all the objects that satisfy the kvfilter parts that are in the + primary key for this table. + """ + path, levels_unused = self._get_most_specific_dir(kvfilter) + try: + res = self._store.client.read(path, recursive=levels_unused != 0) + except etcd.EtcdKeyNotFound: + return None + + if levels_unused == 0: + # This was a fully qualified object, let's use the object + if res.dir: + return [] + else: + return [res] + else: + # This was not fully qualified. Given we used recursive=True, we + # know that "children" is the final objects. + return [cld for cld in res.children if not cld.dir] + + def _select_filter(self, kvfilter, res): + """ + Filters all objects from res that do not satisfy the non-primary + kvfilter entries. + """ + for obj in res: + result = json.loads(obj.value) + + pick_object = True + for key in kvfilter: + if key not in result: + pick_object = False + break + if result[key] != kvfilter[key]: + pick_object = False + break + if pick_object: + yield result + + def select(self, kvfilter=None, columns=None): + """Select specific objects from the store. + + kvfilter is a dict indicating which keys should be matched for. + columns is a list of columns to return, and their order. + Returns a list of column value lists. + """ + if columns is None: + columns = self._columns + + res = self._select_objects(kvfilter) + if res is None: + return [] + results = self._select_filter(kvfilter, res) + + rows = [] + for obj in results: + row = [] + for column in columns: + row.append(obj[column]) + rows.append(tuple(row)) + + return rows + + def insert(self, value_row, ttl=None): + """Insert a new object into the store. + + value_row is a list of column values. + ttl is the time for which the object is supposed to be kept. + """ + value_row = list(value_row) + + values = {} + for column in self._columns: + values[column] = value_row.pop(0) + + path, _ = self._get_most_specific_dir(values, False, update=True) + self._store.client.write(path, json.dumps(values), ttl=ttl) + + def update(self, values, kvfilter): + """Updates an item in the store. + + Requires a single object, thus the kvfilter must be specific to match + a single object. + + kvfilter is the dict of key-values that find a specific object. + values is the dict with key-values that we want to update to. + """ + path, _ = self._get_most_specific_dir(kvfilter, update=True) + for key in values: + if key in self._primary_key: + raise ValueError('Unable to update primary key values') + + current = json.loads(self._store.client.read(path).value) + for key in values: + current[key] = values[key] + self._store.client.write(path, json.dumps(current)) + + def delete(self, kvfilter): + """Deletes an item from the store. + + Requires a single object, thus the kvfilter must be specific to match + a single object. + + kvfilter is the dict of key-values that find a specific object. + """ + path, levels_unused = self._get_most_specific_dir(kvfilter) + if levels_unused == 0 or len(kvfilter) == 0: + try: + current = json.loads(self._store.client.read(path).value) + except etcd.EtcdKeyNotFound: + return + for key in kvfilter: + if current[key] != kvfilter[key]: + # We had 0 levels unused, meaning we are at a qualified + # object, and it doesn't match the kvfilter. We are done. + return + try: + self._store.client.delete(path, recursive=True, dir=True) + except etcd.EtcdKeyNotFound: + pass + else: + # This was not a fully specified object, we need to get all fully + # qualified objects + raise NotImplementedError() + + class Store(Log): # Static, Store-level variables _is_upgrade = False @@ -345,6 +629,9 @@ class Store(Log): _, filename = name.split('://') self._db = FileStore(filename) self._query = FileQuery + elif name.startswith('etcd://'): + self._db = EtcdStore(name) + self._query = EtcdQuery else: self._db = SqlStore.get_connection(name) self._query = SqlQuery From bbeb3e12049589189ea3a746dc982fd4616f0631 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Feb 15 2017 00:12:36 +0000 Subject: [PATCH 3/4] Implement Etcd-based session store Signed-off-by: Patrick Uiterwijk Reviewed-by: Randy Barlow --- diff --git a/ipsilon/ipsilon b/ipsilon/ipsilon index ec0b27f..1e775a2 100755 --- a/ipsilon/ipsilon +++ b/ipsilon/ipsilon @@ -38,6 +38,7 @@ def nuke_session_locks(): cfgfile = find_config(None, None) cherrypy.lib.sessions.SqlSession = ipsilon.util.sessions.SqlSession +cherrypy.lib.sessions.EtcdSession = ipsilon.util.sessions.EtcdSession cherrypy.config.update(cfgfile) # Force cherrypy logging to work. Note that this ignores the config-file diff --git a/ipsilon/tools/dbupgrade.py b/ipsilon/tools/dbupgrade.py index 54d7105..46cdfcd 100644 --- a/ipsilon/tools/dbupgrade.py +++ b/ipsilon/tools/dbupgrade.py @@ -7,7 +7,7 @@ import os from jinja2 import Environment, FileSystemLoader import ipsilon.util.sessions from ipsilon.util.data import AdminStore, Store, UserStore, TranStore -from ipsilon.util.sessions import SqlSession +from ipsilon.util.sessions import SqlSession, EtcdSession from ipsilon.root import Root import logging @@ -62,6 +62,7 @@ def upgrade_failed(): def execute_upgrade(cfgfile): cherrypy.lib.sessions.SqlSession = ipsilon.util.sessions.SqlSession + cherrypy.lib.sessions.EtcdSession = ipsilon.util.sessions.EtcdSession cherrypy.config.update(cfgfile) # pylint: disable=protected-access @@ -84,13 +85,19 @@ def execute_upgrade(cfgfile): # Handle the session store if that is Sql logger.info('Handling sessions datastore') - if cherrypy.config['tools.sessions.storage_type'] != 'sql': - logger.info('Not SQL-based, skipping') - else: + sesstype = cherrypy.config['tools.sessions.storage_type'].lower() + if sesstype == 'sql': dburi = cherrypy.config['tools.sessions.storage_dburi'] SqlSession.setup(storage_dburi=dburi) if _upgrade_database(SqlSession._store) not in [True, None]: return upgrade_failed() + elif sesstype == 'etcd': + dburi = cherrypy.config['tools.sessions.storage_dburi'] + EtcdSession.setup(storage_dburi=dburi) + if _upgrade_database(EtcdSession._store) not in [True, None]: + return upgrade_failed() + else: + logger.info('File based, skipping') # Now handle the rest of the default datastores for store in [UserStore, TranStore]: diff --git a/ipsilon/util/sessions.py b/ipsilon/util/sessions.py index fa4418e..74b6b7e 100644 --- a/ipsilon/util/sessions.py +++ b/ipsilon/util/sessions.py @@ -1,4 +1,4 @@ -# Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING +# Copyright (C) 2014,2016 Ipsilon project Contributors, for license see COPYING import base64 from cherrypy.lib.sessions import Session @@ -6,11 +6,16 @@ from ipsilon.util.data import Store, SqlQuery import threading import datetime try: + import etcd +except ImportError: + etcd = None +import json +import time +try: import cPickle as pickle except ImportError: import pickle - SESSION_TABLE = {'columns': ['id', 'data', 'expiration_time'], 'primary_key': ('id', ), 'indexes': [('expiration_time',)] @@ -108,3 +113,105 @@ class SqlSession(Session): """Release the lock on the currently-loaded session data.""" self.locks[self.id].release() self.locked = False + + +class EtcdSessionStore(Store): + def _initialize_schema(self): + return + + def _upgrade_schema(self, old_version): + raise NotImplementedError() + + def _cleanup(self): + return + + +class EtcdSession(Session): + """Cherrypy-compatible session store backed by Etcd. + + All implemented functions are part of the standard cherrypy session manager + API. + """ + + dburi = None + _client = None + _store = None + _proto = 2 + + @classmethod + def setup(cls, **kwargs): + """Initialization for EtcdSession. + + Called by cherrypy with all session options. + """ + if etcd is None: + raise NotImplementedError('Etcd client not available') + for k, v in kwargs.items(): + if k == 'storage_dburi': + cls.dburi = v + + cls._store = EtcdSessionStore(database_url=cls.dburi) + # pylint: disable=protected-access + cls._rootpath = cls._store._db.rootpath + # pylint: disable=protected-access + cls._client = cls._store._db.client + + @property + def _session_path(self): + """Returns a path in etcd where we store sessions.""" + return '%s/sessions/%s' % (self._rootpath, self.id) + + @property + def _lock(self): + """Returns an etcd.Lock to lock the session across instances.""" + lock = etcd.Lock(self._client, + 'session/%s' % self.id) + # We need to do this manually because otherwise python-etcd invents + # a new uuid for each lock instantiation, while we want to make the + # lock specific for the path. + # pylint: disable=protected-access + lock._uuid = 'wellknown' + return lock + + def _exists(self): + """Returns a boolean whether the current session exists in the store. + """ + try: + self._client.read(self._session_path) + return True + except etcd.EtcdKeyNotFound: + return False + + def _load(self): + """Tries to load the current session from the store.""" + try: + data = self._client.read(self._session_path) + # pylint: disable=no-member + value, exp_time = json.loads(data.value) + exp_dt = datetime.datetime.utcfromtimestamp(exp_time) + return value, exp_dt + except etcd.EtcdKeyNotFound: + return None + + def _save(self, expiration_time): + """Saves the current session to the store.""" + expiration_time = int(time.mktime(expiration_time.timetuple())) + ttl = expiration_time - int(time.time()) + self._client.write(self._session_path, + json.dumps((self._data, expiration_time)), + ttl=ttl) + + def _delete(self): + """Deletes and invalidates the current session.""" + try: + self._client.delete(self._session_path) + except etcd.EtcdKeyNotFound: + pass + + def acquire_lock(self): + self._lock.acquire(blocking=True) + self.locked = True + + def release_lock(self): + self._lock.release() + self.locked = False From 38dd350faf0a62e4b073cd413d5be7fcef1928d8 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Feb 15 2017 00:14:34 +0000 Subject: [PATCH 4/4] Add tests for Etcd data and session stores Signed-off-by: Patrick Uiterwijk Reviewed-by: Randy Barlow --- diff --git a/Makefile b/Makefile index e5ff5bd..093179d 100644 --- a/Makefile +++ b/Makefile @@ -99,6 +99,7 @@ tests: wrappers PYTHONPATH=./ ./tests/tests.py --path=$(TESTDIR) --test=attrs PYTHONPATH=./ ./tests/tests.py --path=$(TESTDIR) --test=trans PYTHONPATH=./ ./tests/tests.py --path=$(TESTDIR) --test=pgdb + PYTHONPATH=./ ./tests/tests.py --path=$(TESTDIR) --test=testetcd PYTHONPATH=./ ./tests/tests.py --path=$(TESTDIR) --test=fconf PYTHONPATH=./ ./tests/tests.py --path=$(TESTDIR) --test=ldap PYTHONPATH=./ ./tests/tests.py --path=$(TESTDIR) --test=ldapdown diff --git a/tests/containers/Dockerfile-fedora b/tests/containers/Dockerfile-fedora index e69de29..4f252c6 100644 --- a/tests/containers/Dockerfile-fedora +++ b/tests/containers/Dockerfile-fedora @@ -0,0 +1 @@ +RUN yum install -y etcd python-etcd diff --git a/tests/helpers/common.py b/tests/helpers/common.py index b1a2688..09137fa 100755 --- a/tests/helpers/common.py +++ b/tests/helpers/common.py @@ -256,6 +256,21 @@ basicConstraints = CA:false""" % {'certdir': os.path.join(self.testdir, with open(filename, 'a') as f: f.write(auth) + def start_etcd_server(self, datadir, addr, clientport, srvport, env): + env['ETCD_NAME'] = 'ipsilon' + env['ETCD_DATA_DIR'] = datadir + env['ETCD_LISTEN_CLIENT_URLS'] = 'http://%s:%s' % (addr, clientport) + env['ETCD_LISTEN_PEER_URLS'] = 'http://%s:%s' % (addr, srvport) + env['ETCD_FORCE_NEW_CLUSTER'] = 'true' + env['ETCD_INITIAL_CLUSTER'] = 'ipsilon=http://%s:%s' % (addr, srvport) + env['ETCD_ADVERTISE_CLIENT_URLS'] = 'http://%s:%s' % (addr, clientport) + env['ETCD_INITIAL_ADVERTISE_PEER_URLS'] = 'http://%s:%s' % (addr, + srvport) + p = subprocess.Popen(['/usr/bin/etcd'], + env=env, preexec_fn=os.setsid) + self.processes.append(p) + return p + def start_http_server(self, conf, env): env['MALLOC_CHECK_'] = '3' env['MALLOC_PERTURB_'] = str(random.randint(0, 32767) % 255 + 1) diff --git a/tests/testetcd.py b/tests/testetcd.py new file mode 100755 index 0000000..b90594c --- /dev/null +++ b/tests/testetcd.py @@ -0,0 +1,261 @@ +#!/usr/bin/python +# +# Copyright (C) 2017 Ipsilon project Contributors, for license see COPYING + +from helpers.common import IpsilonTestBase # pylint: disable=relative-import +from helpers.http import HttpSessions # pylint: disable=relative-import +import os +import pwd +import sys +import subprocess +from string import Template + +idp_g = {'TEMPLATES': '${TESTDIR}/templates/install', + 'CONFDIR': '${TESTDIR}/etc', + 'DATADIR': '${TESTDIR}/lib', + 'CACHEDIR': '${TESTDIR}/cache', + 'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', + 'STATICDIR': '${ROOTDIR}', + 'BINDIR': '${ROOTDIR}/ipsilon', + 'WSGI_SOCKET_PREFIX': '${TESTDIR}/${NAME}/logs/wsgi'} + + +idp_a = {'hostname': '${ADDRESS}:${PORT}', + 'admin_user': '${TEST_USER}', + 'system_user': '${TEST_USER}', + 'instance': '${NAME}', + 'testauth': 'yes', + 'pam': 'no', + 'gssapi': 'no', + 'ipa': 'no', + 'server_debugging': 'True', + 'openidc': 'yes', + 'database_url': 'etcd://127.0.0.10/ipsilon/%(dbname)s?port=42379', + 'session_type': 'etcd', + 'session_dburi': 'etcd://127.0.0.10/ipsilon/sessions?port=42379'} + + +sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', + 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} + + +sp_a = {'hostname': '${ADDRESS}', + 'saml_idp_metadata': 'https://127.0.0.10:45080/idp1/saml2/metadata', + 'saml_auth': '/sp', + 'httpd_user': '${TEST_USER}'} + +sp2_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', + 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} + +sp2_a = {'hostname': '${ADDRESS}', + 'saml_idp_url': 'https://127.0.0.10:45080/idp1', + 'admin_user': '${TEST_USER}', + 'admin_password': '${TESTDIR}/pw.txt', + 'saml_sp_name': 'sp2-test.example.com', + 'saml_auth': '/sp', + 'httpd_user': '${TEST_USER}'} + +keyless_metadata = """ + + + + +urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + +""" + + +def fixup_sp_httpd(httpdir): + location = """ + +Alias /sp ${HTTPDIR}/sp + + + + Require all granted + + + Order Allow,Deny + Allow from All + + +""" + index = """WORKS!""" + + t = Template(location) + text = t.substitute({'HTTPDIR': httpdir}) + with open(httpdir + '/conf.d/ipsilon-saml.conf', 'a') as f: + f.write(text) + + os.mkdir(httpdir + '/sp') + with open(httpdir + '/sp/index.html', 'w') as f: + f.write(index) + + +class IpsilonTest(IpsilonTestBase): + + def __init__(self): + # Etcd is written in golang, which links everything statically. As such + # the wrappers are not going to work. + super(IpsilonTest, self).__init__('testetcd', __file__, + allow_wrappers=False) + + def platform_supported(self): + try: + p = subprocess.Popen(['/usr/bin/etcd', '--version'], + stdout=subprocess.PIPE) + stdout, _ = p.communicate() + except OSError: + print "No etcd installed" + return False + if not p.wait() == 0: + print 'No etcd installed' + return False + # Example line: etcd Version: 3.0.13 + if int(stdout.split('\n')[0].split(': ')[1][0]) < 3: + print 'Etcd version < 3.0' + return False + try: + import etcd # pylint: disable=unused-variable,import-error + except ImportError: + print 'No python-etcd available' + return False + return True + + def setup_servers(self, env=None): + + print "Starting IDP's etcd server" + datadir = os.path.join(self.testdir, 'etcd') + os.mkdir(datadir) + addr = '127.0.0.10' + clientport = '42379' + srvport = '42380' + self.start_etcd_server(datadir, addr, clientport, srvport, env) + + print "Installing IDP server" + name = 'idp1' + addr = '127.0.0.10' + port = '45080' + idp = self.generate_profile(idp_g, idp_a, name, addr, port) + conf = self.setup_idp_server(idp, name, addr, port, env) + + print "Starting IDP's httpd server" + self.start_http_server(conf, env) + + print "Installing first SP server" + name = 'sp1' + addr = '127.0.0.11' + port = '45081' + sp = self.generate_profile(sp_g, sp_a, name, addr, port) + conf = self.setup_sp_server(sp, name, addr, port, env) + fixup_sp_httpd(os.path.dirname(conf)) + + print "Starting first SP's httpd server" + self.start_http_server(conf, env) + + print "Installing second SP server" + name = 'sp2-test.example.com' + addr = '127.0.0.11' + port = '45082' + sp = self.generate_profile(sp2_g, sp2_a, name, addr, port) + with open(os.path.dirname(sp) + '/pw.txt', 'a') as f: + f.write('ipsilon') + conf = self.setup_sp_server(sp, name, addr, port, env) + os.remove(os.path.dirname(sp) + '/pw.txt') + fixup_sp_httpd(os.path.dirname(conf)) + + print "Starting second SP's httpd server" + self.start_http_server(conf, env) + + +if __name__ == '__main__': + + idpname = 'idp1' + sp1name = 'sp1' + sp2name = 'sp2-test.example.com' + user = pwd.getpwuid(os.getuid())[0] + + sess = HttpSessions() + sess.add_server(idpname, 'https://127.0.0.10:45080', user, 'ipsilon') + sess.add_server(sp1name, 'https://127.0.0.11:45081') + sess.add_server(sp2name, 'https://127.0.0.11:45082') + + print "etcd: Authenticate to IDP ...", + try: + sess.auth_to_idp(idpname) + except Exception, e: # pylint: disable=broad-except + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "etcd: Add first SP Metadata to IDP ...", + try: + sess.add_sp_metadata(idpname, sp1name) + except Exception, e: # pylint: disable=broad-except + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "etcd: Access first SP Protected Area ...", + try: + page = sess.fetch_page(idpname, 'https://127.0.0.11:45081/sp/') + page.expected_value('text()', 'WORKS!') + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "etcd: Access second SP Protected Area ...", + try: + page = sess.fetch_page(idpname, 'https://127.0.0.11:45082/sp/') + page.expected_value('text()', 'WORKS!') + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "etcd: Update second SP ...", + try: + # This is a test to see whether we can update SAML SPs where the name + # is an FQDN (includes hyphens and dots). See bug #196 + sess.set_attributes_and_mapping(idpname, [], + ['namefull', 'givenname', 'surname'], + spname=sp2name) + except Exception, e: # pylint: disable=broad-except + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + else: + print " SUCCESS" + + print "etcd: Try authentication failure ...", + newsess = HttpSessions() + newsess.add_server(idpname, 'https://127.0.0.10:45080', user, 'wrong') + try: + newsess.auth_to_idp(idpname) + print >> sys.stderr, " ERROR: Authentication should have failed" + sys.exit(1) + except Exception, e: # pylint: disable=broad-except + print " SUCCESS" + + print "etcd: Add keyless SP Metadata to IDP ...", + try: + sess.add_metadata(idpname, 'keyless', keyless_metadata) + page = sess.fetch_page(idpname, 'https://127.0.0.10:45080/idp1/admin/' + 'providers/saml2/admin') + page.expected_value('//div[@id="row_provider_http://keyless-sp"]/' + '@title', + 'WARNING: SP does not have signing keys!') + except Exception, e: # pylint: disable=broad-except + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS"