From ac4e5f446cf183cfdd992cac9c82defea2147c68 Mon Sep 17 00:00:00 2001 From: Paul Michali Date: Mon, 17 Feb 2014 15:56:22 -0500 Subject: [PATCH] VPNaaS Device Driver for Cisco CSR This is the device driver for the vendor specific VPNaaS plugin. This change relies on the service driver code (review 74144), which is also out for review. Note: Support for sharing of IKE/IPSec policies (which is currently prevented by the service driver code), will be done as a later enhancement. Note: Needs Tempest tests updated/created to test this. Note: To run, this needs an out-of-band Cisco CSR installed and configured. Note: This uses a newer version of requests library and a new httmock library. Until these are approved (75296), the UT will be renamed to prevent testing the REST client API to the CSR. Change-Id: I4f73f7fa1bfcdc89a35ffe63dd253f8eede98485 Paritally-Implements: blueprint vpnaas-cisco-driver --- etc/neutron/plugins/cisco/cisco_vpn_agent.ini | 22 + .../device_drivers/cisco_csr_rest_client.py | 254 +++ .../vpn/device_drivers/cisco_ipsec.py | 795 ++++++++++ .../vpn/service_drivers/cisco_csr_db.py | 8 +- .../vpn/device_drivers/cisco_csr_mock.py | 538 +++++++ .../device_drivers/notest_cisco_csr_rest.py | 1206 ++++++++++++++ .../vpn/device_drivers/test_cisco_ipsec.py | 1386 +++++++++++++++++ 7 files changed, 4207 insertions(+), 2 deletions(-) create mode 100644 etc/neutron/plugins/cisco/cisco_vpn_agent.ini create mode 100644 neutron/services/vpn/device_drivers/cisco_csr_rest_client.py create mode 100644 neutron/services/vpn/device_drivers/cisco_ipsec.py create mode 100644 neutron/tests/unit/services/vpn/device_drivers/cisco_csr_mock.py create mode 100644 neutron/tests/unit/services/vpn/device_drivers/notest_cisco_csr_rest.py create mode 100644 neutron/tests/unit/services/vpn/device_drivers/test_cisco_ipsec.py diff --git a/etc/neutron/plugins/cisco/cisco_vpn_agent.ini b/etc/neutron/plugins/cisco/cisco_vpn_agent.ini new file mode 100644 index 000000000..d15069b7c --- /dev/null +++ b/etc/neutron/plugins/cisco/cisco_vpn_agent.ini @@ -0,0 +1,22 @@ +[cisco_csr_ipsec] +# Status check interval in seconds, for VPNaaS IPSec connections used on CSR +# status_check_interval = 60 + +# Cisco CSR management port information for REST access used by VPNaaS +# TODO(pcm): Remove once CSR is integrated in as a Neutron router. +# +# Format is: +# [cisco_csr_rest:] +# rest_mgmt = +# tunnel_ip = +# username = +# password = +# timeout = +# +# where: +# public IP ----- Public IP address of router used with a VPN service (1:1 with CSR) +# tunnel IP ----- Public IP address of the CSR used for the IPSec tunnel +# mgmt port IP -- IP address of CSR for REST API access (not console port) +# user ---------- Username for REST management port access to Cisco CSR +# password ------ Password for REST management port access to Cisco CSR +# timeout ------- REST request timeout to Cisco CSR (optional) diff --git a/neutron/services/vpn/device_drivers/cisco_csr_rest_client.py b/neutron/services/vpn/device_drivers/cisco_csr_rest_client.py new file mode 100644 index 000000000..3cc3c435f --- /dev/null +++ b/neutron/services/vpn/device_drivers/cisco_csr_rest_client.py @@ -0,0 +1,254 @@ +# Copyright 2014 Cisco Systems, Inc. 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 +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# @author: Paul Michali, Cisco Systems, Inc. + +import time + +import netaddr +import requests +from requests import exceptions as r_exc + +from neutron.openstack.common import jsonutils +from neutron.openstack.common import log as logging + + +TIMEOUT = 20.0 + +LOG = logging.getLogger(__name__) +HEADER_CONTENT_TYPE_JSON = {'content-type': 'application/json'} +URL_BASE = 'https://%(host)s/api/v1/%(resource)s' + + +def make_route_id(cidr, interface): + """Build ID that will be used to identify route for later deletion.""" + net = netaddr.IPNetwork(cidr) + return '%(network)s_%(prefix)s_%(interface)s' % { + 'network': net.network, + 'prefix': net.prefixlen, + 'interface': interface} + + +class CsrRestClient(object): + + """REST CsrRestClient for accessing the Cisco Cloud Services Router.""" + + def __init__(self, host, tunnel_ip, username, password, timeout=None): + self.host = host + self.tunnel_ip = tunnel_ip + self.auth = (username, password) + self.token = None + self.status = requests.codes.OK + self.timeout = timeout + self.max_tries = 5 + self.session = requests.Session() + + def _response_info_for(self, response, method): + """Return contents or location from response. + + For a POST or GET with a 200 response, the response content + is returned. + + For a POST with a 201 response, return the header's location, + which contains the identifier for the created resource. + + If there is an error, return the response content, so that + it can be used in error processing ('error-code', 'error-message', + and 'detail' fields). + """ + if method in ('POST', 'GET') and self.status == requests.codes.OK: + LOG.debug(_('RESPONSE: %s'), response.json()) + return response.json() + if method == 'POST' and self.status == requests.codes.CREATED: + return response.headers.get('location', '') + if self.status >= requests.codes.BAD_REQUEST and response.content: + if 'error-code' in response.content: + content = jsonutils.loads(response.content) + LOG.debug("Error response content %s", content) + return content + + def _request(self, method, url, **kwargs): + """Perform REST request and save response info.""" + try: + LOG.debug(_("%(method)s: Request for %(resource)s payload: " + "%(payload)s"), + {'method': method.upper(), 'resource': url, + 'payload': kwargs.get('data')}) + start_time = time.time() + response = self.session.request(method, url, verify=False, + timeout=self.timeout, **kwargs) + LOG.debug(_("%(method)s Took %(time).2f seconds to process"), + {'method': method.upper(), + 'time': time.time() - start_time}) + except (r_exc.Timeout, r_exc.SSLError) as te: + # Should never see SSLError, unless requests package is old (<2.0) + timeout_val = 0.0 if self.timeout is None else self.timeout + LOG.warning(_("%(method)s: Request timeout%(ssl)s " + "(%(timeout).3f sec) for CSR(%(host)s)"), + {'method': method, + 'timeout': timeout_val, + 'ssl': '(SSLError)' + if isinstance(te, r_exc.SSLError) else '', + 'host': self.host}) + self.status = requests.codes.REQUEST_TIMEOUT + except r_exc.ConnectionError as ce: + LOG.error(_("%(method)s: Unable to connect to CSR(%(host)s): " + "%(error)s"), + {'method': method, 'host': self.host, 'error': ce}) + self.status = requests.codes.NOT_FOUND + except Exception as e: + LOG.error(_("%(method)s: Unexpected error for CSR (%(host)s): " + "%(error)s"), + {'method': method, 'host': self.host, 'error': e}) + self.status = requests.codes.INTERNAL_SERVER_ERROR + else: + self.status = response.status_code + LOG.debug(_("%(method)s: Completed [%(status)s]"), + {'method': method, 'status': self.status}) + return self._response_info_for(response, method) + + def authenticate(self): + """Obtain a token to use for subsequent CSR REST requests. + + This is called when there is no token yet, or if the token has expired + and attempts to use it resulted in an UNAUTHORIZED REST response. + """ + + url = URL_BASE % {'host': self.host, 'resource': 'auth/token-services'} + headers = {'Content-Length': '0', + 'Accept': 'application/json'} + headers.update(HEADER_CONTENT_TYPE_JSON) + LOG.debug(_("%(auth)s with CSR %(host)s"), + {'auth': 'Authenticating' if self.token is None + else 'Reauthenticating', 'host': self.host}) + self.token = None + response = self._request("POST", url, headers=headers, auth=self.auth) + if response: + self.token = response['token-id'] + LOG.debug(_("Successfully authenticated with CSR %s"), self.host) + return True + LOG.error(_("Failed authentication with CSR %(host)s [%(status)s]"), + {'host': self.host, 'status': self.status}) + + def _do_request(self, method, resource, payload=None, more_headers=None, + full_url=False): + """Perform a REST request to a CSR resource. + + If this is the first time interacting with the CSR, a token will + be obtained. If the request fails, due to an expired token, the + token will be obtained and the request will be retried once more. + """ + + if self.token is None: + if not self.authenticate(): + return + + if full_url: + url = resource + else: + url = ('https://%(host)s/api/v1/%(resource)s' % + {'host': self.host, 'resource': resource}) + headers = {'Accept': 'application/json', 'X-auth-token': self.token} + if more_headers: + headers.update(more_headers) + if payload: + payload = jsonutils.dumps(payload) + response = self._request(method, url, data=payload, headers=headers) + if self.status == requests.codes.UNAUTHORIZED: + if not self.authenticate(): + return + headers['X-auth-token'] = self.token + response = self._request(method, url, data=payload, + headers=headers) + if self.status != requests.codes.REQUEST_TIMEOUT: + return response + LOG.error(_("%(method)s: Request timeout for CSR(%(host)s)"), + {'method': method, 'host': self.host}) + + def get_request(self, resource, full_url=False): + """Perform a REST GET requests for a CSR resource.""" + return self._do_request('GET', resource, full_url=full_url) + + def post_request(self, resource, payload=None): + """Perform a POST request to a CSR resource.""" + return self._do_request('POST', resource, payload=payload, + more_headers=HEADER_CONTENT_TYPE_JSON) + + def put_request(self, resource, payload=None): + """Perform a PUT request to a CSR resource.""" + return self._do_request('PUT', resource, payload=payload, + more_headers=HEADER_CONTENT_TYPE_JSON) + + def delete_request(self, resource): + """Perform a DELETE request on a CSR resource.""" + return self._do_request('DELETE', resource, + more_headers=HEADER_CONTENT_TYPE_JSON) + + def create_ike_policy(self, policy_info): + base_ike_policy_info = {u'version': u'v1', + u'local-auth-method': u'pre-share'} + base_ike_policy_info.update(policy_info) + return self.post_request('vpn-svc/ike/policies', + payload=base_ike_policy_info) + + def create_ipsec_policy(self, policy_info): + base_ipsec_policy_info = {u'mode': u'tunnel'} + base_ipsec_policy_info.update(policy_info) + return self.post_request('vpn-svc/ipsec/policies', + payload=base_ipsec_policy_info) + + def create_pre_shared_key(self, psk_info): + return self.post_request('vpn-svc/ike/keyrings', payload=psk_info) + + def create_ipsec_connection(self, connection_info): + base_conn_info = {u'vpn-type': u'site-to-site', + u'ip-version': u'ipv4'} + connection_info.update(base_conn_info) + # TODO(pcm) pass in value, when CSR is embedded as Neutron router. + # Currently, get this from .INI file. + connection_info[u'local-device'][u'tunnel-ip-address'] = self.tunnel_ip + return self.post_request('vpn-svc/site-to-site', + payload=connection_info) + + def configure_ike_keepalive(self, keepalive_info): + base_keepalive_info = {u'periodic': True} + keepalive_info.update(base_keepalive_info) + return self.put_request('vpn-svc/ike/keepalive', keepalive_info) + + def create_static_route(self, route_info): + return self.post_request('routing-svc/static-routes', + payload=route_info) + + def delete_static_route(self, route_id): + return self.delete_request('routing-svc/static-routes/%s' % route_id) + + def delete_ipsec_connection(self, conn_id): + return self.delete_request('vpn-svc/site-to-site/%s' % conn_id) + + def delete_ipsec_policy(self, policy_id): + return self.delete_request('vpn-svc/ipsec/policies/%s' % policy_id) + + def delete_ike_policy(self, policy_id): + return self.delete_request('vpn-svc/ike/policies/%s' % policy_id) + + def delete_pre_shared_key(self, key_id): + return self.delete_request('vpn-svc/ike/keyrings/%s' % key_id) + + def read_tunnel_statuses(self): + results = self.get_request('vpn-svc/site-to-site/active/sessions') + if self.status != requests.codes.OK or not results: + return [] + tunnels = [(t[u'vpn-interface-name'], t[u'status']) + for t in results['items']] + return tunnels diff --git a/neutron/services/vpn/device_drivers/cisco_ipsec.py b/neutron/services/vpn/device_drivers/cisco_ipsec.py new file mode 100644 index 000000000..c518491d3 --- /dev/null +++ b/neutron/services/vpn/device_drivers/cisco_ipsec.py @@ -0,0 +1,795 @@ +# Copyright 2014 Cisco Systems, Inc. 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 +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# @author: Paul Michali, Cisco Systems, Inc. + +import abc +from collections import namedtuple +import httplib + +import netaddr +from oslo.config import cfg + +from neutron.common import exceptions +from neutron.common import rpc as n_rpc +from neutron import context as ctx +from neutron.openstack.common import lockutils +from neutron.openstack.common import log as logging +from neutron.openstack.common import loopingcall +from neutron.openstack.common import rpc +from neutron.openstack.common.rpc import proxy +from neutron.plugins.common import constants +from neutron.plugins.common import utils as plugin_utils +from neutron.services.vpn.common import topics +from neutron.services.vpn import device_drivers +from neutron.services.vpn.device_drivers import ( + cisco_csr_rest_client as csr_client) + + +ipsec_opts = [ + cfg.IntOpt('status_check_interval', + default=60, + help=_("Status check interval for Cisco CSR IPSec connections")) +] +cfg.CONF.register_opts(ipsec_opts, 'cisco_csr_ipsec') + +LOG = logging.getLogger(__name__) + +RollbackStep = namedtuple('RollbackStep', ['action', 'resource_id', 'title']) + + +class CsrResourceCreateFailure(exceptions.NeutronException): + message = _("Cisco CSR failed to create %(resource)s (%(which)s)") + + +class CsrDriverMismatchError(exceptions.NeutronException): + message = _("Required %(resource)s attribute %(attr)s mapping for Cisco " + "CSR is missing in device driver") + + +class CsrUnknownMappingError(exceptions.NeutronException): + message = _("Device driver does not have a mapping of '%(value)s for " + "attribute %(attr)s of %(resource)s") + + +def find_available_csrs_from_config(config_files): + """Read INI for available Cisco CSRs that driver can use. + + Loads management port, tunnel IP, user, and password information for + available CSRs from configuration file. Driver will use this info to + configure VPN connections. The CSR is associated 1:1 with a Neutron + router. To identify which CSR to use for a VPN service, the public + (GW) IP of the Neutron router will be used as an index into the CSR + config info. + """ + multi_parser = cfg.MultiConfigParser() + LOG.info(_("Scanning config files %s for Cisco CSR configurations"), + config_files) + try: + read_ok = multi_parser.read(config_files) + except cfg.ParseError as pe: + LOG.error(_("Config file parse error: %s"), pe) + return {} + + if len(read_ok) != len(config_files): + raise cfg.Error(_("Unable to parse config files %s for Cisco CSR " + "info") % config_files) + csrs_found = {} + for parsed_file in multi_parser.parsed: + for parsed_item in parsed_file.keys(): + device_type, sep, for_router = parsed_item.partition(':') + if device_type.lower() == 'cisco_csr_rest': + try: + netaddr.IPNetwork(for_router) + except netaddr.core.AddrFormatError: + LOG.error(_("Ignoring Cisco CSR configuration entry - " + "router IP %s is not valid"), for_router) + continue + entry = parsed_file[parsed_item] + # Check for missing fields + try: + rest_mgmt_ip = entry['rest_mgmt'][0] + tunnel_ip = entry['tunnel_ip'][0] + username = entry['username'][0] + password = entry['password'][0] + except KeyError as ke: + LOG.error(_("Ignoring Cisco CSR for router %(router)s " + "- missing %(field)s setting"), + {'router': for_router, 'field': str(ke)}) + continue + # Validate fields + try: + timeout = float(entry['timeout'][0]) + except ValueError: + LOG.error(_("Ignoring Cisco CSR for router %s - " + "timeout is not a floating point number"), + for_router) + continue + except KeyError: + timeout = csr_client.TIMEOUT + try: + netaddr.IPAddress(rest_mgmt_ip) + except netaddr.core.AddrFormatError: + LOG.error(_("Ignoring Cisco CSR for subnet %s - " + "REST management is not an IP address"), + for_router) + continue + try: + netaddr.IPAddress(tunnel_ip) + except netaddr.core.AddrFormatError: + LOG.error(_("Ignoring Cisco CSR for router %s - " + "local tunnel is not an IP address"), + for_router) + continue + csrs_found[for_router] = {'rest_mgmt': rest_mgmt_ip, + 'tunnel_ip': tunnel_ip, + 'username': username, + 'password': password, + 'timeout': timeout} + + LOG.debug(_("Found CSR for router %(router)s: %(info)s"), + {'router': for_router, + 'info': csrs_found[for_router]}) + return csrs_found + + +class CiscoCsrIPsecVpnDriverApi(proxy.RpcProxy): + """RPC API for agent to plugin messaging.""" + + def get_vpn_services_on_host(self, context, host): + """Get list of vpnservices on this host. + + The vpnservices including related ipsec_site_connection, + ikepolicy, ipsecpolicy, and Cisco info on this host. + """ + return self.call(context, + self.make_msg('get_vpn_services_on_host', + host=host), + topic=self.topic) + + def update_status(self, context, status): + """Update status for all VPN services and connections.""" + return self.cast(context, + self.make_msg('update_status', + status=status), + topic=self.topic) + + +class CiscoCsrIPsecDriver(device_drivers.DeviceDriver): + """Cisco CSR VPN Device Driver for IPSec. + + This class is designed for use with L3-agent now. + However this driver will be used with another agent in future. + so the use of "Router" is kept minimul now. + Insted of router_id, we are using process_id in this code. + """ + + # history + # 1.0 Initial version + + RPC_API_VERSION = '1.0' + __metaclass__ = abc.ABCMeta + + def __init__(self, agent, host): + self.host = host + self.conn = rpc.create_connection(new=True) + context = ctx.get_admin_context_without_session() + node_topic = '%s.%s' % (topics.CISCO_IPSEC_AGENT_TOPIC, self.host) + + self.service_state = {} + + self.conn.create_consumer( + node_topic, + self.create_rpc_dispatcher(), + fanout=False) + self.conn.consume_in_thread() + self.agent_rpc = ( + CiscoCsrIPsecVpnDriverApi(topics.CISCO_IPSEC_DRIVER_TOPIC, '1.0')) + self.periodic_report = loopingcall.FixedIntervalLoopingCall( + self.report_status, context) + self.periodic_report.start( + interval=agent.conf.cisco_csr_ipsec.status_check_interval) + + csrs_found = find_available_csrs_from_config(cfg.CONF.config_file) + if csrs_found: + LOG.info(_("Loaded %(num)d Cisco CSR configuration%(plural)s"), + {'num': len(csrs_found), + 'plural': 's'[len(csrs_found) == 1:]}) + else: + raise SystemExit(_('No Cisco CSR configurations found in: %s') % + cfg.CONF.config_file) + self.csrs = dict([(k, csr_client.CsrRestClient(v['rest_mgmt'], + v['tunnel_ip'], + v['username'], + v['password'], + v['timeout'])) + for k, v in csrs_found.items()]) + + def create_rpc_dispatcher(self): + return n_rpc.PluginRpcDispatcher([self]) + + def vpnservice_updated(self, context, **kwargs): + """Handle VPNaaS service driver change notifications.""" + LOG.debug(_("Handling VPN service update notification")) + self.sync(context, []) + + def create_vpn_service(self, service_data): + """Create new entry to track VPN service and its connections.""" + vpn_service_id = service_data['id'] + vpn_service_router = service_data['external_ip'] + self.service_state[vpn_service_id] = CiscoCsrVpnService( + service_data, self.csrs.get(vpn_service_router)) + return self.service_state[vpn_service_id] + + def update_connection(self, context, vpn_service_id, conn_data): + """Handle notification for a single IPSec connection.""" + vpn_service = self.service_state[vpn_service_id] + conn_id = conn_data['id'] + is_admin_up = conn_data[u'admin_state_up'] + if conn_id in vpn_service.conn_state: + ipsec_conn = vpn_service.conn_state[conn_id] + ipsec_conn.last_status = conn_data['status'] + if is_admin_up: + ipsec_conn.is_dirty = False + LOG.debug(_("Update: IPSec connection %s unchanged - " + "marking clean"), conn_id) + # TODO(pcm) FUTURE - Handle update requests (delete/create?) + # will need to detect what has changed. For now assume no + # change (it is blocked in service driver). + else: + LOG.debug(_("Update: IPSec connection %s is admin down - " + "will be removed in sweep phase"), conn_id) + else: + if not is_admin_up: + LOG.debug(_("Update: Unknown IPSec connection %s is admin " + "down - ignoring"), conn_id) + return + LOG.debug(_("Update: New IPSec connection %s - marking clean"), + conn_id) + ipsec_conn = vpn_service.create_connection(conn_data) + ipsec_conn.create_ipsec_site_connection(context, conn_data) + return ipsec_conn + + def update_service(self, context, service_data): + """Handle notification for a single VPN Service and its connections.""" + vpn_service_id = service_data['id'] + csr_id = service_data['external_ip'] + if csr_id not in self.csrs: + LOG.error(_("Update: Skipping VPN service %(service)s as it's " + "router (%(csr_id)s is not associated with a Cisco " + "CSR"), {'service': vpn_service_id, 'csr_id': csr_id}) + return + is_admin_up = service_data[u'admin_state_up'] + if vpn_service_id in self.service_state: + vpn_service = self.service_state[vpn_service_id] + vpn_service.last_status = service_data['status'] + if is_admin_up: + vpn_service.is_dirty = False + else: + LOG.debug(_("Update: VPN service %s is admin down - will " + "be removed in sweep phase"), vpn_service_id) + return vpn_service + else: + if not is_admin_up: + LOG.debug(_("Update: Unknown VPN service %s is admin down - " + "ignoring"), vpn_service_id) + return + vpn_service = self.create_vpn_service(service_data) + # Handle all the IPSec connection notifications in the data + LOG.debug(_("Update: Processing IPSec connections for VPN service %s"), + vpn_service_id) + for conn_data in service_data['ipsec_conns']: + self.update_connection(context, service_data['id'], conn_data) + LOG.debug(_("Update: Completed update processing")) + return vpn_service + + def update_all_services_and_connections(self, context): + """Update services and connections based on plugin info. + + Perform any create and update operations and then update status. + Mark every visited connection as no longer "dirty" so they will + not be deleted at end of sync processing. + """ + services_data = self.agent_rpc.get_vpn_services_on_host(context, + self.host) + LOG.debug("Sync updating for %d VPN services", len(services_data)) + vpn_services = [] + for service_data in services_data: + vpn_service = self.update_service(context, service_data) + if vpn_service: + vpn_services.append(vpn_service) + return vpn_services + + def mark_existing_connections_as_dirty(self): + """Mark all existing connections as "dirty" for sync.""" + service_count = 0 + connection_count = 0 + for service_state in self.service_state.values(): + service_state.is_dirty = True + service_count += 1 + for conn_id in service_state.conn_state: + service_state.conn_state[conn_id].is_dirty = True + connection_count += 1 + LOG.debug(_("Mark: %(service)d VPN services and %(conn)d IPSec " + "connections marked dirty"), {'service': service_count, + 'conn': connection_count}) + + def remove_unknown_connections(self, context): + """Remove connections that are not known by service driver.""" + service_count = 0 + connection_count = 0 + for vpn_service_id, vpn_service in self.service_state.items(): + dirty = [c_id for c_id, c in vpn_service.conn_state.items() + if c.is_dirty] + for conn_id in dirty: + conn_state = vpn_service.conn_state[conn_id] + conn_state.delete_ipsec_site_connection(context, conn_id) + connection_count += 1 + del vpn_service.conn_state[conn_id] + if vpn_service.is_dirty: + service_count += 1 + del self.service_state[vpn_service_id] + LOG.debug(_("Sweep: Removed %(service)d dirty VPN service%(splural)s " + "and %(conn)d dirty IPSec connection%(cplural)s"), + {'service': service_count, 'conn': connection_count, + 'splural': 's'[service_count == 1:], + 'cplural': 's'[connection_count == 1:]}) + + def build_report_for_connections_on(self, vpn_service): + """Create the report fragment for IPSec connections on a service. + + Collect the current status from the Cisco CSR and use that to update + the status and generate report fragment for each connection on the + service. If there is no status information, or no change, then no + report info will be created for the connection. The combined report + data is returned. + """ + LOG.debug(_("Report: Collecting status for IPSec connections on VPN " + "service %s"), vpn_service.service_id) + tunnels = vpn_service.get_ipsec_connections_status() + report = {} + for connection in vpn_service.conn_state.values(): + current_status = connection.find_current_status_in(tunnels) + frag = connection.build_report_based_on_status(current_status) + if frag: + LOG.debug(_("Report: Adding info for IPSec connection %s"), + connection.conn_id) + report.update(frag) + return report + + def build_report_for_service(self, vpn_service): + """Create the report info for a VPN service and its IPSec connections. + + Get the report info for the connections on the service, and include + it into the report info for the VPN service. If there is no report + info for the connection, then no change has occurred and no report + will be generated. If there is only one connection for the service, + we'll set the service state to match the connection (with ERROR seen + as DOWN). + """ + conn_report = self.build_report_for_connections_on(vpn_service) + if conn_report: + pending_handled = plugin_utils.in_pending_status( + vpn_service.last_status) + if (len(conn_report) == 1 and + conn_report.values()[0]['status'] != constants.ACTIVE): + vpn_service.last_status = constants.DOWN + else: + vpn_service.last_status = constants.ACTIVE + LOG.debug(_("Report: Adding info for VPN service %s"), + vpn_service.service_id) + return {u'id': vpn_service.service_id, + u'status': vpn_service.last_status, + u'updated_pending_status': pending_handled, + u'ipsec_site_connections': conn_report} + else: + return {} + + def report_status(self, context): + """Report status of all VPN services and IPSec connections to plugin. + + This is called periodically by the agent, to push up status changes, + and at the end of any sync operation to reflect the changes due to a + sync or change notification. + """ + service_report = [] + LOG.debug(_("Report: Starting status report")) + for vpn_service_id, vpn_service in self.service_state.items(): + LOG.debug(_("Report: Collecting status for VPN service %s"), + vpn_service_id) + report = self.build_report_for_service(vpn_service) + if report: + service_report.append(report) + if service_report: + LOG.info(_("Sending status report update to plugin")) + self.agent_rpc.update_status(context, service_report) + LOG.debug(_("Report: Completed status report processing")) + return service_report + + @lockutils.synchronized('vpn-agent', 'neutron-') + def sync(self, context, routers): + """Synchronize with plugin and report current status. + + Mark all "known" services/connections as dirty, update them based on + information from the plugin, remove (sweep) any connections that are + not updated (dirty), and report updates, if any, back to plugin. + Called when update/delete a service or create/update/delete a + connection (vpnservice_updated message), or router change + (_process_routers). + """ + self.mark_existing_connections_as_dirty() + self.update_all_services_and_connections(context) + self.remove_unknown_connections(context) + self.report_status(context) + + def create_router(self, process_id): + """Actions taken when router created.""" + # Note: Since Cisco CSR is running out-of-band, nothing to do here + pass + + def destroy_router(self, process_id): + """Actions taken when router deleted.""" + # Note: Since Cisco CSR is running out-of-band, nothing to do here + pass + + +class CiscoCsrVpnService(object): + + """Maintains state/status information for a service and its connections.""" + + def __init__(self, service_data, csr): + self.service_id = service_data['id'] + self.last_status = service_data['status'] + self.conn_state = {} + self.is_dirty = False + self.csr = csr + # TODO(pcm) FUTURE - handle sharing of policies + + def create_connection(self, conn_data): + conn_id = conn_data['id'] + self.conn_state[conn_id] = CiscoCsrIPSecConnection(conn_data, self.csr) + return self.conn_state[conn_id] + + def get_connection(self, conn_id): + return self.conn_state.get(conn_id) + + def conn_status(self, conn_id): + conn_state = self.get_connection(conn_id) + if conn_state: + return conn_state.last_status + + def snapshot_conn_state(self, ipsec_conn): + """Create/obtain connection state and save current status.""" + conn_state = self.conn_state.setdefault( + ipsec_conn['id'], CiscoCsrIPSecConnection(ipsec_conn, self.csr)) + conn_state.last_status = ipsec_conn['status'] + conn_state.is_dirty = False + return conn_state + + STATUS_MAP = {'ERROR': constants.ERROR, + 'UP-ACTIVE': constants.ACTIVE, + 'UP-IDLE': constants.ACTIVE, + 'UP-NO-IKE': constants.ACTIVE, + 'DOWN': constants.DOWN, + 'DOWN-NEGOTIATING': constants.DOWN} + + def get_ipsec_connections_status(self): + """Obtain current status of all tunnels on a Cisco CSR. + + Convert them to OpenStack status values. + """ + tunnels = self.csr.read_tunnel_statuses() + for tunnel in tunnels: + LOG.debug("CSR Reports %(tunnel)s status '%(status)s'", + {'tunnel': tunnel[0], 'status': tunnel[1]}) + return dict(map(lambda x: (x[0], self.STATUS_MAP[x[1]]), tunnels)) + + def find_matching_connection(self, tunnel_id): + """Find IPSec connection using Cisco CSR tunnel specified, if any.""" + for connection in self.conn_state.values(): + if connection.tunnel == tunnel_id: + return connection.conn_id + + +class CiscoCsrIPSecConnection(object): + + """State and actions for IPSec site-to-site connections.""" + + def __init__(self, conn_info, csr): + self.conn_id = conn_info['id'] + self.csr = csr + self.steps = [] + self.is_dirty = False + self.last_status = conn_info['status'] + self.tunnel = conn_info['cisco']['site_conn_id'] + + def find_current_status_in(self, statuses): + if self.tunnel in statuses: + return statuses[self.tunnel] + else: + return constants.ERROR + + def build_report_based_on_status(self, current_status): + if current_status != self.last_status: + pending_handled = plugin_utils.in_pending_status(self.last_status) + self.last_status = current_status + return {self.conn_id: {'status': current_status, + 'updated_pending_status': pending_handled}} + else: + return {} + + DIALECT_MAP = {'ike_policy': {'name': 'IKE Policy', + 'v1': u'v1', + # auth_algorithm -> hash + 'sha1': u'sha', + # encryption_algorithm -> encryption + '3des': u'3des', + 'aes-128': u'aes', + # TODO(pcm) update these 2 once CSR updated + 'aes-192': u'aes', + 'aes-256': u'aes', + # pfs -> dhGroup + 'group2': 2, + 'group5': 5, + 'group14': 14}, + 'ipsec_policy': {'name': 'IPSec Policy', + # auth_algorithm -> esp-authentication + 'sha1': u'esp-sha-hmac', + # transform_protocol -> ah + 'esp': None, + 'ah': u'ah-sha-hmac', + 'ah-esp': u'ah-sha-hmac', + # encryption_algorithm -> esp-encryption + '3des': u'esp-3des', + 'aes-128': u'esp-aes', + # TODO(pcm) update these 2 once CSR updated + 'aes-192': u'esp-aes', + 'aes-256': u'esp-aes', + # pfs -> pfs + 'group2': u'group2', + 'group5': u'group5', + 'group14': u'group14'}} + + def translate_dialect(self, resource, attribute, info): + """Map VPNaaS attributes values to CSR values for a resource.""" + name = self.DIALECT_MAP[resource]['name'] + if attribute not in info: + raise CsrDriverMismatchError(resource=name, attr=attribute) + value = info[attribute].lower() + if value in self.DIALECT_MAP[resource]: + return self.DIALECT_MAP[resource][value] + raise CsrUnknownMappingError(resource=name, attr=attribute, + value=value) + + def create_psk_info(self, psk_id, conn_info): + """Collect/create attributes needed for pre-shared key.""" + return {u'keyring-name': psk_id, + u'pre-shared-key-list': [ + {u'key': conn_info['psk'], + u'encrypted': False, + u'peer-address': conn_info['peer_address']}]} + + def create_ike_policy_info(self, ike_policy_id, conn_info): + """Collect/create/map attributes needed for IKE policy.""" + for_ike = 'ike_policy' + policy_info = conn_info[for_ike] + version = self.translate_dialect(for_ike, + 'ike_version', + policy_info) + encrypt_algorithm = self.translate_dialect(for_ike, + 'encryption_algorithm', + policy_info) + auth_algorithm = self.translate_dialect(for_ike, + 'auth_algorithm', + policy_info) + group = self.translate_dialect(for_ike, + 'pfs', + policy_info) + lifetime = policy_info['lifetime_value'] + return {u'version': version, + u'priority-id': ike_policy_id, + u'encryption': encrypt_algorithm, + u'hash': auth_algorithm, + u'dhGroup': group, + u'lifetime': lifetime} + + def create_ipsec_policy_info(self, ipsec_policy_id, info): + """Collect/create attributes needed for IPSec policy. + + Note: OpenStack will provide a default encryption algorithm, if one is + not provided, so a authentication only configuration of (ah, sha1), + which maps to ah-sha-hmac transform protocol, cannot be selected. + As a result, we'll always configure the encryption algorithm, and + will select ah-sha-hmac for transform protocol. + """ + + for_ipsec = 'ipsec_policy' + policy_info = info[for_ipsec] + transform_protocol = self.translate_dialect(for_ipsec, + 'transform_protocol', + policy_info) + auth_algorithm = self.translate_dialect(for_ipsec, + 'auth_algorithm', + policy_info) + encrypt_algorithm = self.translate_dialect(for_ipsec, + 'encryption_algorithm', + policy_info) + group = self.translate_dialect(for_ipsec, 'pfs', policy_info) + lifetime = policy_info['lifetime_value'] + settings = {u'policy-id': ipsec_policy_id, + u'protection-suite': { + u'esp-encryption': encrypt_algorithm, + u'esp-authentication': auth_algorithm}, + u'lifetime-sec': lifetime, + u'pfs': group, + # TODO(pcm): Remove when CSR fixes 'Disable' + u'anti-replay-window-size': u'64'} + if transform_protocol: + settings[u'protection-suite'][u'ah'] = transform_protocol + return settings + + def create_site_connection_info(self, site_conn_id, ipsec_policy_id, + conn_info): + """Collect/create attributes needed for the IPSec connection.""" + # TODO(pcm) Enable, once CSR is embedded as a Neutron router + # gw_ip = vpnservice['external_ip'] (need to pass in) + mtu = conn_info['mtu'] + return { + u'vpn-interface-name': site_conn_id, + u'ipsec-policy-id': ipsec_policy_id, + u'local-device': { + # TODO(pcm): FUTURE - Get CSR port of interface with + # local subnet + u'ip-address': u'GigabitEthernet3', + # TODO(pcm): FUTURE - Get IP address of router's public + # I/F, once CSR is used as embedded router. + u'tunnel-ip-address': u'172.24.4.23' + # u'tunnel-ip-address': u'%s' % gw_ip + }, + u'remote-device': { + u'tunnel-ip-address': conn_info['peer_address'] + }, + u'mtu': mtu + } + + def create_routes_info(self, site_conn_id, conn_info): + """Collect/create attributes for static routes.""" + routes_info = [] + for peer_cidr in conn_info.get('peer_cidrs', []): + route = {u'destination-network': peer_cidr, + u'outgoing-interface': site_conn_id} + route_id = csr_client.make_route_id(peer_cidr, site_conn_id) + routes_info.append((route_id, route)) + return routes_info + + def _check_create(self, resource, which): + """Determine if REST create request was successful.""" + if self.csr.status == httplib.CREATED: + LOG.debug("%(resource)s %(which)s is configured", + {'resource': resource, 'which': which}) + return + LOG.error(_("Unable to create %(resource)s %(which)s: " + "%(status)d"), + {'resource': resource, 'which': which, + 'status': self.csr.status}) + # ToDO(pcm): Set state to error + raise CsrResourceCreateFailure(resource=resource, which=which) + + def do_create_action(self, action_suffix, info, resource_id, title): + """Perform a single REST step for IPSec site connection create.""" + create_action = 'create_%s' % action_suffix + try: + getattr(self.csr, create_action)(info) + except AttributeError: + LOG.exception(_("Internal error - '%s' is not defined"), + create_action) + raise CsrResourceCreateFailure(resource=title, + which=resource_id) + self._check_create(title, resource_id) + self.steps.append(RollbackStep(action_suffix, resource_id, title)) + + def _verify_deleted(self, status, resource, which): + """Determine if REST delete request was successful.""" + if status in (httplib.NO_CONTENT, httplib.NOT_FOUND): + LOG.debug("%(resource)s configuration %(which)s was removed", + {'resource': resource, 'which': which}) + else: + LOG.warning(_("Unable to delete %(resource)s %(which)s: " + "%(status)d"), {'resource': resource, + 'which': which, + 'status': status}) + + def do_rollback(self): + """Undo create steps that were completed successfully.""" + for step in reversed(self.steps): + delete_action = 'delete_%s' % step.action + LOG.debug(_("Performing rollback action %(action)s for " + "resource %(resource)s"), {'action': delete_action, + 'resource': step.title}) + try: + getattr(self.csr, delete_action)(step.resource_id) + except AttributeError: + LOG.exception(_("Internal error - '%s' is not defined"), + delete_action) + raise CsrResourceCreateFailure(resource=step.title, + which=step.resource_id) + self._verify_deleted(self.csr.status, step.title, step.resource_id) + self.steps = [] + + def create_ipsec_site_connection(self, context, conn_info): + """Creates an IPSec site-to-site connection on CSR. + + Create the PSK, IKE policy, IPSec policy, connection, static route, + and (future) DPD. + """ + # Get all the IDs + conn_id = conn_info['id'] + psk_id = conn_id + site_conn_id = conn_info['cisco']['site_conn_id'] + ike_policy_id = conn_info['cisco']['ike_policy_id'] + ipsec_policy_id = conn_info['cisco']['ipsec_policy_id'] + + LOG.debug(_('Creating IPSec connection %s'), conn_id) + # Get all the attributes needed to create + try: + psk_info = self.create_psk_info(psk_id, conn_info) + ike_policy_info = self.create_ike_policy_info(ike_policy_id, + conn_info) + ipsec_policy_info = self.create_ipsec_policy_info(ipsec_policy_id, + conn_info) + connection_info = self.create_site_connection_info(site_conn_id, + ipsec_policy_id, + conn_info) + routes_info = self.create_routes_info(site_conn_id, conn_info) + except (CsrUnknownMappingError, CsrDriverMismatchError) as e: + LOG.exception(e) + return + + try: + self.do_create_action('pre_shared_key', psk_info, + conn_id, 'Pre-Shared Key') + self.do_create_action('ike_policy', ike_policy_info, + ike_policy_id, 'IKE Policy') + self.do_create_action('ipsec_policy', ipsec_policy_info, + ipsec_policy_id, 'IPSec Policy') + self.do_create_action('ipsec_connection', connection_info, + site_conn_id, 'IPSec Connection') + + # TODO(pcm): FUTURE - Do DPD for v1 and handle if >1 connection + # and different DPD settings + for route_id, route_info in routes_info: + self.do_create_action('static_route', route_info, + route_id, 'Static Route') + except CsrResourceCreateFailure: + self.do_rollback() + LOG.info(_("FAILED: Create of IPSec site-to-site connection %s"), + conn_id) + else: + LOG.info(_("SUCCESS: Created IPSec site-to-site connection %s"), + conn_id) + + def delete_ipsec_site_connection(self, context, conn_id): + """Delete the site-to-site IPSec connection. + + This will be best effort and will continue, if there are any + failures. + """ + LOG.debug(_('Deleting IPSec connection %s'), conn_id) + if not self.steps: + LOG.warning(_('Unable to find connection %s'), conn_id) + else: + self.do_rollback() + + LOG.info(_("SUCCESS: Deleted IPSec site-to-site connection %s"), + conn_id) diff --git a/neutron/services/vpn/service_drivers/cisco_csr_db.py b/neutron/services/vpn/service_drivers/cisco_csr_db.py index 3df2fb036..e1f0760cd 100644 --- a/neutron/services/vpn/service_drivers/cisco_csr_db.py +++ b/neutron/services/vpn/service_drivers/cisco_csr_db.py @@ -11,6 +11,8 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +# +# @author: Paul Michali, Cisco Systems, Inc. import sqlalchemy as sa from sqlalchemy.orm import exc as sql_exc @@ -105,8 +107,9 @@ def get_next_available_ipsec_policy_id(session): def find_conn_with_policy(policy_field, policy_id, conn_id, session): """Return ID of another conneciton (if any) that uses same policy ID.""" qry = session.query(vpn_db.IPsecSiteConnection.id) - match = qry.filter(policy_field == policy_id, - vpn_db.IPsecSiteConnection.id != conn_id).first() + match = qry.filter_request( + policy_field == policy_id, + vpn_db.IPsecSiteConnection.id != conn_id).first() if match: return match[0] @@ -215,6 +218,7 @@ def create_tunnel_mapping(context, conn_info): csr_ipsec_policy_id=csr_ipsec_id) try: context.session.add(map_entry) + # Force committing to database context.session.flush() except db_exc.DBDuplicateEntry: msg = _("Attempt to create duplicate entry in Cisco CSR " diff --git a/neutron/tests/unit/services/vpn/device_drivers/cisco_csr_mock.py b/neutron/tests/unit/services/vpn/device_drivers/cisco_csr_mock.py new file mode 100644 index 000000000..383030303 --- /dev/null +++ b/neutron/tests/unit/services/vpn/device_drivers/cisco_csr_mock.py @@ -0,0 +1,538 @@ +# Copyright 2014 Cisco Systems, Inc. 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 +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# @author: Paul Michali, Cisco Systems, Inc. + +"""Mock REST requests to Cisco Cloud Services Router.""" + +import re + +from functools import wraps +# import httmock +import requests +from requests import exceptions as r_exc + +from neutron.openstack.common import log as logging +# TODO(pcm) Remove once httmock package is added to test-requirements. For +# now, uncomment and include httmock source to UT +from neutron.tests.unit.services.vpn.device_drivers import httmock + +# TODO(pcm) Remove, once verified these have been fixed +FIXED_CSCum50512 = False +FIXED_CSCum35484 = False +FIXED_CSCul82396 = False +FIXED_CSCum10324 = False + +LOG = logging.getLogger(__name__) + + +def repeat(n): + """Decorator to limit the number of times a handler is called. + + Will allow the wrapped function (handler) to be called 'n' times. + After that, this will return None for any additional calls, + allowing other handlers, if any, to be invoked. + """ + + class static: + retries = n + + def decorator(func): + @wraps(func) + def wrapped(*args, **kwargs): + if static.retries == 0: + return None + static.retries -= 1 + return func(*args, **kwargs) + return wrapped + return decorator + + +def filter_request(methods, resource): + """Decorator to invoke handler once for a specific resource. + + This will call the handler only for a specific resource using + a specific method(s). Any other resource request or method will + return None, allowing other handlers, if any, to be invoked. + """ + + class static: + target_methods = [m.upper() for m in methods] + target_resource = resource + + def decorator(func): + @wraps(func) + def wrapped(*args, **kwargs): + if (args[1].method in static.target_methods and + static.target_resource in args[0].path): + return func(*args, **kwargs) + else: + return None # Not for this resource + return wrapped + return decorator + + +@httmock.urlmatch(netloc=r'localhost') +def token(url, request): + if 'auth/token-services' in url.path: + return {'status_code': requests.codes.OK, + 'content': {'token-id': 'dummy-token'}} + + +@httmock.urlmatch(netloc=r'localhost') +def token_unauthorized(url, request): + if 'auth/token-services' in url.path: + return {'status_code': requests.codes.UNAUTHORIZED} + + +@httmock.urlmatch(netloc=r'wrong-host') +def token_wrong_host(url, request): + raise r_exc.ConnectionError() + + +@httmock.all_requests +def token_timeout(url, request): + raise r_exc.Timeout() + + +@filter_request(['get'], 'global/host-name') +@httmock.all_requests +def timeout(url, request): + """Simulated timeout of a normal request.""" + + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + raise r_exc.Timeout() + + +@httmock.urlmatch(netloc=r'localhost') +def no_such_resource(url, request): + """Indicate not found error, when invalid resource requested.""" + return {'status_code': requests.codes.NOT_FOUND} + + +@filter_request(['get'], 'global/host-name') +@repeat(1) +@httmock.urlmatch(netloc=r'localhost') +def expired_request(url, request): + """Simulate access denied failure on first request for this resource. + + Intent here is to simulate that the token has expired, by failing + the first request to the resource. Because of the repeat=1, this + will only be called once, and subsequent calls will not be handled + by this function, but instead will access the normal handler and + will pass. Currently configured for a GET request, but will work + with POST and PUT as well. For DELETE, would need to filter_request on a + different resource (e.g. 'global/local-users') + """ + + return {'status_code': requests.codes.UNAUTHORIZED} + + +@httmock.urlmatch(netloc=r'localhost') +def normal_get(url, request): + if request.method != 'GET': + return + LOG.debug("DEBUG: GET mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + if 'global/host-name' in url.path: + content = {u'kind': u'object#host-name', + u'host-name': u'Router'} + return httmock.response(requests.codes.OK, content=content) + if 'global/local-users' in url.path: + content = {u'kind': u'collection#local-user', + u'users': ['peter', 'paul', 'mary']} + return httmock.response(requests.codes.OK, content=content) + if 'interfaces/GigabitEthernet' in url.path: + actual_interface = url.path.split('/')[-1] + ip = actual_interface[-1] + content = {u'kind': u'object#interface', + u'description': u'Changed description', + u'if-name': actual_interface, + u'proxy-arp': True, + u'subnet-mask': u'255.255.255.0', + u'icmp-unreachable': True, + u'nat-direction': u'', + u'icmp-redirects': True, + u'ip-address': u'192.168.200.%s' % ip, + u'verify-unicast-source': False, + u'type': u'ethernet'} + return httmock.response(requests.codes.OK, content=content) + if 'vpn-svc/ike/policies/2' in url.path: + content = {u'kind': u'object#ike-policy', + u'priority-id': u'2', + u'version': u'v1', + u'local-auth-method': u'pre-share', + u'encryption': u'aes', + u'hash': u'sha', + u'dhGroup': 5, + u'lifetime': 3600} + return httmock.response(requests.codes.OK, content=content) + if 'vpn-svc/ike/keyrings' in url.path: + content = {u'kind': u'object#ike-keyring', + u'keyring-name': u'5', + u'pre-shared-key-list': [ + {u'key': u'super-secret', + u'encrypted': False, + u'peer-address': u'10.10.10.20 255.255.255.0'} + ]} + return httmock.response(requests.codes.OK, content=content) + if 'vpn-svc/ipsec/policies/' in url.path: + ipsec_policy_id = url.path.split('/')[-1] + content = {u'kind': u'object#ipsec-policy', + u'mode': u'tunnel', + u'policy-id': u'%s' % ipsec_policy_id, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + u'ah': u'ah-sha-hmac', + }, + u'anti-replay-window-size': u'128', + u'lifetime-sec': 120, + u'pfs': u'group5', + u'lifetime-kb': 4608000, + u'idle-time': None} + return httmock.response(requests.codes.OK, content=content) + if 'vpn-svc/site-to-site/Tunnel' in url.path: + tunnel = url.path.split('/')[-1] + # Use same number, to allow mock to generate IPSec policy ID + ipsec_policy_id = tunnel[6:] + content = {u'kind': u'object#vpn-site-to-site', + u'vpn-interface-name': u'%s' % tunnel, + u'ip-version': u'ipv4', + u'vpn-type': u'site-to-site', + u'ipsec-policy-id': u'%s' % ipsec_policy_id, + u'ike-profile-id': None, + u'mtu': 1500, + u'local-device': { + u'ip-address': '10.3.0.1/24', + u'tunnel-ip-address': '10.10.10.10' + }, + u'remote-device': { + u'tunnel-ip-address': '10.10.10.20' + }} + return httmock.response(requests.codes.OK, content=content) + if 'vpn-svc/ike/keepalive' in url.path: + content = {u'interval': 60, + u'retry': 4, + u'periodic': True} + return httmock.response(requests.codes.OK, content=content) + if 'routing-svc/static-routes' in url.path: + content = {u'destination-network': u'10.1.0.0/24', + u'kind': u'object#static-route', + u'next-hop-router': None, + u'outgoing-interface': u'GigabitEthernet1', + u'admin-distance': 1} + return httmock.response(requests.codes.OK, content=content) + if 'vpn-svc/site-to-site/active/sessions': + # Only including needed fields for mock + content = {u'kind': u'collection#vpn-active-sessions', + u'items': [{u'status': u'DOWN-NEGOTIATING', + u'vpn-interface-name': u'Tunnel123'}, ]} + return httmock.response(requests.codes.OK, content=content) + + +@filter_request(['get'], 'vpn-svc/ike/keyrings') +@httmock.urlmatch(netloc=r'localhost') +def get_fqdn(url, request): + LOG.debug("DEBUG: GET FQDN mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + content = {u'kind': u'object#ike-keyring', + u'keyring-name': u'5', + u'pre-shared-key-list': [ + {u'key': u'super-secret', + u'encrypted': False, + u'peer-address': u'cisco.com'} + ]} + return httmock.response(requests.codes.OK, content=content) + + +@filter_request(['get'], 'vpn-svc/ipsec/policies/') +@httmock.urlmatch(netloc=r'localhost') +def get_no_ah(url, request): + LOG.debug("DEBUG: GET No AH mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + ipsec_policy_id = url.path.split('/')[-1] + content = {u'kind': u'object#ipsec-policy', + u'mode': u'tunnel', + u'anti-replay-window-size': u'128', + u'policy-id': u'%s' % ipsec_policy_id, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + }, + u'lifetime-sec': 120, + u'pfs': u'group5', + u'lifetime-kb': 4608000, + u'idle-time': None} + return httmock.response(requests.codes.OK, content=content) + + +@httmock.urlmatch(netloc=r'localhost') +def get_defaults(url, request): + if request.method != 'GET': + return + LOG.debug("DEBUG: GET mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + if 'vpn-svc/ike/policies/2' in url.path: + content = {u'kind': u'object#ike-policy', + u'priority-id': u'2', + u'version': u'v1', + u'local-auth-method': u'pre-share', + u'encryption': u'des', + u'hash': u'sha', + u'dhGroup': 1, + u'lifetime': 86400} + return httmock.response(requests.codes.OK, content=content) + if 'vpn-svc/ipsec/policies/' in url.path: + ipsec_policy_id = url.path.split('/')[-1] + content = {u'kind': u'object#ipsec-policy', + u'mode': u'tunnel', + u'policy-id': u'%s' % ipsec_policy_id, + u'protection-suite': {}, + u'lifetime-sec': 3600, + u'pfs': u'Disable', + u'anti-replay-window-size': u'None', + u'lifetime-kb': 4608000, + u'idle-time': None} + return httmock.response(requests.codes.OK, content=content) + + +@filter_request(['get'], 'vpn-svc/site-to-site') +@httmock.urlmatch(netloc=r'localhost') +def get_unnumbered(url, request): + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + if FIXED_CSCum50512: + tunnel = url.path.split('/')[-1] + ipsec_policy_id = tunnel[6:] + content = {u'kind': u'object#vpn-site-to-site', + u'vpn-interface-name': u'%s' % tunnel, + u'ip-version': u'ipv4', + u'vpn-type': u'site-to-site', + u'ipsec-policy-id': u'%s' % ipsec_policy_id, + u'ike-profile-id': None, + u'mtu': 1500, + u'local-device': { + u'ip-address': u'unnumbered GigabitEthernet3', + u'tunnel-ip-address': u'10.10.10.10' + }, + u'remote-device': { + u'tunnel-ip-address': u'10.10.10.20' + }} + return httmock.response(requests.codes.OK, content=content) + else: + return httmock.response(requests.codes.INTERNAL_SERVER_ERROR) + + +@filter_request(['get'], 'vpn-svc/site-to-site') +@httmock.urlmatch(netloc=r'localhost') +def get_mtu(url, request): + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + tunnel = url.path.split('/')[-1] + ipsec_policy_id = tunnel[6:] + content = {u'kind': u'object#vpn-site-to-site', + u'vpn-interface-name': u'%s' % tunnel, + u'ip-version': u'ipv4', + u'vpn-type': u'site-to-site', + u'ipsec-policy-id': u'%s' % ipsec_policy_id, + u'ike-profile-id': None, + u'mtu': 9192, + u'local-device': { + u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10' + }, + u'remote-device': { + u'tunnel-ip-address': u'10.10.10.20' + }} + return httmock.response(requests.codes.OK, content=content) + + +@filter_request(['get'], 'vpn-svc/ike/keepalive') +@httmock.urlmatch(netloc=r'localhost') +def get_not_configured(url, request): + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.NOT_FOUND} + + +@filter_request(['get'], 'vpn-svc/site-to-site/active/sessions') +@httmock.urlmatch(netloc=r'localhost') +def get_none(url, request): + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + content = {u'kind': u'collection#vpn-active-sessions', + u'items': []} + return httmock.response(requests.codes.OK, content=content) + + +@httmock.urlmatch(netloc=r'localhost') +def post(url, request): + if request.method != 'POST': + return + LOG.debug("DEBUG: POST mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + if 'interfaces/GigabitEthernet' in url.path: + return {'status_code': requests.codes.NO_CONTENT} + if 'global/local-users' in url.path: + if 'username' not in request.body: + return {'status_code': requests.codes.BAD_REQUEST} + if '"privilege": 20' in request.body: + return {'status_code': requests.codes.BAD_REQUEST} + headers = {'location': '%s/test-user' % url.geturl()} + return httmock.response(requests.codes.CREATED, headers=headers) + if 'vpn-svc/ike/policies' in url.path: + headers = {'location': "%s/2" % url.geturl()} + return httmock.response(requests.codes.CREATED, headers=headers) + if 'vpn-svc/ipsec/policies' in url.path: + m = re.search(r'"policy-id": "(\S+)"', request.body) + if m: + headers = {'location': "%s/%s" % (url.geturl(), m.group(1))} + return httmock.response(requests.codes.CREATED, headers=headers) + return {'status_code': requests.codes.BAD_REQUEST} + if 'vpn-svc/ike/keyrings' in url.path: + headers = {'location': "%s/5" % url.geturl()} + return httmock.response(requests.codes.CREATED, headers=headers) + if 'vpn-svc/site-to-site' in url.path: + m = re.search(r'"vpn-interface-name": "(\S+)"', request.body) + if m: + headers = {'location': "%s/%s" % (url.geturl(), m.group(1))} + return httmock.response(requests.codes.CREATED, headers=headers) + return {'status_code': requests.codes.BAD_REQUEST} + if 'routing-svc/static-routes' in url.path: + headers = {'location': + "%s/10.1.0.0_24_GigabitEthernet1" % url.geturl()} + return httmock.response(requests.codes.CREATED, headers=headers) + + +@filter_request(['post'], 'global/local-users') +@httmock.urlmatch(netloc=r'localhost') +def post_change_attempt(url, request): + LOG.debug("DEBUG: POST change value mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.NOT_FOUND, + 'content': { + u'error-code': -1, + u'error-message': u'user test-user already exists'}} + + +@httmock.urlmatch(netloc=r'localhost') +def post_duplicate(url, request): + LOG.debug("DEBUG: POST duplicate mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.BAD_REQUEST, + 'content': { + u'error-code': -1, + u'error-message': u'policy 2 exist, not allow to ' + u'update policy using POST method'}} + + +@filter_request(['post'], 'vpn-svc/site-to-site') +@httmock.urlmatch(netloc=r'localhost') +def post_missing_ipsec_policy(url, request): + LOG.debug("DEBUG: POST missing ipsec policy mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.BAD_REQUEST} + + +@filter_request(['post'], 'vpn-svc/site-to-site') +@httmock.urlmatch(netloc=r'localhost') +def post_missing_ike_policy(url, request): + LOG.debug("DEBUG: POST missing ike policy mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.BAD_REQUEST} + + +@filter_request(['post'], 'vpn-svc/site-to-site') +@httmock.urlmatch(netloc=r'localhost') +def post_bad_ip(url, request): + LOG.debug("DEBUG: POST bad IP mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.BAD_REQUEST} + + +@filter_request(['post'], 'vpn-svc/site-to-site') +@httmock.urlmatch(netloc=r'localhost') +def post_bad_mtu(url, request): + LOG.debug("DEBUG: POST bad mtu mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.BAD_REQUEST} + + +@filter_request(['post'], 'vpn-svc/ipsec/policies') +@httmock.urlmatch(netloc=r'localhost') +def post_bad_lifetime(url, request): + LOG.debug("DEBUG: POST bad lifetime mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + return {'status_code': requests.codes.BAD_REQUEST} + + +@httmock.urlmatch(netloc=r'localhost') +def put(url, request): + if request.method != 'PUT': + return + LOG.debug("DEBUG: PUT mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + # Any resource + return {'status_code': requests.codes.NO_CONTENT} + + +@httmock.urlmatch(netloc=r'localhost') +def delete(url, request): + if request.method != 'DELETE': + return + LOG.debug("DEBUG: DELETE mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + # Any resource + return {'status_code': requests.codes.NO_CONTENT} + + +@httmock.urlmatch(netloc=r'localhost') +def delete_unknown(url, request): + if request.method != 'DELETE': + return + LOG.debug("DEBUG: DELETE unknown mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + # Any resource + return {'status_code': requests.codes.NOT_FOUND, + 'content': { + u'error-code': -1, + u'error-message': 'user unknown not found'}} + + +@httmock.urlmatch(netloc=r'localhost') +def delete_not_allowed(url, request): + if request.method != 'DELETE': + return + LOG.debug("DEBUG: DELETE not allowed mock for %s", url) + if not request.headers.get('X-auth-token', None): + return {'status_code': requests.codes.UNAUTHORIZED} + # Any resource + return {'status_code': requests.codes.METHOD_NOT_ALLOWED} diff --git a/neutron/tests/unit/services/vpn/device_drivers/notest_cisco_csr_rest.py b/neutron/tests/unit/services/vpn/device_drivers/notest_cisco_csr_rest.py new file mode 100644 index 000000000..920b9e403 --- /dev/null +++ b/neutron/tests/unit/services/vpn/device_drivers/notest_cisco_csr_rest.py @@ -0,0 +1,1206 @@ +# Copyright 2014 Cisco Systems, Inc. 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 +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# @author: Paul Michali, Cisco Systems, Inc. + +#TODO(pcm): Rename this file to remove the "no" prefix, once httmock is +# approved and added to requirements.txt + +import random + +import httmock +import requests + +from neutron.openstack.common import log as logging +from neutron.services.vpn.device_drivers import ( + cisco_csr_rest_client as csr_client) +from neutron.tests import base +from neutron.tests.unit.services.vpn.device_drivers import ( + cisco_csr_mock as csr_request) +# TODO(pcm) Remove once httmock is available. In the meantime, use temp +# copy of hhtmock source to run UT +# from neutron.tests.unit.services.vpn.device_drivers import httmock + + +LOG = logging.getLogger(__name__) +# Enables debug logging to console +if True: + logging.CONF.set_override('debug', True) + logging.setup('neutron') + +if csr_request.FIXED_CSCum35484: + dummy_uuid = '1eb4ee6b-0870-45a0-b554-7b69096' +else: + dummy_uuid = '1eb4ee6b-0870-45a0-b554-7b' + + +# Note: Helper functions to test reuse of IDs. +def generate_pre_shared_key_id(): + return random.randint(100, 200) + + +def generate_ike_policy_id(): + return random.randint(200, 300) + + +def generate_ipsec_policy_id(): + return random.randint(300, 400) + + +class TestCsrLoginRestApi(base.BaseTestCase): + + """Test logging into CSR to obtain token-id.""" + + def setUp(self): + super(TestCsrLoginRestApi, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_get_token(self): + """Obtain the token and its expiration time.""" + with httmock.HTTMock(csr_request.token): + self.assertTrue(self.csr.authenticate()) + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertIsNotNone(self.csr.token) + + def test_unauthorized_token_request(self): + """Negative test of invalid user/password.""" + self.csr.auth = ('stack', 'bogus') + with httmock.HTTMock(csr_request.token_unauthorized): + self.assertIsNone(self.csr.authenticate()) + self.assertEqual(requests.codes.UNAUTHORIZED, self.csr.status) + + def test_non_existent_host(self): + """Negative test of request to non-existent host.""" + self.csr.host = 'wrong-host' + self.csr.token = 'Set by some previously successful access' + with httmock.HTTMock(csr_request.token_wrong_host): + self.assertIsNone(self.csr.authenticate()) + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + self.assertIsNone(self.csr.token) + + def test_timeout_on_token_access(self): + """Negative test of a timeout on a request.""" + with httmock.HTTMock(csr_request.token_timeout): + self.assertIsNone(self.csr.authenticate()) + self.assertEqual(requests.codes.REQUEST_TIMEOUT, self.csr.status) + self.assertIsNone(self.csr.token) + + +class TestCsrGetRestApi(base.BaseTestCase): + + """Test CSR GET REST API.""" + + def setUp(self): + super(TestCsrGetRestApi, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_valid_rest_gets(self): + """Simple GET requests. + + First request will do a post to get token (login). Assumes + that there are two interfaces on the CSR. + """ + + with httmock.HTTMock(csr_request.token, csr_request.normal_get): + content = self.csr.get_request('global/host-name') + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertIn('host-name', content) + self.assertNotEqual(None, content['host-name']) + + content = self.csr.get_request('global/local-users') + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertIn('users', content) + + +class TestCsrPostRestApi(base.BaseTestCase): + + """Test CSR POST REST API.""" + + def setUp(self): + super(TestCsrPostRestApi, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_post_requests(self): + """Simple POST requests (repeatable). + + First request will do a post to get token (login). Assumes + that there are two interfaces (Ge1 and Ge2) on the CSR. + """ + + with httmock.HTTMock(csr_request.token, csr_request.post): + content = self.csr.post_request( + 'interfaces/GigabitEthernet1/statistics', + payload={'action': 'clear'}) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + self.assertIsNone(content) + content = self.csr.post_request( + 'interfaces/GigabitEthernet2/statistics', + payload={'action': 'clear'}) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + self.assertIsNone(content) + + def test_post_with_location(self): + """Create a user and verify that location returned.""" + with httmock.HTTMock(csr_request.token, csr_request.post): + location = self.csr.post_request( + 'global/local-users', + payload={'username': 'test-user', + 'password': 'pass12345', + 'privilege': 15}) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('global/local-users/test-user', location) + + def test_post_missing_required_attribute(self): + """Negative test of POST with missing mandatory info.""" + with httmock.HTTMock(csr_request.token, csr_request.post): + self.csr.post_request('global/local-users', + payload={'password': 'pass12345', + 'privilege': 15}) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + + def test_post_invalid_attribute(self): + """Negative test of POST with invalid info.""" + with httmock.HTTMock(csr_request.token, csr_request.post): + self.csr.post_request('global/local-users', + payload={'username': 'test-user', + 'password': 'pass12345', + 'privilege': 20}) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + + def test_post_already_exists(self): + """Negative test of a duplicate POST. + + Uses the lower level _do_request() API to just perform the POST and + obtain the response, without any error processing. + """ + with httmock.HTTMock(csr_request.token, csr_request.post): + location = self.csr._do_request( + 'POST', + 'global/local-users', + payload={'username': 'test-user', + 'password': 'pass12345', + 'privilege': 15}, + more_headers=csr_client.HEADER_CONTENT_TYPE_JSON) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('global/local-users/test-user', location) + with httmock.HTTMock(csr_request.token, + csr_request.post_change_attempt): + self.csr._do_request( + 'POST', + 'global/local-users', + payload={'username': 'test-user', + 'password': 'pass12345', + 'privilege': 15}, + more_headers=csr_client.HEADER_CONTENT_TYPE_JSON) + # Note: For local-user, a 404 error is returned. For + # site-to-site connection a 400 is returned. + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + + def test_post_changing_value(self): + """Negative test of a POST trying to change a value.""" + with httmock.HTTMock(csr_request.token, csr_request.post): + location = self.csr.post_request( + 'global/local-users', + payload={'username': 'test-user', + 'password': 'pass12345', + 'privilege': 15}) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('global/local-users/test-user', location) + with httmock.HTTMock(csr_request.token, + csr_request.post_change_attempt): + content = self.csr.post_request('global/local-users', + payload={'username': 'test-user', + 'password': 'changed', + 'privilege': 15}) + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + expected = {u'error-code': -1, + u'error-message': u'user test-user already exists'} + self.assertDictContainsSubset(expected, content) + + +class TestCsrPutRestApi(base.BaseTestCase): + + """Test CSR PUT REST API.""" + + def _save_resources(self): + with httmock.HTTMock(csr_request.token, csr_request.normal_get): + details = self.csr.get_request('global/host-name') + if self.csr.status != requests.codes.OK: + self.fail("Unable to save original host name") + self.original_host = details['host-name'] + details = self.csr.get_request('interfaces/GigabitEthernet1') + if self.csr.status != requests.codes.OK: + self.fail("Unable to save interface Ge1 description") + self.original_if = details + if details.get('description', ''): + self.original_if['description'] = '' + self.csr.token = None + + def _restore_resources(self, user, password): + """Restore the host name and itnerface description. + + Must restore the user and password, so that authentication + token can be obtained (as some tests corrupt auth info). + Will also clear token, so that it gets a fresh token. + """ + + self.csr.auth = (user, password) + self.csr.token = None + with httmock.HTTMock(csr_request.token, csr_request.put): + payload = {'host-name': self.original_host} + self.csr.put_request('global/host-name', payload=payload) + if self.csr.status != requests.codes.NO_CONTENT: + self.fail("Unable to restore host name after test") + payload = {'description': self.original_if['description'], + 'if-name': self.original_if['if-name'], + 'ip-address': self.original_if['ip-address'], + 'subnet-mask': self.original_if['subnet-mask'], + 'type': self.original_if['type']} + self.csr.put_request('interfaces/GigabitEthernet1', + payload=payload) + if self.csr.status != requests.codes.NO_CONTENT: + self.fail("Unable to restore I/F Ge1 description after test") + + def setUp(self): + """Prepare for PUT API tests.""" + super(TestCsrPutRestApi, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + self._save_resources() + self.addCleanup(self._restore_resources, 'stack', 'cisco') + + def test_put_requests(self): + """Simple PUT requests (repeatable). + + First request will do a post to get token (login). Assumes + that there are two interfaces on the CSR (Ge1 and Ge2). + """ + + with httmock.HTTMock(csr_request.token, csr_request.put, + csr_request.normal_get): + payload = {'host-name': 'TestHost'} + content = self.csr.put_request('global/host-name', + payload=payload) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + self.assertIsNone(content) + + payload = {'host-name': 'TestHost2'} + content = self.csr.put_request('global/host-name', + payload=payload) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + self.assertIsNone(content) + + def test_change_interface_description(self): + """Test that interface description can be changed. + + This was a problem with an earlier version of the CSR image and is + here to prevent regression. + """ + with httmock.HTTMock(csr_request.token, csr_request.put, + csr_request.normal_get): + payload = {'description': u'Changed description', + 'if-name': self.original_if['if-name'], + 'ip-address': self.original_if['ip-address'], + 'subnet-mask': self.original_if['subnet-mask'], + 'type': self.original_if['type']} + content = self.csr.put_request( + 'interfaces/GigabitEthernet1', payload=payload) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + self.assertIsNone(content) + content = self.csr.get_request('interfaces/GigabitEthernet1') + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertIn('description', content) + self.assertEqual(u'Changed description', + content['description']) + + def ignore_test_change_to_empty_interface_description(self): + """Test that interface description can be changed to empty string. + + This is a problem in the current version of the CSR image, which + rejects the change with a 400 error. This test is here to prevent + a regression (once it is fixed) Note that there is code in the + test setup to change the description to a non-empty string to + avoid failures in other tests. + """ + with httmock.HTTMock(csr_request.token, csr_request.put, + csr_request.normal_get): + payload = {'description': '', + 'if-name': self.original_if['if-name'], + 'ip-address': self.original_if['ip-address'], + 'subnet-mask': self.original_if['subnet-mask'], + 'type': self.original_if['type']} + content = self.csr.put_request( + 'interfaces/GigabitEthernet1', payload=payload) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + self.assertIsNone(content) + content = self.csr.get_request('interfaces/GigabitEthernet1') + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertIn('description', content) + self.assertEqual('', content['description']) + + +class TestCsrDeleteRestApi(base.BaseTestCase): + + """Test CSR DELETE REST API.""" + + def setUp(self): + super(TestCsrDeleteRestApi, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def _make_dummy_user(self): + """Create a user that will be later deleted.""" + self.csr.post_request('global/local-users', + payload={'username': 'dummy', + 'password': 'dummy', + 'privilege': 15}) + self.assertEqual(requests.codes.CREATED, self.csr.status) + + def test_delete_requests(self): + """Simple DELETE requests (creating entry first).""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.delete): + self._make_dummy_user() + self.csr.token = None # Force login + self.csr.delete_request('global/local-users/dummy') + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + # Delete again, but without logging in this time + self._make_dummy_user() + self.csr.delete_request('global/local-users/dummy') + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + + def test_delete_non_existent_entry(self): + """Negative test of trying to delete a non-existent user.""" + with httmock.HTTMock(csr_request.token, csr_request.delete_unknown): + content = self.csr.delete_request('global/local-users/unknown') + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + expected = {u'error-code': -1, + u'error-message': u'user unknown not found'} + self.assertDictContainsSubset(expected, content) + + def test_delete_not_allowed(self): + """Negative test of trying to delete the host-name.""" + with httmock.HTTMock(csr_request.token, + csr_request.delete_not_allowed): + self.csr.delete_request('global/host-name') + self.assertEqual(requests.codes.METHOD_NOT_ALLOWED, + self.csr.status) + + +class TestCsrRestApiFailures(base.BaseTestCase): + + """Test failure cases common for all REST APIs. + + Uses the lower level _do_request() to just perform the operation and get + the result, without any error handling. + """ + + def setUp(self): + super(TestCsrRestApiFailures, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco', timeout=0.1) + + def test_request_for_non_existent_resource(self): + """Negative test of non-existent resource on REST request.""" + with httmock.HTTMock(csr_request.token, csr_request.no_such_resource): + self.csr.post_request('no/such/request') + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + # The result is HTTP 404 message, so no error content to check + + def test_timeout_during_request(self): + """Negative test of timeout during REST request.""" + with httmock.HTTMock(csr_request.token, csr_request.timeout): + self.csr._do_request('GET', 'global/host-name') + self.assertEqual(requests.codes.REQUEST_TIMEOUT, self.csr.status) + + def test_token_expired_on_request(self): + """Token expired before trying a REST request. + + The mock is configured to return a 401 error on the first + attempt to reference the host name. Simulate expiration of + token by changing it. + """ + + with httmock.HTTMock(csr_request.token, csr_request.expired_request, + csr_request.normal_get): + self.csr.token = '123' # These are 44 characters, so won't match + content = self.csr._do_request('GET', 'global/host-name') + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertIn('host-name', content) + self.assertNotEqual(None, content['host-name']) + + def test_failed_to_obtain_token_for_request(self): + """Negative test of unauthorized user for REST request.""" + self.csr.auth = ('stack', 'bogus') + with httmock.HTTMock(csr_request.token_unauthorized): + self.csr._do_request('GET', 'global/host-name') + self.assertEqual(requests.codes.UNAUTHORIZED, self.csr.status) + + +class TestCsrRestIkePolicyCreate(base.BaseTestCase): + + """Test IKE policy create REST requests.""" + + def setUp(self): + super(TestCsrRestIkePolicyCreate, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_create_delete_ike_policy(self): + """Create and then delete IKE policy.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + policy_id = '2' + policy_info = {u'priority-id': u'%s' % policy_id, + u'encryption': u'aes', + u'hash': u'sha', + u'dhGroup': 5, + u'lifetime': 3600} + location = self.csr.create_ike_policy(policy_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ike/policies/%s' % policy_id, location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ike-policy', + u'version': u'v1', + u'local-auth-method': u'pre-share'} + expected_policy.update(policy_info) + self.assertEqual(expected_policy, content) + # Now delete and verify the IKE policy is gone + with httmock.HTTMock(csr_request.token, csr_request.delete, + csr_request.no_such_resource): + self.csr.delete_ike_policy(policy_id) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + + def test_create_ike_policy_with_defaults(self): + """Create IKE policy using defaults for all optional values.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.get_defaults): + policy_id = '2' + policy_info = {u'priority-id': u'%s' % policy_id} + location = self.csr.create_ike_policy(policy_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ike/policies/%s' % policy_id, location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ike-policy', + u'version': u'v1', + u'encryption': u'des', + u'hash': u'sha', + u'dhGroup': 1, + u'lifetime': 86400, + # Lower level sets this, but it is the default + u'local-auth-method': u'pre-share'} + expected_policy.update(policy_info) + self.assertEqual(expected_policy, content) + + def test_create_duplicate_ike_policy(self): + """Negative test of trying to create a duplicate IKE policy.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + policy_id = '2' + policy_info = {u'priority-id': u'%s' % policy_id, + u'encryption': u'aes', + u'hash': u'sha', + u'dhGroup': 5, + u'lifetime': 3600} + location = self.csr.create_ike_policy(policy_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ike/policies/%s' % policy_id, location) + with httmock.HTTMock(csr_request.token, csr_request.post_duplicate): + location = self.csr.create_ike_policy(policy_info) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + expected = {u'error-code': -1, + u'error-message': u'policy 2 exist, not allow to ' + u'update policy using POST method'} + self.assertDictContainsSubset(expected, location) + + +class TestCsrRestIPSecPolicyCreate(base.BaseTestCase): + + """Test IPSec policy create REST requests.""" + + def setUp(self): + super(TestCsrRestIPSecPolicyCreate, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_create_delete_ipsec_policy(self): + """Create and then delete IPSec policy.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + policy_id = '123' + policy_info = { + u'policy-id': u'%s' % policy_id, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + u'ah': u'ah-sha-hmac', + }, + u'lifetime-sec': 120, + u'pfs': u'group5', + u'anti-replay-window-size': u'128' + } + location = self.csr.create_ipsec_policy(policy_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ipsec/policies/%s' % policy_id, location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ipsec-policy', + u'mode': u'tunnel', + u'lifetime-kb': 4608000, + u'idle-time': None} + expected_policy.update(policy_info) + self.assertEqual(expected_policy, content) + # Now delete and verify the IPSec policy is gone + with httmock.HTTMock(csr_request.token, csr_request.delete, + csr_request.no_such_resource): + self.csr.delete_ipsec_policy(policy_id) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + + def test_create_ipsec_policy_with_defaults(self): + """Create IPSec policy with default for all optional values.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.get_defaults): + policy_id = '123' + policy_info = { + u'policy-id': u'%s' % policy_id, + } + location = self.csr.create_ipsec_policy(policy_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ipsec/policies/%s' % policy_id, location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ipsec-policy', + u'mode': u'tunnel', + u'protection-suite': {}, + u'lifetime-sec': 3600, + u'pfs': u'Disable', + u'anti-replay-window-size': u'None', + u'lifetime-kb': 4608000, + u'idle-time': None} + expected_policy.update(policy_info) + self.assertEqual(expected_policy, content) + + def test_create_ipsec_policy_with_uuid(self): + """Create IPSec policy using UUID for id.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + policy_info = { + u'policy-id': u'%s' % dummy_uuid, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + u'ah': u'ah-sha-hmac', + }, + u'lifetime-sec': 120, + u'pfs': u'group5', + u'anti-replay-window-size': u'128' + } + location = self.csr.create_ipsec_policy(policy_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ipsec/policies/%s' % dummy_uuid, + location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ipsec-policy', + u'mode': u'tunnel', + u'lifetime-kb': 4608000, + u'idle-time': None} + expected_policy.update(policy_info) + self.assertEqual(expected_policy, content) + + def test_create_ipsec_policy_without_ah(self): + """Create IPSec policy.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.get_no_ah): + policy_id = '10' + policy_info = { + u'policy-id': u'%s' % policy_id, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + }, + u'lifetime-sec': 120, + u'pfs': u'group5', + u'anti-replay-window-size': u'128' + } + location = self.csr.create_ipsec_policy(policy_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ipsec/policies/%s' % policy_id, location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ipsec-policy', + u'mode': u'tunnel', + u'lifetime-kb': 4608000, + u'idle-time': None} + expected_policy.update(policy_info) + self.assertEqual(expected_policy, content) + + def test_invalid_ipsec_policy_lifetime(self): + """Failure test of IPSec policy with unsupported lifetime.""" + with httmock.HTTMock(csr_request.token, + csr_request.post_bad_lifetime): + policy_id = '123' + policy_info = { + u'policy-id': u'%s' % policy_id, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + u'ah': u'ah-sha-hmac', + }, + u'lifetime-sec': 119, + u'pfs': u'group5', + u'anti-replay-window-size': u'128' + } + self.csr.create_ipsec_policy(policy_info) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + + +class TestCsrRestPreSharedKeyCreate(base.BaseTestCase): + + """Test Pre-shared key (PSK) create REST requests.""" + + def setUp(self): + super(TestCsrRestPreSharedKeyCreate, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_create_delete_pre_shared_key(self): + """Create and then delete a keyring entry for pre-shared key.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + psk_id = '5' + psk_info = {u'keyring-name': u'%s' % psk_id, + u'pre-shared-key-list': [ + {u'key': u'super-secret', + u'encrypted': False, + u'peer-address': u'10.10.10.20/24'} + ]} + location = self.csr.create_pre_shared_key(psk_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ike/keyrings/%s' % psk_id, location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ike-keyring'} + expected_policy.update(psk_info) + # Note: the peer CIDR is returned as an IP and mask + expected_policy[u'pre-shared-key-list'][0][u'peer-address'] = ( + u'10.10.10.20 255.255.255.0') + self.assertEqual(expected_policy, content) + # Now delete and verify pre-shared key is gone + with httmock.HTTMock(csr_request.token, csr_request.delete, + csr_request.no_such_resource): + self.csr.delete_pre_shared_key(psk_id) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + + def test_create_pre_shared_key_with_fqdn_peer(self): + """Create pre-shared key using FQDN for peer address.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.get_fqdn): + psk_id = '5' + psk_info = {u'keyring-name': u'%s' % psk_id, + u'pre-shared-key-list': [ + {u'key': u'super-secret', + u'encrypted': False, + u'peer-address': u'cisco.com'} + ]} + location = self.csr.create_pre_shared_key(psk_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ike/keyrings/%s' % psk_id, location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_policy = {u'kind': u'object#ike-keyring'} + expected_policy.update(psk_info) + self.assertEqual(expected_policy, content) + + def test_create_pre_shared_key_with_duplicate_peer_address(self): + """Negative test of creating a second pre-shared key with same peer.""" + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + psk_id = '5' + psk_info = {u'keyring-name': u'%s' % psk_id, + u'pre-shared-key-list': [ + {u'key': u'super-secret', + u'encrypted': False, + u'peer-address': u'10.10.10.20/24'} + ]} + location = self.csr.create_pre_shared_key(psk_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('vpn-svc/ike/keyrings/%s' % psk_id, location) + with httmock.HTTMock(csr_request.token, csr_request.post_duplicate): + psk_id = u'6' + another_psk_info = {u'keyring-name': psk_id, + u'pre-shared-key-list': [ + {u'key': u'abc123def', + u'encrypted': False, + u'peer-address': u'10.10.10.20/24'} + ]} + self.csr.create_ike_policy(another_psk_info) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + + +class TestCsrRestIPSecConnectionCreate(base.BaseTestCase): + + """Test IPSec site-to-site connection REST requests. + + This requires us to have first created an IKE policy, IPSec policy, + and pre-shared key, so it's more of an itegration test, when used + with a real CSR (as we can't mock out these pre-conditions. + """ + + def setUp(self): + super(TestCsrRestIPSecConnectionCreate, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def _make_psk_for_test(self): + psk_id = generate_pre_shared_key_id() + self._remove_resource_for_test(self.csr.delete_pre_shared_key, + psk_id) + with httmock.HTTMock(csr_request.token, csr_request.post): + psk_info = {u'keyring-name': u'%d' % psk_id, + u'pre-shared-key-list': [ + {u'key': u'super-secret', + u'encrypted': False, + u'peer-address': u'10.10.10.20/24'} + ]} + self.csr.create_pre_shared_key(psk_info) + if self.csr.status != requests.codes.CREATED: + self.fail("Unable to create PSK for test case") + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_pre_shared_key, psk_id) + return psk_id + + def _make_ike_policy_for_test(self): + policy_id = generate_ike_policy_id() + self._remove_resource_for_test(self.csr.delete_ike_policy, + policy_id) + with httmock.HTTMock(csr_request.token, csr_request.post): + policy_info = {u'priority-id': u'%d' % policy_id, + u'encryption': u'aes', + u'hash': u'sha', + u'dhGroup': 5, + u'lifetime': 3600} + self.csr.create_ike_policy(policy_info) + if self.csr.status != requests.codes.CREATED: + self.fail("Unable to create IKE policy for test case") + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ike_policy, policy_id) + return policy_id + + def _make_ipsec_policy_for_test(self): + policy_id = generate_ipsec_policy_id() + self._remove_resource_for_test(self.csr.delete_ipsec_policy, + policy_id) + with httmock.HTTMock(csr_request.token, csr_request.post): + policy_info = { + u'policy-id': u'%d' % policy_id, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + u'ah': u'ah-sha-hmac', + }, + u'lifetime-sec': 120, + u'pfs': u'group5', + u'anti-replay-window-size': u'64' + } + self.csr.create_ipsec_policy(policy_info) + if self.csr.status != requests.codes.CREATED: + self.fail("Unable to create IPSec policy for test case") + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ipsec_policy, policy_id) + return policy_id + + def _remove_resource_for_test(self, delete_resource, resource_id): + with httmock.HTTMock(csr_request.token, csr_request.delete): + delete_resource(resource_id) + + def _prepare_for_site_conn_create(self, skip_psk=False, skip_ike=False, + skip_ipsec=False): + """Create the policies and PSK so can then create site conn.""" + if not skip_psk: + self._make_psk_for_test() + if not skip_ike: + self._make_ike_policy_for_test() + if not skip_ipsec: + ipsec_policy_id = self._make_ipsec_policy_for_test() + else: + ipsec_policy_id = generate_ipsec_policy_id() + # Note: Use same ID number for tunnel and IPSec policy, so that when + # GET tunnel info, the mocks can infer the IPSec policy ID from the + # tunnel number. + return (ipsec_policy_id, ipsec_policy_id) + + def test_create_delete_ipsec_connection(self): + """Create and then delete an IPSec connection.""" + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create() + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'mtu': 1500, + u'local-device': {u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + location = self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ipsec_connection, + 'Tunnel%d' % tunnel_id) + self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id, + location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_connection = {u'kind': u'object#vpn-site-to-site', + u'ike-profile-id': None, + u'mtu': 1500, + u'ip-version': u'ipv4'} + expected_connection.update(connection_info) + self.assertEqual(expected_connection, content) + # Now delete and verify that site-to-site connection is gone + with httmock.HTTMock(csr_request.token, csr_request.delete, + csr_request.no_such_resource): + # Only delete connection. Cleanup will take care of prerequisites + self.csr.delete_ipsec_connection('Tunnel%d' % tunnel_id) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + + def test_create_ipsec_connection_with_no_tunnel_subnet(self): + """Create an IPSec connection without an IP address on tunnel.""" + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create() + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.get_unnumbered): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'local-device': {u'ip-address': u'GigabitEthernet3', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + location = self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ipsec_connection, + 'Tunnel%d' % tunnel_id) + self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id, + location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + if csr_request.FIXED_CSCum50512: + self.assertEqual(requests.codes.OK, self.csr.status) + expected_connection = {u'kind': u'object#vpn-site-to-site', + u'ip-version': u'ipv4'} + expected_connection.update(connection_info) + expected_connection[u'local-device'][u'ip-address'] = ( + u'unnumbered GigabitEthernet3') + self.assertEqual(expected_connection, content) + else: + self.assertEqual(requests.codes.INTERNAL_SERVER_ERROR, + self.csr.status) + + def test_create_ipsec_connection_no_pre_shared_key(self): + """Test of connection create without associated pre-shared key. + + The CSR will create the connection, but will not be able to pass + traffic without the pre-shared key. + """ + + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create( + skip_psk=True) + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'mtu': 1500, + u'local-device': {u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + location = self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ipsec_connection, + 'Tunnel%d' % tunnel_id) + self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id, + location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_connection = {u'kind': u'object#vpn-site-to-site', + u'ike-profile-id': None, + u'mtu': 1500, + u'ip-version': u'ipv4'} + expected_connection.update(connection_info) + self.assertEqual(expected_connection, content) + + def test_create_ipsec_connection_with_default_ike_policy(self): + """Test of connection create without IKE policy (uses default). + + Without an IKE policy, the CSR will use a built-in default IKE + policy setting for the connection. + """ + + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create( + skip_ike=True) + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'mtu': 1500, + u'local-device': {u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + location = self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ipsec_connection, + 'Tunnel%d' % tunnel_id) + self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id, + location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_connection = {u'kind': u'object#vpn-site-to-site', + u'ike-profile-id': None, + u'mtu': 1500, + u'ip-version': u'ipv4'} + expected_connection.update(connection_info) + self.assertEqual(expected_connection, content) + + def test_create_ipsec_connection_missing_ipsec_policy(self): + """Negative test of connection create without IPSec policy.""" + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create( + skip_ipsec=True) + with httmock.HTTMock(csr_request.token, + csr_request.post_missing_ipsec_policy): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'local-device': {u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + + def test_create_ipsec_connection_conficting_tunnel_ip(self): + """Negative test of connection create with conflicting tunnel IP. + + The GigabitEthernet3 interface has an IP of 10.2.0.6. This will + try a connection create with an IP that is on the same subnet. + """ + + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create() + with httmock.HTTMock(csr_request.token, csr_request.post_bad_ip): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'local-device': {u'ip-address': u'10.2.0.10/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + + def test_create_ipsec_connection_with_max_mtu(self): + """Create an IPSec connection with max MTU value.""" + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create() + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.get_mtu): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'mtu': 9192, + u'local-device': {u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + location = self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ipsec_connection, + 'Tunnel%d' % tunnel_id) + self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id, + location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_connection = {u'kind': u'object#vpn-site-to-site', + u'ike-profile-id': None, + u'ip-version': u'ipv4'} + expected_connection.update(connection_info) + self.assertEqual(expected_connection, content) + + def test_create_ipsec_connection_with_bad_mtu(self): + """Negative test of connection create with unsupported MTU value.""" + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create() + with httmock.HTTMock(csr_request.token, csr_request.post_bad_mtu): + connection_info = { + u'vpn-interface-name': u'Tunnel%d' % tunnel_id, + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'mtu': 9193, + u'local-device': {u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status) + + def test_status_when_no_tunnels_exist(self): + """Get status, when there are no tunnels.""" + with httmock.HTTMock(csr_request.token, csr_request.get_none): + tunnels = self.csr.read_tunnel_statuses() + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertEqual([], tunnels) + + def test_status_for_one_tunnel(self): + """Get status of one tunnel.""" + # Create the IPsec site-to-site connection first + tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create() + tunnel_id = 123 # Must hard code to work with mock + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + connection_info = { + u'vpn-interface-name': u'Tunnel123', + u'ipsec-policy-id': u'%d' % ipsec_policy_id, + u'local-device': {u'ip-address': u'10.3.0.1/24', + u'tunnel-ip-address': u'10.10.10.10'}, + u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'} + } + location = self.csr.create_ipsec_connection(connection_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.addCleanup(self._remove_resource_for_test, + self.csr.delete_ipsec_connection, + u'Tunnel123') + self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id, + location) + with httmock.HTTMock(csr_request.token, csr_request.normal_get): + tunnels = self.csr.read_tunnel_statuses() + self.assertEqual(requests.codes.OK, self.csr.status) + self.assertEqual([(u'Tunnel123', u'DOWN-NEGOTIATING'), ], tunnels) + + +class TestCsrRestIkeKeepaliveCreate(base.BaseTestCase): + + """Test IKE keepalive REST requests. + + This is a global configuration that will apply to all VPN tunnels and + is used to specify Dead Peer Detection information. Currently, the API + supports DELETE API, but a bug has been created to remove the API and + add an indicator of when the capability is disabled. + """ + + def setUp(self): + super(TestCsrRestIkeKeepaliveCreate, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_configure_ike_keepalive(self): + """Set IKE keep-alive (aka Dead Peer Detection) for the CSR.""" + with httmock.HTTMock(csr_request.token, csr_request.put, + csr_request.normal_get): + keepalive_info = {'interval': 60, 'retry': 4} + self.csr.configure_ike_keepalive(keepalive_info) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + content = self.csr.get_request('vpn-svc/ike/keepalive') + self.assertEqual(requests.codes.OK, self.csr.status) + expected = {'periodic': False} + expected.update(keepalive_info) + self.assertDictContainsSubset(expected, content) + + def test_disable_ike_keepalive(self): + """Disable IKE keep-alive (aka Dead Peer Detection) for the CSR.""" + with httmock.HTTMock(csr_request.token, csr_request.delete, + csr_request.put, csr_request.get_not_configured): + if csr_request.FIXED_CSCum10324: + # TODO(pcm) Is this how to disable? + keepalive_info = {'interval': 0, 'retry': 4} + self.csr.configure_ike_keepalive(keepalive_info) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + else: + self.csr.delete_request('vnc-svc/ike/keepalive') + self.assertIn(self.csr.status, + (requests.codes.NO_CONTENT, + requests.codes.NOT_FOUND)) + self.csr.get_request('vpn-svc/ike/keepalive') + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) + + +class TestCsrRestStaticRoute(base.BaseTestCase): + + """Test static route REST requests. + + A static route is added for the peer's private network. Would create + a route for each of the peer CIDRs specified for the VPN connection. + """ + + def setUp(self): + super(TestCsrRestStaticRoute, self).setUp() + self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10', + 'stack', 'cisco') + + def test_create_delete_static_route(self): + """Create and then delete a static route for the tunnel.""" + cidr = u'10.1.0.0/24' + interface = u'GigabitEthernet1' + expected_id = '10.1.0.0_24_GigabitEthernet1' + with httmock.HTTMock(csr_request.token, csr_request.post, + csr_request.normal_get): + route_info = {u'destination-network': cidr, + u'outgoing-interface': interface} + location = self.csr.create_static_route(route_info) + self.assertEqual(requests.codes.CREATED, self.csr.status) + self.assertIn('routing-svc/static-routes/%s' % expected_id, + location) + # Check the hard-coded items that get set as well... + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.OK, self.csr.status) + expected_route = {u'kind': u'object#static-route', + u'next-hop-router': None, + u'admin-distance': 1} + expected_route.update(route_info) + self.assertEqual(expected_route, content) + # Now delete and verify that static route is gone + with httmock.HTTMock(csr_request.token, csr_request.delete, + csr_request.no_such_resource): + route_id = csr_client.make_route_id(cidr, interface) + self.csr.delete_static_route(route_id) + self.assertEqual(requests.codes.NO_CONTENT, self.csr.status) + content = self.csr.get_request(location, full_url=True) + self.assertEqual(requests.codes.NOT_FOUND, self.csr.status) diff --git a/neutron/tests/unit/services/vpn/device_drivers/test_cisco_ipsec.py b/neutron/tests/unit/services/vpn/device_drivers/test_cisco_ipsec.py new file mode 100644 index 000000000..924ff447e --- /dev/null +++ b/neutron/tests/unit/services/vpn/device_drivers/test_cisco_ipsec.py @@ -0,0 +1,1386 @@ +# Copyright 2014 Cisco Systems, Inc. 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 +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# @author: Paul Michali, Cisco Systems, Inc. + +import httplib +import os +import tempfile + +import mock + +from neutron import context +from neutron.openstack.common import uuidutils +from neutron.plugins.common import constants +from neutron.services.vpn.device_drivers import ( + cisco_csr_rest_client as csr_client) +from neutron.services.vpn.device_drivers import cisco_ipsec as ipsec_driver +from neutron.tests import base + +_uuid = uuidutils.generate_uuid +FAKE_HOST = 'fake_host' +FAKE_ROUTER_ID = _uuid() +FAKE_VPN_SERVICE = { + 'id': _uuid(), + 'router_id': FAKE_ROUTER_ID, + 'admin_state_up': True, + 'status': constants.PENDING_CREATE, + 'subnet': {'cidr': '10.0.0.0/24'}, + 'ipsec_site_connections': [ + {'peer_cidrs': ['20.0.0.0/24', + '30.0.0.0/24']}, + {'peer_cidrs': ['40.0.0.0/24', + '50.0.0.0/24']}] +} +FIND_CFG_FOR_CSRS = ('neutron.services.vpn.device_drivers.cisco_ipsec.' + 'find_available_csrs_from_config') + + +class TestCiscoCsrIPSecConnection(base.BaseTestCase): + def setUp(self): + super(TestCiscoCsrIPSecConnection, self).setUp() + self.addCleanup(mock.patch.stopall) + self.conn_info = { + u'id': '123', + u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + 'psk': 'secret', + 'peer_address': '192.168.1.2', + 'peer_cidrs': ['10.1.0.0/24', '10.2.0.0/24'], + 'mtu': 1500, + 'ike_policy': {'auth_algorithm': 'sha1', + 'encryption_algorithm': 'aes-128', + 'pfs': 'Group5', + 'ike_version': 'v1', + 'lifetime_units': 'seconds', + 'lifetime_value': 3600}, + 'ipsec_policy': {'transform_protocol': 'ah', + 'encryption_algorithm': 'aes-128', + 'auth_algorithm': 'sha1', + 'pfs': 'group5', + 'lifetime_units': 'seconds', + 'lifetime_value': 3600}, + 'cisco': {'site_conn_id': 'Tunnel0', + 'ike_policy_id': 222, + 'ipsec_policy_id': 333, + # TODO(pcm) FUTURE use vpnservice['external_ip'] + 'router_public_ip': '172.24.4.23'} + } + self.csr = mock.Mock(spec=csr_client.CsrRestClient) + self.csr.status = 201 # All calls to CSR REST API succeed + self.ipsec_conn = ipsec_driver.CiscoCsrIPSecConnection(self.conn_info, + self.csr) + + def test_create_ipsec_site_connection(self): + """Ensure all steps are done to create an IPSec site connection. + + Verify that each of the driver calls occur (in order), and + the right information is stored for later deletion. + """ + expected = ['create_pre_shared_key', + 'create_ike_policy', + 'create_ipsec_policy', + 'create_ipsec_connection', + 'create_static_route', + 'create_static_route'] + expected_rollback_steps = [ + ipsec_driver.RollbackStep(action='pre_shared_key', + resource_id='123', + title='Pre-Shared Key'), + ipsec_driver.RollbackStep(action='ike_policy', + resource_id=222, + title='IKE Policy'), + ipsec_driver.RollbackStep(action='ipsec_policy', + resource_id=333, + title='IPSec Policy'), + ipsec_driver.RollbackStep(action='ipsec_connection', + resource_id='Tunnel0', + title='IPSec Connection'), + ipsec_driver.RollbackStep(action='static_route', + resource_id='10.1.0.0_24_Tunnel0', + title='Static Route'), + ipsec_driver.RollbackStep(action='static_route', + resource_id='10.2.0.0_24_Tunnel0', + title='Static Route')] + self.ipsec_conn.create_ipsec_site_connection(mock.Mock(), + self.conn_info) + client_calls = [c[0] for c in self.csr.method_calls] + self.assertEqual(expected, client_calls) + self.assertEqual(expected_rollback_steps, self.ipsec_conn.steps) + + def test_create_ipsec_site_connection_with_rollback(self): + """Failure test of IPSec site conn creation that fails and rolls back. + + Simulate a failure in the last create step (making routes for the + peer networks), and ensure that the create steps are called in + order (except for create_static_route), and that the delete + steps are called in reverse order. At the end, there should be no + rollback infromation for the connection. + """ + def fake_route_check_fails(*args, **kwargs): + if args[0] == 'Static Route': + # So that subsequent calls to CSR rest client (for rollback) + # will fake as passing. + self.csr.status = httplib.NO_CONTENT + raise ipsec_driver.CsrResourceCreateFailure(resource=args[0], + which=args[1]) + + with mock.patch.object(ipsec_driver.CiscoCsrIPSecConnection, + '_check_create', + side_effect=fake_route_check_fails): + + expected = ['create_pre_shared_key', + 'create_ike_policy', + 'create_ipsec_policy', + 'create_ipsec_connection', + 'create_static_route', + 'delete_ipsec_connection', + 'delete_ipsec_policy', + 'delete_ike_policy', + 'delete_pre_shared_key'] + self.ipsec_conn.create_ipsec_site_connection(mock.Mock(), + self.conn_info) + client_calls = [c[0] for c in self.csr.method_calls] + self.assertEqual(expected, client_calls) + self.assertEqual([], self.ipsec_conn.steps) + + def test_create_verification_with_error(self): + """Negative test of create check step had failed.""" + self.csr.status = httplib.NOT_FOUND + self.assertRaises(ipsec_driver.CsrResourceCreateFailure, + self.ipsec_conn._check_create, 'name', 'id') + + def test_failure_with_invalid_create_step(self): + """Negative test of invalid create step (programming error).""" + self.ipsec_conn.steps = [] + try: + self.ipsec_conn.do_create_action('bogus', None, '123', 'Bad Step') + except ipsec_driver.CsrResourceCreateFailure: + pass + else: + self.fail('Expected exception with invalid create step') + + def test_failure_with_invalid_delete_step(self): + """Negative test of invalid delete step (programming error).""" + self.ipsec_conn.steps = [ipsec_driver.RollbackStep(action='bogus', + resource_id='123', + title='Bogus Step')] + try: + self.ipsec_conn.do_rollback() + except ipsec_driver.CsrResourceCreateFailure: + pass + else: + self.fail('Expected exception with invalid delete step') + + def test_delete_ipsec_connection(self): + # TODO(pcm) implement + pass + + +class TestCiscoCsrIPsecConnectionCreateTransforms(base.BaseTestCase): + + """Verifies that config info is prepared/transformed correctly.""" + + def setUp(self): + super(TestCiscoCsrIPsecConnectionCreateTransforms, self).setUp() + self.addCleanup(mock.patch.stopall) + self.conn_info = { + u'id': '123', + u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + 'psk': 'secret', + 'peer_address': '192.168.1.2', + 'peer_cidrs': ['10.1.0.0/24', '10.2.0.0/24'], + 'mtu': 1500, + 'ike_policy': {'auth_algorithm': 'sha1', + 'encryption_algorithm': 'aes-128', + 'pfs': 'Group5', + 'ike_version': 'v1', + 'lifetime_units': 'seconds', + 'lifetime_value': 3600}, + 'ipsec_policy': {'transform_protocol': 'ah', + 'encryption_algorithm': 'aes-128', + 'auth_algorithm': 'sha1', + 'pfs': 'group5', + 'lifetime_units': 'seconds', + 'lifetime_value': 3600}, + 'cisco': {'site_conn_id': 'Tunnel0', + 'ike_policy_id': 222, + 'ipsec_policy_id': 333, + # TODO(pcm) get from vpnservice['external_ip'] + 'router_public_ip': '172.24.4.23'} + } + self.ipsec_conn = ipsec_driver.CiscoCsrIPSecConnection(self.conn_info, + mock.Mock()) + + def test_invalid_attribute(self): + """Negative test of unknown attribute - programming error.""" + self.assertRaises(ipsec_driver.CsrDriverMismatchError, + self.ipsec_conn.translate_dialect, + 'ike_policy', 'unknown_attr', self.conn_info) + + def test_driver_unknown_mapping(self): + """Negative test of service driver providing unknown value to map.""" + self.conn_info['ike_policy']['pfs'] = "unknown_value" + self.assertRaises(ipsec_driver.CsrUnknownMappingError, + self.ipsec_conn.translate_dialect, + 'ike_policy', 'pfs', self.conn_info['ike_policy']) + + def test_psk_create_info(self): + """Ensure that pre-shared key info is created correctly.""" + expected = {u'keyring-name': '123', + u'pre-shared-key-list': [ + {u'key': 'secret', + u'encrypted': False, + u'peer-address': '192.168.1.2'}]} + psk_id = self.conn_info['id'] + psk_info = self.ipsec_conn.create_psk_info(psk_id, self.conn_info) + self.assertEqual(expected, psk_info) + + def test_create_ike_policy_info(self): + """Ensure that IKE policy info is mapped/created correctly.""" + expected = {u'priority-id': 222, + u'encryption': u'aes', + u'hash': u'sha', + u'dhGroup': 5, + u'version': u'v1', + u'lifetime': 3600} + policy_id = self.conn_info['cisco']['ike_policy_id'] + policy_info = self.ipsec_conn.create_ike_policy_info(policy_id, + self.conn_info) + self.assertEqual(expected, policy_info) + + def test_create_ike_policy_info_non_defaults(self): + """Ensure that IKE policy info with different values.""" + self.conn_info['ike_policy'] = { + 'auth_algorithm': 'sha1', + 'encryption_algorithm': 'aes-256', + 'pfs': 'Group14', + 'ike_version': 'v1', + 'lifetime_units': 'seconds', + 'lifetime_value': 60 + } + expected = {u'priority-id': 222, + u'encryption': u'aes', # TODO(pcm): fix + u'hash': u'sha', + u'dhGroup': 14, + u'version': u'v1', + u'lifetime': 60} + policy_id = self.conn_info['cisco']['ike_policy_id'] + policy_info = self.ipsec_conn.create_ike_policy_info(policy_id, + self.conn_info) + self.assertEqual(expected, policy_info) + + def test_ipsec_policy_info(self): + """Ensure that IPSec policy info is mapped/created correctly.""" + expected = {u'policy-id': 333, + u'protection-suite': { + u'esp-encryption': u'esp-aes', + u'esp-authentication': u'esp-sha-hmac', + u'ah': u'ah-sha-hmac' + }, + u'lifetime-sec': 3600, + u'pfs': u'group5', + u'anti-replay-window-size': u'64'} + ipsec_policy_id = self.conn_info['cisco']['ipsec_policy_id'] + policy_info = self.ipsec_conn.create_ipsec_policy_info(ipsec_policy_id, + self.conn_info) + self.assertEqual(expected, policy_info) + + def test_ipsec_policy_info_non_defaults(self): + """Create/map IPSec policy info with different values.""" + self.conn_info['ipsec_policy'] = {'transform_protocol': 'esp', + 'encryption_algorithm': '3des', + 'auth_algorithm': 'sha1', + 'pfs': 'group14', + 'lifetime_units': 'seconds', + 'lifetime_value': 120} + expected = {u'policy-id': 333, + u'protection-suite': { + u'esp-encryption': u'esp-3des', + u'esp-authentication': u'esp-sha-hmac' + }, + u'lifetime-sec': 120, + u'pfs': u'group14', + u'anti-replay-window-size': u'64'} + ipsec_policy_id = self.conn_info['cisco']['ipsec_policy_id'] + policy_info = self.ipsec_conn.create_ipsec_policy_info(ipsec_policy_id, + self.conn_info) + self.assertEqual(expected, policy_info) + + def test_site_connection_info(self): + """Ensure site-to-site connection info is created/mapped correctly.""" + expected = {u'vpn-interface-name': 'Tunnel0', + u'ipsec-policy-id': 333, + u'local-device': { + u'ip-address': u'GigabitEthernet3', + u'tunnel-ip-address': u'172.24.4.23' + }, + u'remote-device': { + u'tunnel-ip-address': '192.168.1.2' + }, + u'mtu': 1500} + ipsec_policy_id = self.conn_info['cisco']['ipsec_policy_id'] + site_conn_id = self.conn_info['cisco']['site_conn_id'] + conn_info = self.ipsec_conn.create_site_connection_info( + site_conn_id, ipsec_policy_id, self.conn_info) + self.assertEqual(expected, conn_info) + + def test_static_route_info(self): + """Create static route info for peer CIDRs.""" + expected = [('10.1.0.0_24_Tunnel0', + {u'destination-network': '10.1.0.0/24', + u'outgoing-interface': 'Tunnel0'}), + ('10.2.0.0_24_Tunnel0', + {u'destination-network': '10.2.0.0/24', + u'outgoing-interface': 'Tunnel0'})] +# self.driver.csr.make_route_id.side_effect = ['10.1.0.0_24_Tunnel0', +# '10.2.0.0_24_Tunnel0'] + site_conn_id = self.conn_info['cisco']['site_conn_id'] + routes_info = self.ipsec_conn.create_routes_info(site_conn_id, + self.conn_info) + self.assertEqual(2, len(routes_info)) + self.assertEqual(expected, routes_info) + + +class TestCiscoCsrIPsecDeviceDriverSyncStatuses(base.BaseTestCase): + + """Test status/state of services and connections, after sync.""" + + def setUp(self): + super(TestCiscoCsrIPsecDeviceDriverSyncStatuses, self).setUp() + self.addCleanup(mock.patch.stopall) + for klass in ['neutron.openstack.common.rpc.create_connection', + 'neutron.context.get_admin_context_without_session', + 'neutron.openstack.common.' + 'loopingcall.FixedIntervalLoopingCall']: + mock.patch(klass).start() + self.context = context.Context('some_user', 'some_tenant') + self.agent = mock.Mock() + conf_patch = mock.patch('oslo.config.cfg.CONF').start() + conf_patch.config_file = ['dummy'] + self.config_load = mock.patch(FIND_CFG_FOR_CSRS).start() + self.config_load.return_value = {'1.1.1.1': {'rest_mgmt': '2.2.2.2', + 'tunnel_ip': '1.1.1.3', + 'username': 'pe', + 'password': 'password', + 'timeout': 120}} + self.driver = ipsec_driver.CiscoCsrIPsecDriver(self.agent, FAKE_HOST) + self.driver.agent_rpc = mock.Mock() + self.conn_create = mock.patch.object( + ipsec_driver.CiscoCsrIPSecConnection, + 'create_ipsec_site_connection').start() + self.conn_delete = mock.patch.object( + ipsec_driver.CiscoCsrIPSecConnection, + 'delete_ipsec_site_connection').start() + self.csr = mock.Mock() + self.driver.csrs['1.1.1.1'] = self.csr + self.service123_data = {u'id': u'123', + u'status': constants.DOWN, + u'admin_state_up': False, + u'external_ip': u'1.1.1.1'} + self.conn1_data = {u'id': u'1', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + + # NOTE: For sync, there is mark (trivial), update (tested), + # sweep (tested), and report(tested) phases. + + def test_update_ipsec_connection_create_notify(self): + """Notified of connection create request - create.""" + # Make the (existing) service + self.driver.create_vpn_service(self.service123_data) + conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + + connection = self.driver.update_connection(self.context, + u'123', conn_data) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.PENDING_CREATE, connection.last_status) + self.assertEqual(1, self.conn_create.call_count) + + def test_update_ipsec_connection_changed_settings(self): + """Notified of connection changing config - update.""" + # TODO(pcm) Place holder for this condition + # Make the (existing) service and connection + vpn_service = self.driver.create_vpn_service(self.service123_data) + # TODO(pcm) add info that indicates that the connection has changed + conn_data = {u'id': u'1', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + vpn_service.create_connection(conn_data) + self.driver.mark_existing_connections_as_dirty() + + connection = self.driver.update_connection(self.context, + '123', conn_data) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.ACTIVE, connection.last_status) + self.assertEqual(0, self.conn_create.call_count) + # TODO(pcm) FUTURE - handling for update (delete/create?) + + def test_update_of_unknown_ipsec_connection(self): + """Notified of update of unknown connection - create. + + Occurs if agent restarts and receives a notification of change + to connection, but has no previous record of the connection. + Result will be to rebuild the connection. + + This can also happen, if a connection is changed from admin + down to admin up (so don't need a separate test for admin up. + """ + # Will have previously created service, but don't know of connection + self.driver.create_vpn_service(self.service123_data) + conn_data = {u'id': u'1', u'status': constants.DOWN, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + + connection = self.driver.update_connection(self.context, + u'123', conn_data) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.DOWN, connection.last_status) + self.assertEqual(1, self.conn_create.call_count) + + def test_update_unchanged_ipsec_connection(self): + """Unchanged state for connection during sync - nop.""" + # Make the (existing) service and connection + vpn_service = self.driver.create_vpn_service(self.service123_data) + conn_data = {u'id': u'1', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + vpn_service.create_connection(conn_data) + self.driver.mark_existing_connections_as_dirty() + # The notification (state) hasn't changed for the connection + + connection = self.driver.update_connection(self.context, + '123', conn_data) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.ACTIVE, connection.last_status) + self.assertEqual(0, self.conn_create.call_count) + + def test_update_connection_admin_down(self): + """Connection updated to admin down state - dirty.""" + # Make existing service, and conenction that was active + vpn_service = self.driver.create_vpn_service(self.service123_data) + conn_data = {u'id': '1', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + vpn_service.create_connection(conn_data) + self.driver.mark_existing_connections_as_dirty() + # Now simulate that the notification shows the connection admin down + conn_data[u'admin_state_up'] = False + conn_data[u'status'] = constants.DOWN + + connection = self.driver.update_connection(self.context, + u'123', conn_data) + self.assertTrue(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.DOWN, connection.last_status) + self.assertEqual(0, self.conn_create.call_count) + + def test_update_missing_connection_admin_down(self): + """Connection not present is in admin down state - nop. + + If the agent has restarted, and a sync notification occurs with + a connection that is in admin down state, ignore the connection + versus creating and marking dirty and then deleting. + """ + # Make existing service, but no connection + self.driver.create_vpn_service(self.service123_data) + conn_data = {u'id': '1', u'status': constants.DOWN, + u'admin_state_up': False, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + + connection = self.driver.update_connection(self.context, + u'123', conn_data) + self.assertIsNone(connection) + self.assertEqual(0, self.conn_create.call_count) + + def test_update_for_vpn_service_create(self): + """Creation of new IPSec connection on new VPN service - create. + + Service will be created and marked as 'clean', and update + processing for connection will occur (create). + """ + conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + service_data = {u'id': u'123', + u'status': constants.PENDING_CREATE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn_data]} + vpn_service = self.driver.update_service(self.context, service_data) + self.assertFalse(vpn_service.is_dirty) + self.assertEqual(constants.PENDING_CREATE, vpn_service.last_status) + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.PENDING_CREATE, connection.last_status) + self.assertEqual(1, self.conn_create.call_count) + + def test_update_for_new_connection_on_existing_service(self): + """Creating a new IPSec connection on an existing service.""" + # Create the service before testing, and mark it dirty + prev_vpn_service = self.driver.create_vpn_service(self.service123_data) + self.driver.mark_existing_connections_as_dirty() + conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + service_data = {u'id': u'123', + u'status': constants.ACTIVE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn_data]} + vpn_service = self.driver.update_service(self.context, service_data) + # Should reuse the entry and update the status + self.assertEqual(prev_vpn_service, vpn_service) + self.assertFalse(vpn_service.is_dirty) + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.PENDING_CREATE, connection.last_status) + self.assertEqual(1, self.conn_create.call_count) + + def test_update_for_vpn_service_with_one_unchanged_connection(self): + """Existing VPN service and IPSec connection without any changes - nop. + + Service and connection will be marked clean. No processing for + either, as there are no changes. + """ + # Create a service and add in a connection that is active + prev_vpn_service = self.driver.create_vpn_service(self.service123_data) + conn_data = {u'id': u'1', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + prev_vpn_service.create_connection(conn_data) + self.driver.mark_existing_connections_as_dirty() + # Create notification with conn unchanged and service already created + service_data = {u'id': u'123', + u'status': constants.ACTIVE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn_data]} + vpn_service = self.driver.update_service(self.context, service_data) + # Should reuse the entry and update the status + self.assertEqual(prev_vpn_service, vpn_service) + self.assertFalse(vpn_service.is_dirty) + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.ACTIVE, connection.last_status) + self.assertEqual(0, self.conn_create.call_count) + + def test_update_service_admin_down(self): + """VPN service updated to admin down state - dirty. + + Mark service dirty and do not process any notfications for + connections using the service. + """ + # Create an "existing" service, prior to notification + prev_vpn_service = self.driver.create_vpn_service(self.service123_data) + self.driver.mark_existing_connections_as_dirty() + conn_data = {u'id': u'1', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + service_data = {u'id': u'123', + u'status': constants.DOWN, + u'external_ip': u'1.1.1.1', + u'admin_state_up': False, + u'ipsec_conns': [conn_data]} + vpn_service = self.driver.update_service(self.context, service_data) + self.assertEqual(prev_vpn_service, vpn_service) + self.assertTrue(vpn_service.is_dirty) + self.assertEqual(constants.DOWN, vpn_service.last_status) + self.assertIsNone(vpn_service.get_connection(u'1')) + + def test_update_unknown_service_admin_down(self): + """Unknown VPN service uodated to admin down state - nop. + + Can happen if agent restarts and then gets its first notificaiton + of a service that is in the admin down state. Will not do anything, + versus creating, marking dirty, and then deleting the VPN service. + """ + service_data = {u'id': u'123', + u'status': constants.DOWN, + u'external_ip': u'1.1.1.1', + u'admin_state_up': False, + u'ipsec_conns': []} + vpn_service = self.driver.update_service(self.context, service_data) + self.assertIsNone(vpn_service) + + def test_update_of_unknown_service_create(self): + """Create of VPN service that is currently unknown - record. + + If agent is restarted or user changes VPN service to admin up, the + notification may contain a VPN service with an IPSec connection + that is not in PENDING_CREATE state. + """ + conn_data = {u'id': u'1', u'status': constants.DOWN, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + service_data = {u'id': u'123', + u'status': constants.ACTIVE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn_data]} + vpn_service = self.driver.update_service(self.context, service_data) + self.assertFalse(vpn_service.is_dirty) + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + self.assertFalse(connection.is_dirty) + self.assertEqual(u'Tunnel0', connection.tunnel) + self.assertEqual(constants.DOWN, connection.last_status) + self.assertEqual(1, self.conn_create.call_count) + + def test_update_service_create_no_csr(self): + """Failure test of sync of service that is not on CSR - ignore. + + Ignore the VPN service and its IPSec connection(s) notifications for + which there is no corresponding Cisco CSR. + """ + conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + service_data = {u'id': u'123', + u'status': constants.PENDING_CREATE, + u'external_ip': u'2.2.2.2', + u'admin_state_up': True, + u'ipsec_conns': [conn_data]} + vpn_service = self.driver.update_service(self.context, service_data) + self.assertIsNone(vpn_service) + + def _check_connection_for_service(self, count, vpn_service): + """Helper to check the connection information for a service.""" + connection = vpn_service.get_connection(u'%d' % count) + self.assertIsNotNone(connection, "for connection %d" % count) + self.assertFalse(connection.is_dirty, "for connection %d" % count) + self.assertEqual(u'Tunnel%d' % count, connection.tunnel, + "for connection %d" % count) + self.assertEqual(constants.PENDING_CREATE, connection.last_status, + "for connection %d" % count) + return count + 1 + + def notification_for_two_services_with_two_conns(self): + """Helper used by tests to create two services, each with two conns.""" + conn1_data = {u'id': u'1', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel1'}} + conn2_data = {u'id': u'2', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel2'}} + service1_data = {u'id': u'123', + u'status': constants.PENDING_CREATE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn1_data, conn2_data]} + conn3_data = {u'id': u'3', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel3'}} + conn4_data = {u'id': u'4', u'status': constants.PENDING_CREATE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel4'}} + service2_data = {u'id': u'456', + u'status': constants.PENDING_CREATE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn3_data, conn4_data]} + return service1_data, service2_data + + def test_create_two_connections_on_two_services(self): + """High level test of multiple VPN services with connections.""" + # Build notification message + (service1_data, + service2_data) = self.notification_for_two_services_with_two_conns() + # Simulate plugin returning notifcation, when requested + self.driver.agent_rpc.get_vpn_services_on_host.return_value = [ + service1_data, service2_data] + vpn_services = self.driver.update_all_services_and_connections( + self.context) + self.assertEqual(2, len(vpn_services)) + count = 1 + for vpn_service in vpn_services: + self.assertFalse(vpn_service.is_dirty, + "for service %s" % vpn_service) + self.assertEqual(constants.PENDING_CREATE, vpn_service.last_status, + "for service %s" % vpn_service) + count = self._check_connection_for_service(count, vpn_service) + count = self._check_connection_for_service(count, vpn_service) + self.assertEqual(4, self.conn_create.call_count) + + def test_sweep_connection_marked_as_clean(self): + """Sync updated connection - no action.""" + # Create a service and connection + vpn_service = self.driver.create_vpn_service(self.service123_data) + connection = vpn_service.create_connection(self.conn1_data) + self.driver.mark_existing_connections_as_dirty() + # Simulate that the update phase visted both of them + vpn_service.is_dirty = False + connection.is_dirty = False + self.driver.remove_unknown_connections(self.context) + vpn_service = self.driver.service_state.get(u'123') + self.assertIsNotNone(vpn_service) + self.assertFalse(vpn_service.is_dirty) + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + self.assertFalse(connection.is_dirty) + + def test_sweep_connection_dirty(self): + """Sync did not update connection - delete.""" + # Create a service and connection + vpn_service = self.driver.create_vpn_service(self.service123_data) + vpn_service.create_connection(self.conn1_data) + self.driver.mark_existing_connections_as_dirty() + # Simulate that the update phase only visited the service + vpn_service.is_dirty = False + self.driver.remove_unknown_connections(self.context) + vpn_service = self.driver.service_state.get(u'123') + self.assertIsNotNone(vpn_service) + self.assertFalse(vpn_service.is_dirty) + connection = vpn_service.get_connection(u'1') + self.assertIsNone(connection) + self.assertEqual(1, self.conn_delete.call_count) + + def test_sweep_service_dirty(self): + """Sync did not update service - delete it and all conns.""" + # Create a service and connection + vpn_service = self.driver.create_vpn_service(self.service123_data) + vpn_service.create_connection(self.conn1_data) + self.driver.mark_existing_connections_as_dirty() + # Both the service and the connection are still 'dirty' + self.driver.remove_unknown_connections(self.context) + self.assertIsNone(self.driver.service_state.get(u'123')) + self.assertEqual(1, self.conn_delete.call_count) + + def test_sweep_multiple_services(self): + """One service and conn udpated, one service and conn not.""" + # Create two services, each with a connection + vpn_service1 = self.driver.create_vpn_service(self.service123_data) + vpn_service1.create_connection(self.conn1_data) + service456_data = {u'id': u'456', + u'status': constants.ACTIVE, + u'admin_state_up': False, + u'external_ip': u'1.1.1.1'} + conn2_data = {u'id': u'2', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + prev_vpn_service2 = self.driver.create_vpn_service(service456_data) + prev_connection2 = prev_vpn_service2.create_connection(conn2_data) + self.driver.mark_existing_connections_as_dirty() + # Simulate that the update phase visited the first service and conn + prev_vpn_service2.is_dirty = False + prev_connection2.is_dirty = False + self.driver.remove_unknown_connections(self.context) + self.assertIsNone(self.driver.service_state.get(u'123')) + vpn_service2 = self.driver.service_state.get(u'456') + self.assertEqual(prev_vpn_service2, vpn_service2) + self.assertFalse(vpn_service2.is_dirty) + connection2 = vpn_service2.get_connection(u'2') + self.assertEqual(prev_connection2, connection2) + self.assertFalse(connection2.is_dirty) + self.assertEqual(1, self.conn_delete.call_count) + + def simulate_mark_update_sweep_for_service_with_conn(self, service_state, + connection_state): + """Create internal structures for single service with connection.""" + # Simulate that we have done mark, update, and sweep. + conn_data = {u'id': u'1', u'status': connection_state, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel0'}} + service_data = {u'id': u'123', + u'status': service_state, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn_data]} + return self.driver.update_service(self.context, service_data) + + def test_report_fragment_connection_created(self): + """Generate report section for a created connection.""" + # Prepare service and connection in PENDING_CREATE state + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.PENDING_CREATE, constants.PENDING_CREATE) + # Simulate that CSR has reported the connection is still up + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'UP-ACTIVE'), ] + + # Get the statuses for connections existing on CSR + tunnels = vpn_service.get_ipsec_connections_status() + self.assertEqual({u'Tunnel0': constants.ACTIVE}, tunnels) + + # Check that there is a status for this connection + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + current_status = connection.find_current_status_in(tunnels) + self.assertEqual(constants.ACTIVE, current_status) + + # Create report fragment due to change + self.assertNotEqual(connection.last_status, current_status) + report_frag = connection.build_report_based_on_status(current_status) + self.assertEqual(current_status, connection.last_status) + expected = {'1': {'status': constants.ACTIVE, + 'updated_pending_status': True}} + self.assertEqual(expected, report_frag) + + def test_report_fragment_connection_unchanged_status(self): + """No report section generated for a created connection.""" + # Prepare service and connection in ACTIVE state + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.ACTIVE, constants.ACTIVE) + # Simulate that CSR has reported the connection is up + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'UP-IDLE'), ] + + # Get the statuses for connections existing on CSR + tunnels = vpn_service.get_ipsec_connections_status() + self.assertEqual({u'Tunnel0': constants.ACTIVE}, tunnels) + + # Check that there is a status for this connection + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + current_status = connection.find_current_status_in(tunnels) + self.assertEqual(constants.ACTIVE, current_status) + + # Should be no report, as no change + self.assertEqual(connection.last_status, current_status) + report_frag = connection.build_report_based_on_status(current_status) + self.assertEqual(current_status, connection.last_status) + self.assertEqual({}, report_frag) + + def test_report_fragment_connection_changed_status(self): + """Generate report section for connection with changed state.""" + # Prepare service in ACTIVE state and connection in DOWN state + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.ACTIVE, constants.DOWN) + # Simulate that CSR has reported the connection is still up + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'UP-NO-IKE'), ] + + # Get the statuses for connections existing on CSR + tunnels = vpn_service.get_ipsec_connections_status() + self.assertEqual({u'Tunnel0': constants.ACTIVE}, tunnels) + + # Check that there is a status for this connection + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + current_status = connection.find_current_status_in(tunnels) + self.assertEqual(constants.ACTIVE, current_status) + + # Create report fragment due to change + self.assertNotEqual(connection.last_status, current_status) + report_frag = connection.build_report_based_on_status(current_status) + self.assertEqual(current_status, connection.last_status) + expected = {'1': {'status': constants.ACTIVE, + 'updated_pending_status': False}} + self.assertEqual(expected, report_frag) + + def test_report_fragment_connection_failed_create(self): + """Failure test of report fragment for conn that failed creation. + + Normally, without any status from the CSR, the connection report would + be skipped, but we need to report back failures. + """ + # Prepare service and connection in PENDING_CREATE state + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.PENDING_CREATE, constants.PENDING_CREATE) + # Simulate that CSR does NOT report the status (no tunnel) + self.csr.read_tunnel_statuses.return_value = [] + + # Get the statuses for connections existing on CSR + tunnels = vpn_service.get_ipsec_connections_status() + self.assertEqual({}, tunnels) + + # Check that there is a status for this connection + connection = vpn_service.get_connection(u'1') + self.assertIsNotNone(connection) + current_status = connection.find_current_status_in(tunnels) + self.assertEqual(constants.ERROR, current_status) + + # Create report fragment due to change + self.assertNotEqual(connection.last_status, current_status) + report_frag = connection.build_report_based_on_status(current_status) + self.assertEqual(current_status, connection.last_status) + expected = {'1': {'status': constants.ERROR, + 'updated_pending_status': True}} + self.assertEqual(expected, report_frag) + + def test_report_fragment_two_connections(self): + """Generate report fragment for two connections on a service.""" + # Prepare service with two connections, one ACTIVE, one DOWN + conn1_data = {u'id': u'1', u'status': constants.DOWN, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel1'}} + conn2_data = {u'id': u'2', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel2'}} + service_data = {u'id': u'123', + u'status': constants.ACTIVE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn1_data, conn2_data]} + vpn_service = self.driver.update_service(self.context, service_data) + # Simulate that CSR has reported the connections with diff status + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel1', u'UP-IDLE'), (u'Tunnel2', u'DOWN-NEGOTIATING')] + + # Get the report fragments for the connections + report_frag = self.driver.build_report_for_connections_on(vpn_service) + expected = {u'1': {u'status': constants.ACTIVE, + u'updated_pending_status': False}, + u'2': {u'status': constants.DOWN, + u'updated_pending_status': False}} + self.assertEqual(expected, report_frag) + + def test_report_service_create(self): + """VPN service and IPSec connection created - report.""" + # Simulate creation of the service and connection + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.PENDING_CREATE, constants.PENDING_CREATE) + # Simulate that the CSR has created the connection + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'UP-ACTIVE'), ] + + report = self.driver.build_report_for_service(vpn_service) + expected_report = { + u'id': u'123', + u'updated_pending_status': True, + u'status': constants.ACTIVE, + u'ipsec_site_connections': { + u'1': {u'status': constants.ACTIVE, + u'updated_pending_status': True} + } + } + self.assertEqual(expected_report, report) + # Check that service and connection statuses are updated + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + self.assertEqual(constants.ACTIVE, + vpn_service.get_connection(u'1').last_status) + + def test_report_service_create_of_first_conn_fails(self): + """VPN service and IPSec conn created, but conn failed - report. + + Since this is the sole IPSec connection on the service, and the + create failed (connection in ERROR state), the VPN service's + status will be set to DOWN. + """ + # Simulate creation of the service and connection + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.PENDING_CREATE, constants.PENDING_CREATE) + # Simulate that the CSR has no info due to failed create + self.csr.read_tunnel_statuses.return_value = [] + + report = self.driver.build_report_for_service(vpn_service) + expected_report = { + u'id': u'123', + u'updated_pending_status': True, + u'status': constants.DOWN, + u'ipsec_site_connections': { + u'1': {u'status': constants.ERROR, + u'updated_pending_status': True} + } + } + self.assertEqual(expected_report, report) + # Check that service and connection statuses are updated + self.assertEqual(constants.DOWN, vpn_service.last_status) + self.assertEqual(constants.ERROR, + vpn_service.get_connection(u'1').last_status) + + def test_report_connection_created_on_existing_service(self): + """Creating connection on existing service - report.""" + # Simulate existing service and connection create + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.ACTIVE, constants.PENDING_CREATE) + # Simulate that the CSR has created the connection + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'UP-IDLE'), ] + + report = self.driver.build_report_for_service(vpn_service) + expected_report = { + u'id': u'123', + u'updated_pending_status': False, + u'status': constants.ACTIVE, + u'ipsec_site_connections': { + u'1': {u'status': constants.ACTIVE, + u'updated_pending_status': True} + } + } + self.assertEqual(expected_report, report) + # Check that service and connection statuses are updated + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + self.assertEqual(constants.ACTIVE, + vpn_service.get_connection(u'1').last_status) + + def test_no_report_no_changes(self): + """VPN service with unchanged IPSec connection - no report. + + Note: No report will be generated if the last connection on the + service is deleted. The service (and connection) objects will + have been reoved by the sweep operation and thus not reported. + On the plugin, the service should be changed to DOWN. Likewise, + if the service goes to admin down state. + """ + # Simulate an existing service and connection that are ACTIVE + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.ACTIVE, constants.ACTIVE) + # Simulate that the CSR reports the connection still active + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'UP-ACTIVE'), ] + + report = self.driver.build_report_for_service(vpn_service) + self.assertEqual({}, report) + # Check that service and connection statuses are still same + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + self.assertEqual(constants.ACTIVE, + vpn_service.get_connection(u'1').last_status) + + def test_report_sole_connection_goes_down(self): + """Only connection on VPN service goes down - report. + + In addition to reporting the status change and recording the new + state for the IPSec connection, the VPN service status will be + ACTIVE. + """ + # Simulate an existing service and connection that are ACTIVE + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.ACTIVE, constants.ACTIVE) + # Simulate that the CSR reports the connection went down + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'DOWN-NEGOTIATING'), ] + + report = self.driver.build_report_for_service(vpn_service) + expected_report = { + u'id': u'123', + u'updated_pending_status': False, + u'status': constants.DOWN, + u'ipsec_site_connections': { + u'1': {u'status': constants.DOWN, + u'updated_pending_status': False} + } + } + self.assertEqual(expected_report, report) + # Check that service and connection statuses are updated + self.assertEqual(constants.DOWN, vpn_service.last_status) + self.assertEqual(constants.DOWN, + vpn_service.get_connection(u'1').last_status) + + def test_report_sole_connection_comes_up(self): + """Only connection on VPN service comes up - report. + + In addition to reporting the status change and recording the new + state for the IPSec connection, the VPN service status will be + DOWN. + """ + # Simulate an existing service and connection that are DOWN + vpn_service = self.simulate_mark_update_sweep_for_service_with_conn( + constants.DOWN, constants.DOWN) + # Simulate that the CSR reports the connection came up + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel0', u'UP-NO-IKE'), ] + + report = self.driver.build_report_for_service(vpn_service) + expected_report = { + u'id': u'123', + u'updated_pending_status': False, + u'status': constants.ACTIVE, + u'ipsec_site_connections': { + u'1': {u'status': constants.ACTIVE, + u'updated_pending_status': False} + } + } + self.assertEqual(expected_report, report) + # Check that service and connection statuses are updated + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + self.assertEqual(constants.ACTIVE, + vpn_service.get_connection(u'1').last_status) + + def test_report_service_with_two_connections_gone_down(self): + """One service with two connections that went down - report. + + If there is more than one IPSec connection on a VPN service, the + service will always report as being ACTIVE (whereas, if there is + only one connection, the service will reflect the connection status. + """ + # Simulated one service with two ACTIVE connections + conn1_data = {u'id': u'1', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel1'}} + conn2_data = {u'id': u'2', u'status': constants.ACTIVE, + u'admin_state_up': True, + u'cisco': {u'site_conn_id': u'Tunnel2'}} + service_data = {u'id': u'123', + u'status': constants.ACTIVE, + u'external_ip': u'1.1.1.1', + u'admin_state_up': True, + u'ipsec_conns': [conn1_data, conn2_data]} + vpn_service = self.driver.update_service(self.context, service_data) + # Simulate that the CSR has reported that the connections are DOWN + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel1', u'DOWN-NEGOTIATING'), (u'Tunnel2', u'DOWN')] + + report = self.driver.build_report_for_service(vpn_service) + expected_report = { + u'id': u'123', + u'updated_pending_status': False, + u'status': constants.ACTIVE, + u'ipsec_site_connections': { + u'1': {u'status': constants.DOWN, + u'updated_pending_status': False}, + u'2': {u'status': constants.DOWN, + u'updated_pending_status': False}} + } + self.assertEqual(expected_report, report) + # Check that service and connection statuses are updated + self.assertEqual(constants.ACTIVE, vpn_service.last_status) + self.assertEqual(constants.DOWN, + vpn_service.get_connection(u'1').last_status) + self.assertEqual(constants.DOWN, + vpn_service.get_connection(u'2').last_status) + + def test_report_multiple_services(self): + """Status changes for several services - report.""" + # Simulate creation of the service and connection + (service1_data, + service2_data) = self.notification_for_two_services_with_two_conns() + vpn_service1 = self.driver.update_service(self.context, service1_data) + vpn_service2 = self.driver.update_service(self.context, service2_data) + # Simulate that the CSR has created the connections + self.csr.read_tunnel_statuses.return_value = [ + (u'Tunnel1', u'UP-ACTIVE'), (u'Tunnel2', u'DOWN'), + (u'Tunnel3', u'DOWN-NEGOTIATING'), (u'Tunnel4', u'UP-IDLE')] + + report = self.driver.report_status(self.context) + expected_report = [{u'id': u'123', + u'updated_pending_status': True, + u'status': constants.ACTIVE, + u'ipsec_site_connections': { + u'1': {u'status': constants.ACTIVE, + u'updated_pending_status': True}, + u'2': {u'status': constants.DOWN, + u'updated_pending_status': True}} + }, + {u'id': u'456', + u'updated_pending_status': True, + u'status': constants.ACTIVE, + u'ipsec_site_connections': { + u'3': {u'status': constants.DOWN, + u'updated_pending_status': True}, + u'4': {u'status': constants.ACTIVE, + u'updated_pending_status': True}} + }] + self.assertEqual(expected_report, report) + # Check that service and connection statuses are updated + self.assertEqual(constants.ACTIVE, vpn_service1.last_status) + self.assertEqual(constants.ACTIVE, + vpn_service1.get_connection(u'1').last_status) + self.assertEqual(constants.DOWN, + vpn_service1.get_connection(u'2').last_status) + self.assertEqual(constants.ACTIVE, vpn_service2.last_status) + self.assertEqual(constants.DOWN, + vpn_service2.get_connection(u'3').last_status) + self.assertEqual(constants.ACTIVE, + vpn_service2.get_connection(u'4').last_status) + + # TODO(pcm) FUTURE - UTs for update action, when supported. + + def test_vpnservice_updated(self): + with mock.patch.object(self.driver, 'sync') as sync: + context = mock.Mock() + self.driver.vpnservice_updated(context) + sync.assert_called_once_with(context, []) + + +class TestCiscoCsrIPsecDeviceDriverConfigLoading(base.BaseTestCase): + + def setUp(self): + super(TestCiscoCsrIPsecDeviceDriverConfigLoading, self).setUp() + self.addCleanup(mock.patch.stopall) + + def create_tempfile(self, contents): + (fd, path) = tempfile.mkstemp(prefix='test', suffix='.conf') + try: + os.write(fd, contents.encode('utf-8')) + finally: + os.close(fd) + return path + + def test_loading_csr_configuration(self): + """Ensure that Cisco CSR configs can be loaded from config files.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 10.20.30.1\n' + 'tunnel_ip = 3.2.1.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n') + expected = {'3.2.1.1': {'rest_mgmt': '10.20.30.1', + 'tunnel_ip': '3.2.1.3', + 'username': 'me', + 'password': 'secret', + 'timeout': 5.0}} + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual(expected, csrs_found) + + def test_loading_config_without_timeout(self): + """Cisco CSR config without timeout will use default timeout.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 10.20.30.1\n' + 'tunnel_ip = 3.2.1.3\n' + 'username = me\n' + 'password = secret\n') + expected = {'3.2.1.1': {'rest_mgmt': '10.20.30.1', + 'tunnel_ip': '3.2.1.3', + 'username': 'me', + 'password': 'secret', + 'timeout': csr_client.TIMEOUT}} + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual(expected, csrs_found) + + def test_skip_loading_duplicate_csr_configuration(self): + """Failure test that duplicate configurations are ignored.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 10.20.30.1\n' + 'tunnel_ip = 3.2.1.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n' + '[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 5.5.5.3\n' + 'tunnel_ip = 3.2.1.6\n' + 'username = me\n' + 'password = secret\n') + expected = {'3.2.1.1': {'rest_mgmt': '10.20.30.1', + 'tunnel_ip': '3.2.1.3', + 'username': 'me', + 'password': 'secret', + 'timeout': 5.0}} + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual(expected, csrs_found) + + def test_fail_loading_config_with_invalid_timeout(self): + """Failure test of invalid timeout in config info.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 10.20.30.1\n' + 'tunnel_ip = 3.2.1.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = yes\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_fail_loading_config_missing_required_info(self): + """Failure test of config missing required info.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:1.1.1.0]\n' + 'tunnel_ip = 1.1.1.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n' + '[CISCO_CSR_REST:2.2.2.0]\n' + 'rest_mgmt = 10.20.30.1\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n' + '[CISCO_CSR_REST:3.3.3.0]\n' + 'rest_mgmt = 10.20.30.1\n' + 'tunnel_ip = 3.3.3.3\n' + 'password = secret\n' + 'timeout = 5.0\n' + '[CISCO_CSR_REST:4.4.4.0]\n' + 'rest_mgmt = 10.20.30.1\n' + 'tunnel_ip = 4.4.4.4\n' + 'username = me\n' + 'timeout = 5.0\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_fail_loading_config_with_invalid_router_id(self): + """Failure test of config with invalid rotuer ID.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:4.3.2.1.9]\n' + 'rest_mgmt = 10.20.30.1\n' + 'tunnel_ip = 4.3.2.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_fail_loading_config_with_invalid_mgmt_ip(self): + """Failure test of configuration with invalid management IP address.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 1.1.1.1.1\n' + 'tunnel_ip = 3.2.1.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_fail_loading_config_with_invalid_tunnel_ip(self): + """Failure test of configuration with invalid tunnel IP address.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 1.1.1.1\n' + 'tunnel_ip = 3.2.1.4.5\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_failure_no_configurations_entries(self): + """Failure test config file without any CSR definitions.""" + cfg_file = self.create_tempfile('NO CISCO SECTION AT ALL\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_failure_no_csr_configurations_entries(self): + """Failure test config file without any CSR definitions.""" + cfg_file = self.create_tempfile('[SOME_CONFIG:123]\n' + 'username = me\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_missing_config_value(self): + """Failure test of config file missing a value for attribute.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = \n' + 'tunnel_ip = 3.2.1.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 5.0\n') + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual({}, csrs_found) + + def test_ignores_invalid_attribute_in_config(self): + """Test ignoring of config file with invalid attribute.""" + cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n' + 'rest_mgmt = 1.1.1.1\n' + 'bogus = abcdef\n' + 'tunnel_ip = 3.2.1.3\n' + 'username = me\n' + 'password = secret\n' + 'timeout = 15.5\n') + expected = {'3.2.1.1': {'rest_mgmt': '1.1.1.1', + 'tunnel_ip': '3.2.1.3', + 'username': 'me', + 'password': 'secret', + 'timeout': 15.5}} + csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file]) + self.assertEqual(expected, csrs_found) -- 2.45.2