From ccb3f8845fe2365714a204c6cb8bdc03e3dfdb16 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 7 Aug 2014 10:26:26 -0700 Subject: [PATCH] Convert route management from BSD `route` to Linux `/sbin/ip` --- akanda/router/drivers/ip.py | 138 ++++++++++++++++++++++++++ akanda/router/drivers/route.py | 148 ---------------------------- akanda/router/manager.py | 7 +- test/unit/drivers/test_route.py | 167 ++++++++++++++++++-------------- 4 files changed, 233 insertions(+), 227 deletions(-) delete mode 100644 akanda/router/drivers/route.py diff --git a/akanda/router/drivers/ip.py b/akanda/router/drivers/ip.py index da1d1a2..5f1661a 100644 --- a/akanda/router/drivers/ip.py +++ b/akanda/router/drivers/ip.py @@ -16,6 +16,7 @@ import functools +import logging import re import netaddr @@ -23,6 +24,8 @@ import netaddr from akanda.router import models from akanda.router.drivers import base +LOG = logging.getLogger(__name__) + GENERIC_IFNAME = 'ge' PHYSICAL_INTERFACES = ['lo', 'eth', 'em', 're', 'en', 'vio', 'vtnet'] @@ -158,6 +161,141 @@ class IPManager(base.Manager): self.update_interface(primary) return ip_str + def update_default_gateway(self, config): + # Track whether we have set the default gateways, by IP + # version. + gw_set = { + 4: False, + 6: False, + } + + ifname = None + for net in config.networks: + if not net.is_external_network: + continue + ifname = net.interface.ifname + + # The default v4 gateway is pulled out as a special case + # because we only want one but we might have multiple v4 + # subnets on the external network. However, sometimes the RUG + # can't figure out what that value is, because it thinks we + # don't have any external IP addresses, yet. In that case, it + # doesn't give us a default. + if config.default_v4_gateway: + self._set_default_gateway(config.default_v4_gateway, ifname) + gw_set[4] = True + + # Look through our networks and make sure we have a default + # gateway set for each IP version, if we have an IP for that + # version on the external net. If we haven't already set the + # v4 gateway, this picks the gateway for the first subnet we + # find, which might be wrong. + for net in config.networks: + if not net.is_external_network: + continue + + for subnet in net.subnets: + if subnet.gateway_ip and not gw_set[subnet.gateway_ip.version]: + self._set_default_gateway( + subnet.gateway_ip, + net.interface.ifname + ) + gw_set[subnet.gateway_ip.version] = True + + def update_host_routes(self, config, cache): + db = cache.get_or_create('host_routes', lambda: {}) + for net in config.networks: + + # For each subnet... + for subnet in net.subnets: + cidr = str(subnet.cidr) + + # determine the set of previously written routes for this cidr + if cidr not in db: + db[cidr] = set() + + current = db[cidr] + + # build a set of new routes for this cidr + latest = set() + for r in subnet.host_routes: + latest.add((r.destination, r.next_hop)) + + # If the set of previously written routes contains routes that + # aren't defined in the new config, run commands to delete them + for x in current - latest: + if self._alter_route(net.interface.ifname, 'del', *x): + current.remove(x) + + # If the new config contains routes that aren't defined in the + # set of previously written routes, run commands to add them + for x in latest - current: + if self._alter_route(net.interface.ifname, 'add', *x): + current.add(x) + + if not current: + del db[cidr] + + cache.set('host_routes', db) + + def _get_default_gateway(self, version): + current = None + try: + cmd_out = self.sudo('-%s' % version, 'route', 'show') + except: + # assume the route is missing and use defaults + pass + else: + for l in cmd_out.splitlines(): + l = l.strip() + if l.startswith('default'): + match = re.search('via (?P[^ ]+)', l) + if match: + return match.group('gateway') + return current + + def _set_default_gateway(self, gateway_ip, ifname): + version = 4 + if gateway_ip.version == 6: + version = 6 + current = self._get_default_gateway(version) + desired = str(gateway_ip) + ifname = self.generic_to_host(ifname) + + if current and current != desired: + # Remove the current gateway and add the desired one + self.sudo( + '-%s' % version, 'route', 'del', 'default', 'via', current, + 'dev', ifname + ) + return self.sudo( + '-%s' % version, 'route', 'add', 'default', 'via', desired, + 'dev', ifname + ) + if not current: + # Add the desired gateway + return self.sudo( + '-%s' % version, 'route', 'add', 'default', 'via', desired, + 'dev', ifname + ) + + def _alter_route(self, ifname, action, destination, next_hop): + version = destination.version + ifname = self.generic_to_host(ifname) + try: + LOG.debug(self.sudo( + '-%s' % version, 'route', action, str(destination), 'via', + str(next_hop), 'dev', ifname + )) + return True + except RuntimeError as e: + # Since these are user-supplied custom routes, it's very possible + # that adding/removing them will fail. A failure to apply one of + # these custom rules, however, should *not* cause an overall router + # failure. + LOG.warn('Route could not be %sed: %s' % (action, unicode(e))) + return False + def get_rug_address(): """ Return the RUG address """ diff --git a/akanda/router/drivers/route.py b/akanda/router/drivers/route.py deleted file mode 100644 index acf00e0..0000000 --- a/akanda/router/drivers/route.py +++ /dev/null @@ -1,148 +0,0 @@ -# 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. - - -import logging - -from akanda.router.drivers import base - - -LOG = logging.getLogger(__name__) - - -class RouteManager(base.Manager): - EXECUTABLE = '/sbin/route' - - def __init__(self, root_helper='sudo'): - super(RouteManager, self).__init__(root_helper) - - def update_default(self, config): - # Track whether we have set the default gateways, by IP - # version. - gw_set = { - 4: False, - 6: False, - } - - # The default v4 gateway is pulled out as a special case - # because we only want one but we might have multiple v4 - # subnets on the external network. However, sometimes the RUG - # can't figure out what that value is, because it thinks we - # don't have any external IP addresses, yet. In that case, it - # doesn't give us a default. - if config.default_v4_gateway: - self._set_default_gateway(config.default_v4_gateway) - gw_set[4] = True - - # Look through our networks and make sure we have a default - # gateway set for each IP version, if we have an IP for that - # version on the external net. If we haven't already set the - # v4 gateway, this picks the gateway for the first subnet we - # find, which might be wrong. - for net in config.networks: - if not net.is_external_network: - continue - - for subnet in net.subnets: - if subnet.gateway_ip and not gw_set[subnet.gateway_ip.version]: - self._set_default_gateway(subnet.gateway_ip) - gw_set[subnet.gateway_ip.version] = True - - def update_host_routes(self, config, cache): - db = cache.get_or_create('host_routes', lambda: {}) - for net in config.networks: - - # For each subnet... - for subnet in net.subnets: - cidr = str(subnet.cidr) - - # determine the set of previously written routes for this cidr - if cidr not in db: - db[cidr] = set() - - current = db[cidr] - - # build a set of new routes for this cidr - latest = set() - for r in subnet.host_routes: - latest.add((r.destination, r.next_hop)) - - # If the set of previously written routes contains routes that - # aren't defined in the new config, run commands to delete them - for x in current - latest: - if self._alter_route('delete', *x): - current.remove(x) - - # If the new config contains routes that aren't defined in the - # set of previously written routes, run commands to add them - for x in latest - current: - if self._alter_route('add', *x): - current.add(x) - - if not current: - del db[cidr] - - cache.set('host_routes', db) - - def _get_default_gateway(self, version): - current = None - try: - cmd_out = self.sudo('-n', 'get', version, 'default') - except: - # assume the route is missing and use defaults - pass - else: - if 'no such process' in cmd_out.lower(): - # There is no gateway - return None - for l in cmd_out.splitlines(): - l = l.strip() - if l.startswith('gateway:'): - return l.partition(':')[-1].strip() - return current - - def _set_default_gateway(self, gateway_ip): - version = '-inet' - if gateway_ip.version == 6: - version += '6' - current = self._get_default_gateway(version) - desired = str(gateway_ip) - - if not current: - # Set the gateway - return self.sudo('add', version, 'default', desired) - if current != desired: - # Update the current gateway - return self.sudo('change', version, 'default', desired) - # Nothing to do - return '' - - def _alter_route(self, action, destination, next_hop): - version = '-inet' - if destination.version == 6: - version += '6' - try: - LOG.debug( - self.sudo(action, version, str(destination), str(next_hop)) - ) - return True - except RuntimeError as e: - # Since these are user-supplied custom routes, it's very possible - # that adding/removing them will fail. A failure to apply one of - # these custom rules, however, should *not* cause an overall router - # failure. - LOG.warn('Route could not be %sed: %s' % (action, unicode(e))) - return False diff --git a/akanda/router/manager.py b/akanda/router/manager.py index b41e957..f007cfd 100644 --- a/akanda/router/manager.py +++ b/akanda/router/manager.py @@ -19,8 +19,7 @@ import os import re from akanda.router import models -from akanda.router.drivers import (bird, dnsmasq, ip, metadata, pf, - route, arp) +from akanda.router.drivers import (bird, dnsmasq, ip, metadata, pf, arp) class Manager(object): @@ -88,8 +87,8 @@ class Manager(object): mgr.update_conf(rule_data) def update_routes(self, cache): - mgr = route.RouteManager() - mgr.update_default(self.config) + mgr = ip.IPManager() + mgr.update_default_gateway(self.config) mgr.update_host_routes(self.config, cache) def update_arp(self): diff --git a/test/unit/drivers/test_route.py b/test/unit/drivers/test_route.py index 23421e4..5a34ea9 100644 --- a/test/unit/drivers/test_route.py +++ b/test/unit/drivers/test_route.py @@ -22,67 +22,52 @@ import netaddr from dogpile.cache import make_region from akanda.router import models -from akanda.router.drivers import route +from akanda.router.drivers import ip class RouteTest(unittest2.TestCase): def setUp(self): - self.mgr = route.RouteManager() + super(RouteTest, self).setUp() + self.mgr = ip.IPManager() + self.host_patch = mock.patch.object( + self.mgr, 'generic_to_host', lambda x: x.replace('ge', 'eth') + ) + self.host_patch.start() + + def tearDown(self): + super(RouteTest, self).tearDown() + self.host_patch.stop() def test_get_default_gateway_v6_missing(self): - output = 'route: writing to routing socket: No such process\n' + output = '' with mock.patch.object(self.mgr, 'sudo') as sudo: sudo.return_value = output self.assertEqual( None, - self.mgr._get_default_gateway('-inet6') + self.mgr._get_default_gateway(6) ) - sudo.assert_called_with('-n', 'get', '-inet6', 'default') + sudo.assert_called_with('-6', 'route', 'show') def test_get_default_gateway_v6(self): - output = """ - route to: :: -destination: :: - mask: default - gateway: fdee:9f85:83be::1 - interface: vio1 - if address: fdee:9f85:83be:0:f816:3eff:fe7b:6263 - priority: 8 (static) - flags: - use mtu expire - 0 0 0 -""" + output = "default via fe80::f816:3eff:fe33:deac dev eth2 metric 1024" with mock.patch.object(self.mgr, 'sudo') as sudo: sudo.return_value = output self.assertEqual( - 'fdee:9f85:83be::1', - self.mgr._get_default_gateway('-inet6') + 'fe80::f816:3eff:fe33:deac', + self.mgr._get_default_gateway(6) ) - sudo.assert_called_with('-n', 'get', '-inet6', 'default') + sudo.assert_called_with('-6', 'route', 'show') def test_get_default_gateway_v4(self): - output = """ - route to: default -destination: default - mask: default - gateway: 192.168.122.1 - interface: vio0 - if address: 192.168.122.240 - priority: 8 (static) - flags: - label: DHCLIENT 20978 - use mtu expire - 73687 0 0 -sockaddrs: -""" + output = "default via 192.168.122.1 dev eth0 metric 100" with mock.patch.object(self.mgr, 'sudo') as sudo: sudo.return_value = output self.assertEqual( '192.168.122.1', - self.mgr._get_default_gateway('-inet') + self.mgr._get_default_gateway(4) ) - sudo.assert_called_with('-n', 'get', '-inet', 'default') + sudo.assert_called_with('-4', 'route', 'show') def test_set_default_v4_matches_current(self): ip_s = '192.168.122.1' @@ -93,7 +78,7 @@ sockaddrs: get.return_value = ip_s with mock.patch.object(self.mgr, 'sudo') as sudo: sudo.side_effect = AssertionError('should not be called') - self.mgr._set_default_gateway(ip) + self.mgr._set_default_gateway(ip, 'ge1') def test_set_default_v4_changes_current(self): ip_s = '192.168.122.1' @@ -101,10 +86,19 @@ sockaddrs: ip.version = 4 ip.__str__.return_value = ip_s with mock.patch.object(self.mgr, '_get_default_gateway') as get: - get.return_value = '192.168.122.254' - with mock.patch.object(self.mgr, 'sudo') as sudo: - self.mgr._set_default_gateway(ip) - sudo.assert_called_with('change', '-inet', 'default', ip_s) + get.return_value = '192.168.122.254' + with mock.patch.object(self.mgr, 'sudo') as sudo: + self.mgr._set_default_gateway(ip, 'ge1') + assert sudo.call_args_list == [ + mock.call( + '-4', 'route', 'del', 'default', 'via', + get.return_value, 'dev', 'eth1' + ), + mock.call( + '-4', 'route', 'add', 'default', 'via', ip_s, + 'dev', 'eth1' + ) + ] def test_set_default_v4_no_current(self): ip_s = '192.168.122.1' @@ -114,8 +108,11 @@ sockaddrs: with mock.patch.object(self.mgr, '_get_default_gateway') as get: get.return_value = None with mock.patch.object(self.mgr, 'sudo') as sudo: - self.mgr._set_default_gateway(ip) - sudo.assert_called_with('add', '-inet', 'default', ip_s) + self.mgr._set_default_gateway(ip, 'ge1') + sudo.assert_called_with( + '-4', 'route', 'add', 'default', 'via', '192.168.122.1', + 'dev', 'eth1' + ) def test_set_default_v6_matches_current(self): ip_s = 'fe80::5054:ff:fee2:1d4f' @@ -126,7 +123,7 @@ sockaddrs: get.return_value = ip_s with mock.patch.object(self.mgr, 'sudo') as sudo: sudo.side_effect = AssertionError('should not be called') - self.mgr._set_default_gateway(ip) + self.mgr._set_default_gateway(ip, 'ge1') def test_set_default_v6_changes_current(self): ip_s = 'fe80::5054:ff:fee2:1d4f' @@ -136,19 +133,32 @@ sockaddrs: with mock.patch.object(self.mgr, '_get_default_gateway') as get: get.return_value = 'fe80::5054:ff:fee2:aaaa' with mock.patch.object(self.mgr, 'sudo') as sudo: - self.mgr._set_default_gateway(ip) - sudo.assert_called_with('change', '-inet6', 'default', ip_s) + self.mgr._set_default_gateway(ip, 'ge1') + assert sudo.call_args_list == [ + mock.call( + '-6', 'route', 'del', 'default', 'via', + get.return_value, 'dev', 'eth1' + ), + mock.call( + '-6', 'route', 'add', 'default', 'via', ip_s, + 'dev', 'eth1' + ) + ] def test_set_default_v6_no_current(self): ip_s = 'fe80::5054:ff:fee2:1d4f' ip = mock.MagicMock() ip.version = 6 ip.__str__.return_value = ip_s + self.mgr.generic_mapping = {'ge1', 'eth1'} with mock.patch.object(self.mgr, '_get_default_gateway') as get: get.return_value = None with mock.patch.object(self.mgr, 'sudo') as sudo: - self.mgr._set_default_gateway(ip) - sudo.assert_called_with('add', '-inet6', 'default', ip_s) + self.mgr._set_default_gateway(ip, 'ge1') + sudo.assert_called_with( + '-6', 'route', 'add', 'default', 'via', ip_s, + 'dev', 'eth1' + ) def test_update_default_no_inputs(self): c = models.Configuration({}) @@ -156,13 +166,13 @@ sockaddrs: set.side_effect = AssertionError( 'should not try to set default gw' ) - self.mgr.update_default(c) + self.mgr.update_default_gateway(c) def test_update_default_v4_from_gateway(self): c = models.Configuration({'default_v4_gateway': '172.16.77.1'}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: - self.mgr.update_default(c) - set.assert_called_once_with(c.default_v4_gateway) + self.mgr.update_default_gateway(c) + set.assert_called_once_with(c.default_v4_gateway, None) def test_update_default_v4_from_subnet(self): subnet = dict( @@ -181,10 +191,10 @@ sockaddrs: ) c = models.Configuration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: - self.mgr.update_default(c) + self.mgr.update_default_gateway(c) net = c.networks[0] snet = net.subnets[0] - set.assert_called_once_with(snet.gateway_ip) + set.assert_called_once_with(snet.gateway_ip, 'ge0') def test_update_multiple_v4_subnets(self): subnet = dict( @@ -209,10 +219,10 @@ sockaddrs: ) c = models.Configuration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: - self.mgr.update_default(c) + self.mgr.update_default_gateway(c) net = c.networks[0] snet = net.subnets[0] - set.assert_called_once_with(snet.gateway_ip) + set.assert_called_once_with(snet.gateway_ip, 'ge0') def test_update_default_v6(self): subnet = dict( @@ -231,10 +241,10 @@ sockaddrs: ) c = models.Configuration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: - self.mgr.update_default(c) + self.mgr.update_default_gateway(c) net = c.networks[0] snet = net.subnets[0] - set.assert_called_once_with(snet.gateway_ip) + set.assert_called_once_with(snet.gateway_ip, 'ge0') def test_update_default_multiple_v6(self): subnet = dict( @@ -259,12 +269,12 @@ sockaddrs: ) c = models.Configuration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: - self.mgr.update_default(c) + self.mgr.update_default_gateway(c) net = c.networks[0] snet = net.subnets[0] - set.assert_called_once_with(snet.gateway_ip) + set.assert_called_once_with(snet.gateway_ip, 'ge0') - @mock.patch.object(route.RouteManager, '_set_default_gateway', + @mock.patch.object(ip.IPManager, '_set_default_gateway', lambda *a, **kw: None) def test_custom_host_routes(self): subnet = dict( @@ -290,7 +300,8 @@ sockaddrs: # ...so let's add one! self.mgr.update_host_routes(c, cache) sudo.assert_called_once_with( - 'add', '-inet', '192.240.128.0/20', '192.168.89.2' + '-4', 'route', 'add', '192.240.128.0/20', 'via', + '192.168.89.2', 'dev', 'eth0' ) # db[subnet.cidr] should contain the above route @@ -311,7 +322,8 @@ sockaddrs: c = models.Configuration({'networks': [network]}) self.mgr.update_host_routes(c, cache) sudo.assert_called_once_with( - 'delete', '-inet', '192.240.128.0/20', '192.168.89.2' + '-4', 'route', 'del', '192.240.128.0/20', 'via', + '192.168.89.2', 'dev', 'eth0' ) self.assertEqual(len(cache.get('host_routes')), 0) @@ -327,8 +339,10 @@ sockaddrs: c = models.Configuration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ - mock.call('add', '-inet', '192.240.128.0/20', '192.168.89.2'), - mock.call('add', '-inet', '192.220.128.0/20', '192.168.89.3'), + mock.call('-4', 'route', 'add', '192.240.128.0/20', + 'via', '192.168.89.2', 'dev', 'eth0'), + mock.call('-4', 'route', 'add', '192.220.128.0/20', + 'via', '192.168.89.3', 'dev', 'eth0'), ]) # ...let's remove one and add another... @@ -343,9 +357,10 @@ sockaddrs: c = models.Configuration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ - mock.call('delete', '-inet', '192.220.128.0/20', - '192.168.89.3'), - mock.call('add', '-inet', '192.185.128.0/20', '192.168.89.4') + mock.call('-4', 'route', 'del', '192.220.128.0/20', + 'via', '192.168.89.3', 'dev', 'eth0'), + mock.call('-4', 'route', 'add', '192.185.128.0/20', + 'via', '192.168.89.4', 'dev', 'eth0') ]) # ...let's add another subnet... @@ -364,7 +379,8 @@ sockaddrs: c = models.Configuration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ - mock.call('add', '-inet', '192.240.128.0/20', '192.168.90.1') + mock.call('-4', 'route', 'add', '192.240.128.0/20', + 'via', '192.168.90.1', 'dev', 'eth0') ]) self.assertEqual(len(cache.get('host_routes')), 2) @@ -375,12 +391,12 @@ sockaddrs: c = models.Configuration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ - mock.call('delete', '-inet', '192.185.128.0/20', - '192.168.89.4'), - mock.call('delete', '-inet', '192.240.128.0/20', - '192.168.89.2'), - mock.call('delete', '-inet', '192.240.128.0/20', - '192.168.90.1'), + mock.call('-4', 'route', 'del', '192.185.128.0/20', + 'via', '192.168.89.4', 'dev', 'eth0'), + mock.call('-4', 'route', 'del', '192.240.128.0/20', + 'via', '192.168.89.2', 'dev', 'eth0'), + mock.call('-4', 'route', 'del', '192.240.128.0/20', + 'via', '192.168.90.1', 'dev', 'eth0'), ]) self.assertEqual(len(cache.get('host_routes')), 0) @@ -409,6 +425,7 @@ sockaddrs: self.mgr.update_host_routes(c, cache) sudo.assert_called_once_with( - 'add', '-inet', '192.240.128.0/20', '192.168.89.2' + '-4', 'route', 'add', '192.240.128.0/20', 'via', + '192.168.89.2', 'dev', 'eth0' ) self.assertEqual(len(cache.get('host_routes')), 0)