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 0000000000..d15069b7c9 --- /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 0000000000..3cc3c435f2 --- /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 0000000000..c518491d3e --- /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 3df2fb036b..e1f0760cd6 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 0000000000..3830303030 --- /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 0000000000..920b9e403b --- /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 0000000000..924ff447e6 --- /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)