From 4dde1f78e7b4fc8dd97604c6176a890597a409f6 Mon Sep 17 00:00:00 2001 From: Mark McClain Date: Thu, 24 Mar 2016 22:41:48 +0000 Subject: [PATCH] Ensure VPN settings are more prescriptive. Previously VPN service relied on default behaviours and an open firewall. This specifies more values and ensures the firewall is properly set. Additionally, test coverage is expanded. Closes-Bug:1564213 Change-Id: Iefaccddaad54c412195802f97811722bb593b2ca --- astara_router/defaults.py | 3 + astara_router/drivers/iptables.py | 32 +++++- astara_router/drivers/vpn/ipsec.py | 28 ++++-- .../drivers/vpn/templates/ipsec.conf.j2 | 16 ++- setup.cfg | 5 + test/unit/drivers/test_strongswan.py | 96 ++++++++++++++++++ test/unit/fakes.py | 99 ++++++++++++++++++- test/unit/test_models.py | 94 ++++++++++++++++++ 8 files changed, 362 insertions(+), 11 deletions(-) create mode 100644 test/unit/drivers/test_strongswan.py diff --git a/astara_router/defaults.py b/astara_router/defaults.py index d93b90c..6750100 100644 --- a/astara_router/defaults.py +++ b/astara_router/defaults.py @@ -29,6 +29,9 @@ API_SERVICE = 5000 DHCP = 67 DHCPV6 = 546 +ISAKMP = 500 +IPSEC_NAT_T = 4500 + NFS_DEVELOPMENT = [111, 1110, 2049, 4045] MANAGEMENT_PORTS = [SSH, API_SERVICE] # + NFS_DEVELOPMENT diff --git a/astara_router/drivers/iptables.py b/astara_router/drivers/iptables.py index 673413f..689c1d0 100644 --- a/astara_router/drivers/iptables.py +++ b/astara_router/drivers/iptables.py @@ -159,7 +159,9 @@ class IPTablesManager(base.Manager): return itertools.chain( self._build_default_filter_rules(), self._build_management_filter_rules(config), - self._build_internal_network_filter_rules(config) + self._build_internal_network_filter_rules(config), + self._build_vpn_filter_rules(config), + [Rule('COMMIT')] ) def _build_default_filter_rules(self): @@ -258,7 +260,33 @@ class IPTablesManager(base.Manager): '--state RELATED,ESTABLISHED -j ACCEPT' % ext_if.ifname )) - rules.append(Rule('COMMIT')) + return rules + + def _build_vpn_filter_rules(self, config): + rules = [] + ext_net = self.get_external_network(config) + if ext_net: + ext_if = ext_net.interface + else: + ext_net = None + + if ext_net is None or not config.vpn: + return rules + + template = ( + ('-A INPUT -i %%s -p udp -m udp --dport %d -j ACCEPT ' % + settings.ISAKMP), + ('-A INPUT -i %%s -p udp -m udp --dport %d -j ACCEPT ' % + settings.IPSEC_NAT_T), + '-A INPUT -i %s -p esp -j ACCEPT', + '-A INPUT -i %s -p ah -j ACCEPT' + ) + + for version in (4, 6): + rules.extend( + (Rule(t % ext_if.ifname, ip_version=version) for t in template) + ) + return rules def _build_nat_table(self, config): diff --git a/astara_router/drivers/vpn/ipsec.py b/astara_router/drivers/vpn/ipsec.py index d17b67b..814e77f 100644 --- a/astara_router/drivers/vpn/ipsec.py +++ b/astara_router/drivers/vpn/ipsec.py @@ -22,6 +22,20 @@ from astara_router import utils TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates') +STRONGSWAN_TRANSLATIONS = { + "3des": "3des", + "aes-128": "aes128", + "aes-256": "aes256", + "aes-192": "aes192", + "group2": "modp1024", + "group5": "modp1536", + "group14": "modp2048", + "group15": "modp3072", + "bi-directional": "start", + "response-only": "add", +} + + class StrongswanManager(base.Manager): """ A class to interact with strongswan, an IPSEC VPN daemon. @@ -40,19 +54,21 @@ class StrongswanManager(base.Manager): Writes config file for strongswan daemon. :type config: astara_router.models.Configuration - :param config: - {'ge0': 'eth0', 'ge1': 'eth1'} """ + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(TEMPLATE_DIR) + ) + + env.filters['strongswan'] = lambda v: STRONGSWAN_TRANSLATIONS.get(v, v) + templates = ('ipsec.conf', 'ipsec.secrets') for template_name in templates: - tmpl = jinja2.Template( - open(os.path.join(TEMPLATE_DIR, template_name+'.j2')).read() - ) + tmpl = env.get_template(template_name+'.j2') tmp = os.path.join('/tmp', template_name) - open(tmp, 'w').write(tmpl.render(vpnservices=config.vpn)) + utils.replace_file(tmp, tmpl.render(vpnservices=config.vpn)) for template_name in templates: tmp = os.path.join('/tmp', template_name) diff --git a/astara_router/drivers/vpn/templates/ipsec.conf.j2 b/astara_router/drivers/vpn/templates/ipsec.conf.j2 index a1b6d5e..94a221f 100644 --- a/astara_router/drivers/vpn/templates/ipsec.conf.j2 +++ b/astara_router/drivers/vpn/templates/ipsec.conf.j2 @@ -5,12 +5,12 @@ conn %default keylife=20m rekeymargin=3m keyingtries=1 - authby=psk mobike=no {% for vpnservice in vpnservices %} # Configuration for {{vpnservice.name}} {% for ipsec_site_connection in vpnservice.ipsec_site_connections%} conn {{ipsec_site_connection.id}} + authby=psk keyexchange=ike{{ipsec_site_connection.ikepolicy.ike_version}} left={{vpnservice.get_external_ip(ipsec_site_connection.peer_address)}} leftsubnet={{ipsec_site_connection.local_ep_group.cidrs|join(',')}} @@ -20,6 +20,20 @@ conn {{ipsec_site_connection.id}} rightsubnet={{ipsec_site_connection.peer_ep_group.cidrs|join(',')}} rightid={{ipsec_site_connection.peer_id}} auto=route + dpdaction={{ipsec_site_connection.dpd.action}} + dpddelay={{ipsec_site_connection.dpd.interval}} + dpdtimeout={{ipsec_site_connection.dpd.timeout}} + + # ike + ike={{ipsec_site_connection.ikepolicy.encryption_algorithm|strongswan}}-{{ipsec_site_connection.ikepolicy.auth_algorithm|strongswan}}-{{ipsec_site_connection.ikepolicy.pfs|strongswan}} + ikelifetime={{ipsec_site_connection.ikepolicy.lifetime.value}}s + + # ipsec + {{ipsec_site_connection.ipsecpolicy.transform_protocol}}={{ipsec_site_connection.ikepolicy.encryption_algorithm|strongswan}}-{{ipsec_site_connection.ikepolicy.auth_algorithm|strongswan}}-{{ipsec_site_connection.ikepolicy.pfs|strongswan}} + lifetime={{ipsec_site_connection.ipsecpolicy.lifetime.value}}s + + type={{ipsec_site_connection.ipsecpolicy.encapsulation_mode}} + {% endfor %} {% endfor %} diff --git a/setup.cfg b/setup.cfg index f8ff037..7625025 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,3 +41,8 @@ console_scripts = all_files = 1 build-dir = doc/build source-dir = doc/source + +[nosetests] +verbosity = 2 +detailed-errors = 1 +cover-package = astara_router diff --git a/test/unit/drivers/test_strongswan.py b/test/unit/drivers/test_strongswan.py new file mode 100644 index 0000000..32b3146 --- /dev/null +++ b/test/unit/drivers/test_strongswan.py @@ -0,0 +1,96 @@ +# Copyright 2014 DreamHost, LLC +# +# Author: DreamHost, LLC +# +# 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. + + +from unittest2 import TestCase + +import mock +import netaddr +import re +import textwrap + +from astara_router.drivers.vpn import ipsec + +class StrongswanTestCase(TestCase): + """ + """ + def setUp(self): + self.mock_execute = mock.patch('astara_router.utils.execute').start() + self.mock_replace_file = mock.patch( + 'astara_router.utils.replace_file' + ).start() + self.addCleanup(mock.patch.stopall) + + self.mgr = ipsec.StrongswanManager() + + def test_save_config(self): + mock_config = mock.Mock() + with mock.patch.object(ipsec, 'jinja2') as mock_jinja: + + mock_env = mock_jinja.Environment.return_value + mock_get_template = mock_env.get_template + mock_render_rv = mock_get_template.return_value.render.return_value + + self.mgr.save_config(mock_config) + + mock_get_template.assert_has_calls([ + mock.call('ipsec.conf.j2'), + mock.call().render(vpnservices=mock_config.vpn), + mock.call('ipsec.secrets.j2'), + mock.call().render(vpnservices=mock_config.vpn), + ]) + + self.mock_replace_file.assert_has_calls([ + mock.call('/tmp/ipsec.conf', mock_render_rv), + mock.call('/tmp/ipsec.secrets', mock_render_rv), + ]) + + sudo = 'sudo astara-rootwrap /etc/rootwrap.conf' + + self.mock_execute.assert_has_calls([ + mock.call(['mv','/tmp/ipsec.conf', '/etc/ipsec.conf'], sudo), + mock.call( + ['mv', '/tmp/ipsec.secrets', '/etc/ipsec.secrets'], + sudo + ), + ]) + + def test_restart(self): + self.mgr.restart() + self.mock_execute.assert_has_calls([ + mock.call(['service', 'strongswan', 'status'], + 'sudo astara-rootwrap /etc/rootwrap.conf'), + mock.call(['service', 'strongswan', 'reload'], + 'sudo astara-rootwrap /etc/rootwrap.conf'), + ]) + + def test_restart_failure(self): + with mock.patch('astara_router.utils.execute') as execute: + execute.side_effect = [Exception('status failed!'), None] + self.mgr.restart() + execute.assert_has_calls([ + mock.call(['service', 'strongswan', 'status'], + 'sudo astara-rootwrap /etc/rootwrap.conf'), + mock.call(['service', 'strongswan', 'start'], + 'sudo astara-rootwrap /etc/rootwrap.conf'), + ]) + + def test_stop(self): + self.mgr.stop() + self.mock_execute.assert_has_calls([ + mock.call(['service', 'strongswan', 'stop'], + 'sudo astara-rootwrap /etc/rootwrap.conf'), + ]) diff --git a/test/unit/fakes.py b/test/unit/fakes.py index b603c49..63b5db5 100644 --- a/test/unit/fakes.py +++ b/test/unit/fakes.py @@ -16,13 +16,15 @@ FAKE_SYSTEM_DICT = { "allocations": [], "subnets": [ { + "id": "98a6270e-cf5f-4a60-9d7f-0d4524c00606", "host_routes": [], "cidr": "192.168.0.0/24", "gateway_ip": "192.168.0.1", "dns_nameservers": [], - "dhcp_enabled": True + "dhcp_enabled": True, }, { + "id": "ext-subnet-id", "host_routes": [], "cidr": "fdd6:a1fa:cfa8:6af6::/64", "gateway_ip": "fdd6:a1fa:cfa8:6af6::1", @@ -45,6 +47,7 @@ FAKE_SYSTEM_DICT = { "allocations": [], "subnets": [ { + "id": "mgt-subnet-id", "host_routes": [], "cidr": "fdca:3ba5:a17a:acda::/64", "gateway_ip": "fdca:3ba5:a17a:acda::1", @@ -108,7 +111,6 @@ FAKE_POOL_DICT = { 'tenant_id': u'd22b149cee9b4eac8349c517eda00b89' } - FAKE_MEMBER_DICT = { 'address': u'192.168.0.194', 'admin_state_up': True, @@ -119,6 +121,99 @@ FAKE_MEMBER_DICT = { 'weight': 1 } +FAKE_LIFETIME_DICT = { + 'units': u'seconds', + 'value': 3600, +} + +FAKE_DEAD_PEER_DETECTION_DICT = { + 'action': u'hold', + 'interval': 30, + 'timeout': 120 +} + +FAKE_IKEPOLICY_DICT = { + 'auth_algorithm': u'sha1', + 'encryption_algorithm': u'aes-128', + 'id': u'2b7dddc7-721f-4b93-bff3-20a7ff765726', + 'ike_version': u'v1', + 'lifetime': FAKE_LIFETIME_DICT, + 'name': u'ikepolicy1', + 'pfs': u'group5', + 'phase1_negotiation_mode': u'main', + 'tenant_id': u'd01558034b144068a4884fa7d8c03cc8' +} + +FAKE_IPSECPOLICY_DICT = { + 'auth_algorithm': u'sha1', + 'encapsulation_mode': u'tunnel', + 'encryption_algorithm': u'aes-128', + 'id': u'48f7ab18-f900-4ebe-9ef6-b1cc675f4e51', + 'lifetime': FAKE_LIFETIME_DICT, + 'name': u'ipsecpolicy1', + 'pfs': u'group5', + 'tenant_id': u'd01558034b144068a4884fa7d8c03cc8', + 'transform_protocol': u'esp' +} + +FAKE_LOCAL_ENDPOINT_DICT = { + 'endpoints': [u'98a6270e-cf5f-4a60-9d7f-0d4524c00606'], + 'id': u'3fbb0b1f-3fbe-4f97-9ec7-eba7f6009b94', + 'name': u'local', + 'tenant_id': u'd01558034b144068a4884fa7d8c03cc8', + 'type': u'subnet' +} + +FAKE_PEER_ENDPOINT_DICT = { + 'endpoints': ['172.31.155.0/24'], + 'id': u'dc15b31c-54a6-4b83-a4b0-7a6b136bbb5b', + 'name': u'peer', + 'tenant_id': u'd01558034b144068a4884fa7d8c03cc8', + 'type': u'cidr' +} + +FAKE_IPSEC_CONNECTION_DICT = { + 'admin_state_up': True, + 'auth_mode': u'psk', + 'dpd': FAKE_DEAD_PEER_DETECTION_DICT, + 'id': u'bfb6da63-7979-405d-9193-eda5601cf74b', + 'ikepolicy': FAKE_IKEPOLICY_DICT, + 'initiator': u'bi-directional', + 'ipsecpolicy': FAKE_IPSECPOLICY_DICT, + 'local_ep_group': FAKE_LOCAL_ENDPOINT_DICT, + 'mtu': 1420, + 'name': u'theconn', + 'peer_address': '172.24.4.129', + 'peer_cidrs': [], + 'peer_ep_group': FAKE_PEER_ENDPOINT_DICT, + 'peer_id': u'172.24.4.129', + 'psk': u'secrete', + 'route_mode': u'static', + 'status': u'PENDING_CREATE', + 'tenant_id': u'd01558034b144068a4884fa7d8c03cc8', + 'vpnservice_id': u'1d5ff89a-d03f-4d57-b696-34ef5c53ae28' +} + +FAKE_IPSEC_VPNSERVICE_DICT = { + 'admin_state_up': True, + 'external_v4_ip': '172.24.4.2', + 'external_v6_ip': '2001:db8::1', + 'id': u'1d5ff89a-d03f-4d57-b696-34ef5c53ae28', + 'ipsec_connections': [FAKE_IPSEC_CONNECTION_DICT], + 'name': u'thevpn', + 'router_id': u'3d6d9ede-9b20-4610-9804-54ce1ef2bb43', + 'status': u'PENDING_CREATE', + 'subnet_id': None +} + +FAKE_VPN_DICT = { + 'vpn': { + 'ipsec': [FAKE_IPSEC_VPNSERVICE_DICT] + } +} + +FAKE_SYSTEM_WITH_VPN_DICT = dict(FAKE_SYSTEM_DICT, vpn=FAKE_VPN_DICT['vpn']) + def fake_loadbalancer_dict(listener=False, pool=False, members=False): lb_dict = copy(FAKE_LOADBALANCER_DICT) diff --git a/test/unit/test_models.py b/test/unit/test_models.py index 05ae013..c1c679b 100644 --- a/test/unit/test_models.py +++ b/test/unit/test_models.py @@ -706,3 +706,97 @@ class LoadBalancerConfigurationTest(TestCase): errors = lb_conf.validate() # id is required self.assertEqual(len(errors), 1) + + +class VPNModelsTest(TestCase): + def _test_model(self, model, config_dict, skip_keys=()): + instance = model.from_dict(config_dict) + for k in config_dict.keys(): + if k in skip_keys: + continue + + self.assertEqual(getattr(instance, k), config_dict[k]) + + return instance + + def test_lifetime_model(self): + self._test_model(models.Lifetime, fakes.FAKE_LIFETIME_DICT) + + def test_dead_peer_model(self): + self._test_model( + models.DeadPeerDetection, + fakes.FAKE_DEAD_PEER_DETECTION_DICT + ) + + def test_endpoint_model_local(self): + self._test_model(models.EndpointGroup, fakes.FAKE_LOCAL_ENDPOINT_DICT) + + def test_endpoint_model_peer(self): + conf_dict = fakes.FAKE_PEER_ENDPOINT_DICT + ep = self._test_model(models.EndpointGroup, conf_dict, ['endpoints']) + + self.assertEqual( + ep.endpoints, + [netaddr.IPNetwork(conf_dict['endpoints'][0])] + ) + + def _test_policy_model(self, config_dict, model): + with mock.patch.object(models, 'Lifetime') as mock_life: + self._test_model(model, config_dict, ['lifetime']) + mock_life.from_dict.assert_called_once_with(config_dict['lifetime']) + + def test_ikepolicy_model(self): + return self._test_policy_model( + fakes.FAKE_IKEPOLICY_DICT, + models.IkePolicy + ) + + def test_ipsecpolicy_model(self): + return self._test_policy_model( + fakes.FAKE_IPSECPOLICY_DICT, + models.IpsecPolicy + ) + + + def test_ipsec_site_connection_model(self): + config_dict = fakes.FAKE_IPSEC_CONNECTION_DICT + + skip_keys = [ + 'dpd', + 'ikepolicy', + 'ipsecpolicy', + 'local_ep_group', + 'peer_ep_group', + 'peer_address' + ] + + conn = self._test_model( + models.IpsecSiteConnection, + config_dict, + skip_keys + ) + + self.assertEqual( + conn.peer_address, + netaddr.IPAddress(config_dict['peer_address']) + ) + + self.assertIsInstance(conn.local_ep_group, models.EndpointGroup) + self.assertEqual(conn.local_ep_group.name, 'local') + self.assertIsInstance(conn.peer_ep_group, models.EndpointGroup) + self.assertEqual(conn.peer_ep_group.name, 'peer') + self.assertIsInstance(conn.dpd, models.DeadPeerDetection) + self.assertIsInstance(conn.ikepolicy, models.IkePolicy) + self.assertIsInstance(conn.ipsecpolicy, models.IpsecPolicy) + + def test_vpnservice_model(self): + config_dict = fakes.FAKE_IPSEC_VPNSERVICE_DICT + + vpn = self._test_model( + models.VpnService, + config_dict, + ['ipsec_connections', 'external_v4_ip', 'external_v6_ip'] + ) + + self.assertEqual(vpn.external_v4_ip, netaddr.IPAddress('172.24.4.2')) + self.assertEqual(vpn.external_v6_ip, netaddr.IPAddress('2001:db8::1'))