From 920954e31d47a5e51ec476973f928203dd814c8a Mon Sep 17 00:00:00 2001 From: Mark McClain Date: Mon, 14 Mar 2016 19:08:00 -0400 Subject: [PATCH] Add support for StrongSwan VPN to router This change adds Strongswan to support VPNaaS in appliance. Change-Id: I1adb74c159eaf4f62950d17ed015856e90a91641 Partial-Blueprint: neutron-vpnaas --- ansible/main.yml | 3 + ansible/tasks/astara.yml | 6 +- ansible/tasks/strongswan.yml | 9 + astara_router/drivers/vpn/__init__.py | 0 astara_router/drivers/vpn/ipsec.py | 89 +++++++ astara_router/drivers/vpn/templates/README | 3 + .../drivers/vpn/templates/ipsec.conf.j2 | 25 ++ .../drivers/vpn/templates/ipsec.secrets.j2 | 6 + astara_router/manager.py | 11 + astara_router/models.py | 233 +++++++++++++++++- etc/rootwrap.d/network.filters | 3 + test/unit/api/v1/test_system.py | 3 +- test/unit/drivers/test_arp.py | 1 + test/unit/drivers/test_iptables.py | 2 + test/unit/drivers/test_route.py | 9 + test/unit/test_models.py | 19 +- 16 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 ansible/tasks/strongswan.yml create mode 100644 astara_router/drivers/vpn/__init__.py create mode 100644 astara_router/drivers/vpn/ipsec.py create mode 100644 astara_router/drivers/vpn/templates/README create mode 100644 astara_router/drivers/vpn/templates/ipsec.conf.j2 create mode 100644 astara_router/drivers/vpn/templates/ipsec.secrets.j2 diff --git a/ansible/main.yml b/ansible/main.yml index b329bd4..c75ba96 100644 --- a/ansible/main.yml +++ b/ansible/main.yml @@ -6,6 +6,8 @@ bird_enable: False bird6_enable: True bird_enable_service: True + strongswan_enable: True + strongswan_enable_service: False dnsmasq_conf_dir: /etc/dnsmasq.d dnsmasq_conf_file: /etc/dnsmasq.conf install_extras: False @@ -22,6 +24,7 @@ - include: tasks/astara.yml - include: tasks/bird.yml - include: tasks/conntrackd.yml + - include: tasks/strongswan.yml - include: tasks/dnsmasq.yml - include: tasks/extras.yml when: install_extras diff --git a/ansible/tasks/astara.yml b/ansible/tasks/astara.yml index e2fadb5..a0c179d 100644 --- a/ansible/tasks/astara.yml +++ b/ansible/tasks/astara.yml @@ -29,8 +29,9 @@ - name: install gunicorn config file template: src=gunicorn.j2 dest=/etc/astara_gunicorn_config.py + - name: add gunicorn user - command: useradd -r gunicorn + action: user name=gunicorn state=present - name: install init.d files copy: src={{playbook_dir}}/../scripts/etc/init.d/{{item}} dest=/etc/init.d/{{item}} mode=0555 @@ -90,4 +91,7 @@ command: apt-get -y autoremove when: do_cleanup +- name: Ensure gunicorn is restarted + service: name=astara-router-api-server state=restarted enabled=yes + diff --git a/ansible/tasks/strongswan.yml b/ansible/tasks/strongswan.yml new file mode 100644 index 0000000..ead5d1b --- /dev/null +++ b/ansible/tasks/strongswan.yml @@ -0,0 +1,9 @@ +--- + +- name: install strongswan + apt: name=strongswan state=installed install_recommends=yes + when: strongswan_enable + +- name: Ensure strongswan is started + service: name=strongswan state=started enabled=yes + when: strongswan_enable and strongswan_enable_service diff --git a/astara_router/drivers/vpn/__init__.py b/astara_router/drivers/vpn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astara_router/drivers/vpn/ipsec.py b/astara_router/drivers/vpn/ipsec.py new file mode 100644 index 0000000..d17b67b --- /dev/null +++ b/astara_router/drivers/vpn/ipsec.py @@ -0,0 +1,89 @@ +# Copyright 2016 Akanda, Inc +# +# 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. + +import os + +import jinja2 + +from astara_router.drivers import base +from astara_router import utils + +TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates') + + +class StrongswanManager(base.Manager): + """ + A class to interact with strongswan, an IPSEC VPN daemon. + """ + def __init__(self, root_helper='sudo astara-rootwrap /etc/rootwrap.conf'): + """ + Initializes StrongswanManager class. + + :type root_helper: string + :param root_helper: System utility used to gain escalate privileges. + """ + super(StrongswanManager, self).__init__(root_helper) + + def save_config(self, config): + """ + Writes config file for strongswan daemon. + + :type config: astara_router.models.Configuration + :param config: + {'ge0': 'eth0', 'ge1': 'eth1'} + """ + + templates = ('ipsec.conf', 'ipsec.secrets') + + for template_name in templates: + tmpl = jinja2.Template( + open(os.path.join(TEMPLATE_DIR, template_name+'.j2')).read() + ) + + tmp = os.path.join('/tmp', template_name) + open(tmp, 'w').write(tmpl.render(vpnservices=config.vpn)) + + for template_name in templates: + tmp = os.path.join('/tmp', template_name) + etc = os.path.join('/etc', template_name) + utils.execute(['mv', tmp, etc], self.root_helper) + + def restart(self): + """ + Restart the Strongswan daemon using the system provided init scripts. + """ + try: + utils.execute( + ['service', 'strongswan', 'status'], + self.root_helper + ) + except: # pragma no cover + utils.execute(['service', 'strongswan', 'start'], self.root_helper) + else: # pragma no cover + utils.execute( + ['service', 'strongswan', 'reload'], + self.root_helper + ) + + def stop(self): + """ + Stop the Strongswan daemon using the system provided init scripts. + """ + try: + utils.execute( + ['service', 'strongswan', 'stop'], + self.root_helper + ) + except: # pragma no cover + pass diff --git a/astara_router/drivers/vpn/templates/README b/astara_router/drivers/vpn/templates/README new file mode 100644 index 0000000..8664e8f --- /dev/null +++ b/astara_router/drivers/vpn/templates/README @@ -0,0 +1,3 @@ +These templates were originally copied from the upstream neutron-vpaas [1]. + +[1] http://git.openstack.org/cgit/openstack/neutron-vpnaas/tree/neutron_vpnaas/services/vpn/device_drivers/template/strongswan diff --git a/astara_router/drivers/vpn/templates/ipsec.conf.j2 b/astara_router/drivers/vpn/templates/ipsec.conf.j2 new file mode 100644 index 0000000..a1b6d5e --- /dev/null +++ b/astara_router/drivers/vpn/templates/ipsec.conf.j2 @@ -0,0 +1,25 @@ +config setup + +conn %default + ikelifetime=60m + 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}} + 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(',')}} + leftid={{vpnservice.get_external_ip(ipsec_site_connection.peer_address)}} + leftfirewall=yes + right={{ipsec_site_connection.peer_address}} + rightsubnet={{ipsec_site_connection.peer_ep_group.cidrs|join(',')}} + rightid={{ipsec_site_connection.peer_id}} + auto=route +{% endfor %} +{% endfor %} + diff --git a/astara_router/drivers/vpn/templates/ipsec.secrets.j2 b/astara_router/drivers/vpn/templates/ipsec.secrets.j2 new file mode 100644 index 0000000..8240b47 --- /dev/null +++ b/astara_router/drivers/vpn/templates/ipsec.secrets.j2 @@ -0,0 +1,6 @@ +{% for vpnservice in vpnservices %} +# Configuration for {{vpnservice.name}} +{% for ipsec_site_connection in vpnservice.ipsec_site_connections %} +{{ipsec_site_connection.external_ip}} {{ipsec_site_connection.peer_id}} : PSK "{{ipsec_site_connection.psk}}" +{% endfor %} +{% endfor %} diff --git a/astara_router/manager.py b/astara_router/manager.py index 3f14864..78e46fe 100644 --- a/astara_router/manager.py +++ b/astara_router/manager.py @@ -22,6 +22,7 @@ from astara_router import models from astara_router import settings from astara_router.drivers import (bird, conntrackd, dnsmasq, ip, metadata, iptables, arp, hostname, loadbalancer) +from astara_router.drivers.vpn import ipsec class ServiceManagerBase(object): @@ -114,6 +115,7 @@ class RouterManager(ServiceManagerBase): self.update_routes(cache) self.update_arp() self.update_conntrackd() + self.update_ipsec_vpn() self.reload_config() def update_conntrackd(self): @@ -162,6 +164,15 @@ class RouterManager(ServiceManagerBase): ) mgr.remove_stale_entries(self._config) + def update_ipsec_vpn(self): + mgr = ipsec.StrongswanManager() + + if self._config.vpn: + mgr.save_config(self._config) + mgr.restart() + else: + mgr.stop() + def get_interfaces(self): return self.ip_mgr.get_interfaces() diff --git a/astara_router/models.py b/astara_router/models.py index 7867ed5..e4f6e09 100644 --- a/astara_router/models.py +++ b/astara_router/models.py @@ -16,6 +16,7 @@ import abc +import itertools import re import netaddr @@ -313,8 +314,9 @@ class Label(ModelBase): class Subnet(ModelBase): - def __init__(self, cidr, gateway_ip, dhcp_enabled=True, + def __init__(self, id_, cidr, gateway_ip, dhcp_enabled=True, dns_nameservers=None, host_routes=None): + self.id = id_ self.cidr = cidr self.gateway_ip = gateway_ip self.dhcp_enabled = bool(dhcp_enabled) @@ -353,6 +355,7 @@ class Subnet(ModelBase): host_routes = [StaticRoute(r['destination'], r['nexthop']) for r in d.get('host_routes', [])] return cls( + d['id'], d['cidr'], d['gateway_ip'], d['dhcp_enabled'], @@ -663,6 +666,211 @@ class FixedIp(ModelBase): return dict((f, getattr(self, f)) for f in fields) +class DeadPeerDetection(ModelBase): + def __init__(self, action, interval, timeout): + self.action = action + self.interval = interval + self.timeout = timeout + + @classmethod + def from_dict(cls, d): + return cls( + d['action'], + d['interval'], + d['timeout'] + ) + + +class Lifetime(ModelBase): + def __init__(self, units, value): + self.units = units + self.value = value + + @classmethod + def from_dict(cls, d): + return cls( + d['units'], + d['value'] + ) + + +class EndpointGroup(ModelBase): + def __init__(self, id_, tenant_id, name, type_, endpoints=()): + self.id = id_ + self.tenant_id = tenant_id + self.name = name + self.type = type_ + if type_ == 'cidr': + self.endpoints = [netaddr.IPNetwork(ep) for ep in endpoints] + else: + self.endpoints = endpoints + self.subnet_map = {} + + @property + def cidrs(self): + if self.type == 'subnet': + return [ + self.subnet_map[ep].cidr + for ep in self.endpoints + if ep in self.subnet_map + ] + else: + return self.endpoints + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['tenant_id'], + d['name'], + d['type'], + d['endpoints'] + ) + + +class IkePolicy(ModelBase): + def __init__(self, id_, tenant_id, name, ike_version, auth_algorithm, + encryption_algorithm, pfs, phase1_negotiation_mode, lifetime): + self.id = id_ + self.tenant_id = tenant_id + self.name = name + self.ike_version = ike_version + self.auth_algorithm = auth_algorithm + self.encryption_algorithm = encryption_algorithm + self.pfs = pfs + self.phase1_negotiation_mode = phase1_negotiation_mode + self.lifetime = lifetime + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['tenant_id'], + d['name'], + d['ike_version'], + d['auth_algorithm'], + d['encryption_algorithm'], + d['pfs'], + d['phase1_negotiation_mode'], + Lifetime.from_dict(d['lifetime']) + ) + + +class IpsecPolicy(ModelBase): + def __init__(self, id_, tenant_id, name, transform_protocol, + auth_algorithm, encryption_algorithm, encapsulation_mode, + lifetime, pfs): + self.id = id_ + self.tenant_id = tenant_id + self.name = name + self.transform_protocol = transform_protocol + self.auth_algorithm = auth_algorithm + self.encryption_algorithm = encryption_algorithm + self.encapsulation_mode = encapsulation_mode + self.lifetime = lifetime + self.pfs = pfs + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['tenant_id'], + d['name'], + d['transform_protocol'], + d['auth_algorithm'], + d['encryption_algorithm'], + d['encapsulation_mode'], + Lifetime.from_dict(d['lifetime']), + d['pfs'] + ) + + +class IpsecSiteConnection(ModelBase): + def __init__(self, id_, tenant_id, name, peer_address, peer_id, + admin_state_up, route_mode, mtu, initiator, auth_mode, psk, + dpd, status, vpnservice_id, local_ep_group=None, + peer_ep_group=None, peer_cidrs=[], ikepolicy=None, + ipsecpolicy=None): + self.id = id_ + self.tenant_id = tenant_id + self.name = name + self.peer_address = netaddr.IPAddress(peer_address) + self.peer_id = peer_id + self.route_mode = route_mode + self.mtu = mtu + self.initiator = initiator + self.auth_mode = auth_mode + self.psk = psk + self.dpd = dpd + self.status = status + self.admin_state_up = admin_state_up + self.vpnservice_id = vpnservice_id + self.ipsecpolicy = ipsecpolicy + self.ikepolicy = ikepolicy + self.local_ep_group = local_ep_group + self.peer_ep_group = peer_ep_group + self.peer_cidrs = [netaddr.IPNetwork(pc) for pc in peer_cidrs] + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['tenant_id'], + d['name'], + d['peer_address'], + d['peer_id'], + d['admin_state_up'], + d['route_mode'], + d['mtu'], + d['initiator'], + d['auth_mode'], + d['psk'], + DeadPeerDetection.from_dict(d['dpd']), + d['status'], + d['vpnservice_id'], + peer_cidrs=d['peer_cidrs'], + ikepolicy=IkePolicy.from_dict(d['ikepolicy']), + ipsecpolicy=IpsecPolicy.from_dict(d['ipsecpolicy']), + local_ep_group=EndpointGroup.from_dict(d['local_ep_group']), + peer_ep_group=EndpointGroup.from_dict(d['peer_ep_group']), + ) + + +class VpnService(ModelBase): + def __init__(self, id_, name, status, admin_state_up, external_v4_ip, + external_v6_ip, router_id, subnet_id=None, + ipsec_site_connections=()): + self.id = id_ + self.name = name + self.status = status + self.admin_state_up = admin_state_up + self.external_v4_ip = netaddr.IPAddress(external_v4_ip) + self.external_v6_ip = netaddr.IPAddress(external_v6_ip) + self.router_id = router_id + self.subnet_id = subnet_id + self.ipsec_site_connections = ipsec_site_connections + + def get_external_ip(self, peer_ip): + if peer_ip.version == '6': + return self.external_v6_ip + else: + return self.external_v4_ip + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['name'], + d['status'], + d['admin_state_up'], + d['external_v4_ip'], + d['external_v6_ip'], + d['router_id'], + d.get('subnet_id'), + [IpsecSiteConnection.from_dict(c) for c in d['ipsec_connections']] + ) + + class SystemConfiguration(ModelBase): service_name = 'system' @@ -740,6 +948,13 @@ class RouterConfiguration(SystemConfiguration): self._attach_floating_ips(self.floating_ips) + self.vpn = [ + VpnService.from_dict(s) + for s in conf_dict.get('vpn', {}).get('ipsec', []) + ] + + self._link_subnets() + def validate(self): """Validate anchor rules to ensure that ifaces and tables exist.""" interfaces = set(n.interface.ifname for n in self.networks) @@ -788,8 +1003,22 @@ class RouterConfiguration(SystemConfiguration): if fip.fixed_ip in int_cidr: fip.network = net + def _link_subnets(self): + subnet_map = {} + for n in self.networks: + for s in n.subnets: + subnet_map[s.id] = s + + vpn_conn_generator = (v.ipsec_site_connections for v in self.vpn) + + for conn in itertools.chain.from_iterable(vpn_conn_generator): + if conn.local_ep_group.type == 'subnet': + conn.local_ep_group.subnet_map = subnet_map + def to_dict(self): - fields = ('networks', 'address_book', 'anchors', 'static_routes') + fields = ( + 'networks', 'address_book', 'anchors', 'static_routes', 'vpn' + ) return dict((f, getattr(self, f)) for f in fields) @property diff --git a/etc/rootwrap.d/network.filters b/etc/rootwrap.d/network.filters index 4ba983d..235f69f 100644 --- a/etc/rootwrap.d/network.filters +++ b/etc/rootwrap.d/network.filters @@ -41,5 +41,8 @@ ip6tables: CommandFilter, ip6tables, root # astara_router/drivers/metadata.py: mv_metadata: RegExpFilter, mv, root, mv, /tmp/metadata\.conf, /etc/metadata\.conf +# astara_router/drivers/vpn/ipsec.py: +mv_strongswan: RegExpFilter, mv, root, mv, /tmp/ipsec.*, /etc/ipsec.* + # astara services services: CommandFilter, service, root diff --git a/test/unit/api/v1/test_system.py b/test/unit/api/v1/test_system.py index 7f3a373..c31d24f 100644 --- a/test/unit/api/v1/test_system.py +++ b/test/unit/api/v1/test_system.py @@ -114,7 +114,8 @@ class SystemAPITestCase(unittest.TestCase): 'interfaces': [], 'management_address': None, 'tenant_id': None - } + }, + 'vpn': [], } } self.assertEqual(json.loads(result.data), expected) diff --git a/test/unit/drivers/test_arp.py b/test/unit/drivers/test_arp.py index 7fc7d80..174130a 100644 --- a/test/unit/drivers/test_arp.py +++ b/test/unit/drivers/test_arp.py @@ -103,6 +103,7 @@ class ARPTest(unittest2.TestCase): 'name': 'ext', }, 'subnets': [{ + 'id': 'theid', 'cidr': '172.16.77.0/24', 'gateway_ip': '172.16.77.1', 'dhcp_enabled': True, diff --git a/test/unit/drivers/test_iptables.py b/test/unit/drivers/test_iptables.py index 391d92b..d032fd9 100644 --- a/test/unit/drivers/test_iptables.py +++ b/test/unit/drivers/test_iptables.py @@ -31,6 +31,7 @@ CONFIG = models.RouterConfiguration({ 'name': 'ext', 'network_type': models.Network.TYPE_EXTERNAL, 'subnets': [{ + 'id': 'theid', 'cidr': '172.16.77.0/24', 'gateway_ip': '172.16.77.1', 'dhcp_enabled': True, @@ -48,6 +49,7 @@ CONFIG = models.RouterConfiguration({ 'name': 'internal', 'network_type': models.Network.TYPE_INTERNAL, 'subnets': [{ + 'id': 'theid', 'cidr': '192.168.0.0/24', 'gateway_ip': '192.168.0.1', 'dhcp_enabled': True, diff --git a/test/unit/drivers/test_route.py b/test/unit/drivers/test_route.py index 8e7e9e1..db020f4 100644 --- a/test/unit/drivers/test_route.py +++ b/test/unit/drivers/test_route.py @@ -176,6 +176,7 @@ class RouteTest(unittest2.TestCase): def test_update_default_v4_from_subnet(self): subnet = dict( + id='theid', cidr='192.168.89.0/24', gateway_ip='192.168.89.1', dhcp_enabled=True, @@ -198,12 +199,14 @@ class RouteTest(unittest2.TestCase): def test_update_multiple_v4_subnets(self): subnet = dict( + id='id-1', cidr='192.168.89.0/24', gateway_ip='192.168.89.1', dhcp_enabled=True, dns_nameservers=[], ) subnet2 = dict( + id='id-2', cidr='192.168.71.0/24', gateway_ip='192.168.71.1', dhcp_enabled=True, @@ -226,6 +229,7 @@ class RouteTest(unittest2.TestCase): def test_update_default_v6(self): subnet = dict( + id='theid', cidr='fe80::1/64', gateway_ip='fe80::1', dhcp_enabled=True, @@ -248,12 +252,14 @@ class RouteTest(unittest2.TestCase): def test_update_default_multiple_v6(self): subnet = dict( + id='id-1', cidr='fe80::1/64', gateway_ip='fe80::1', dhcp_enabled=True, dns_nameservers=[], ) subnet2 = dict( + id='id-2', cidr='fe89::1/64', gateway_ip='fe89::1', dhcp_enabled=True, @@ -278,6 +284,7 @@ class RouteTest(unittest2.TestCase): lambda *a, **kw: None) def test_custom_host_routes(self): subnet = dict( + id='theid', cidr='192.168.89.0/24', gateway_ip='192.168.89.1', dhcp_enabled=True, @@ -367,6 +374,7 @@ class RouteTest(unittest2.TestCase): self.assertEqual(len(cache.get('host_routes')), 1) sudo.reset_mock() network['subnets'].append(dict( + id='add-1', cidr='192.168.90.0/24', gateway_ip='192.168.90.1', dhcp_enabled=True, @@ -402,6 +410,7 @@ class RouteTest(unittest2.TestCase): def test_custom_host_routes_failure(self): subnet = dict( + id='theid', cidr='192.168.89.0/24', gateway_ip='192.168.89.1', dhcp_enabled=True, diff --git a/test/unit/test_models.py b/test/unit/test_models.py index 0c3188d..05ae013 100644 --- a/test/unit/test_models.py +++ b/test/unit/test_models.py @@ -289,9 +289,16 @@ class StaticRouteTestCase(TestCase): class SubnetTestCase(TestCase): def test_subnet(self): - s = models.Subnet('192.168.1.0/24', '192.168.1.1', True, ['8.8.8.8'], - []) + s = models.Subnet( + 'id', + '192.168.1.0/24', + '192.168.1.1', + True, + ['8.8.8.8'], + [] + ) + self.assertEqual(s.id, 'id') self.assertEqual(s.cidr, netaddr.IPNetwork('192.168.1.0/24')) self.assertEqual(s.gateway_ip, netaddr.IPAddress('192.168.1.1')) self.assertTrue(s.dhcp_enabled) @@ -299,12 +306,12 @@ class SubnetTestCase(TestCase): self.assertEqual(s.host_routes, []) def test_gateway_ip_empty(self): - s = models.Subnet('192.168.1.0/24', '', True, ['8.8.8.8'], + s = models.Subnet('id', '192.168.1.0/24', '', True, ['8.8.8.8'], []) self.assertIsNone(s.gateway_ip) def test_gateway_ip_none(self): - s = models.Subnet('192.168.1.0/24', None, True, ['8.8.8.8'], + s = models.Subnet('id', '192.168.1.0/24', None, True, ['8.8.8.8'], []) self.assertIsNone(s.gateway_ip) @@ -365,6 +372,7 @@ class NetworkTestCase(TestCase): class RouterConfigurationTestCase(TestCase): def test_init_only_networks(self): subnet = dict( + id='id', cidr='192.168.1.0/24', gateway_ip='192.168.1.1', dhcp_enabled=True, @@ -542,7 +550,8 @@ class RouterConfigurationTestCase(TestCase): expected = dict(networks=[], address_book={}, static_routes=[], - anchors=[]) + anchors=[], + vpn=[]) self.assertEqual(c.to_dict(), expected)