diff --git a/akanda/router/drivers/arp.py b/akanda/router/drivers/arp.py index 8505b30..b844751 100644 --- a/akanda/router/drivers/arp.py +++ b/akanda/router/drivers/arp.py @@ -14,16 +14,74 @@ # License for the specific language governing permissions and limitations # under the License. - +import argparse import logging import re +import socket +import struct +from akanda.router import utils from akanda.router.drivers import base +from akanda.router.models import Network LOG = logging.getLogger(__name__) +def send_gratuitous_arp(): + parser = argparse.ArgumentParser( + description='Send a gratuitous ARP' + ) + parser.add_argument('ifname', metavar='ifname', type=str) + parser.add_argument('address', metavar='address', type=str) + args = parser.parse_args() + + return _send_gratuitous_arp(args.ifname, args.address) + + +def _send_gratuitous_arp(ifname, address): + """ + Send a gratuitous ARP reply. Generally used when Floating IPs are + associated. + :type ifname: str + :param ifname: The real name of the interface to send an ARP on + :type address: str + :param address: The source IPv4 address + """ + HTYPE_ARP = 0x0806 + PTYPE_IPV4 = 0x0800 + + # Bind to the socket + sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW) + sock.bind((ifname, HTYPE_ARP)) + hwaddr = sock.getsockname()[4] + + # Build a gratuitous ARP packet + gratuitous_arp = [ + struct.pack("!h", 1), # HTYPE Ethernet + struct.pack("!h", PTYPE_IPV4), # PTYPE IPv4 + struct.pack("!B", 6), # HADDR length, 6 for IEEE 802 MAC addresses + struct.pack("!B", 4), # PADDR length, 4 for IPv4 + struct.pack("!h", 2), # OPER, 2 = ARP Reply + + # Sender's hardware and protocol address are duplicated in the + # target fields + + hwaddr, # Sender MAC + socket.inet_aton(address), # Sender IP address + hwaddr, # Target MAC + socket.inet_aton(address) # Target IP address + ] + frame = [ + '\xff\xff\xff\xff\xff\xff', # Broadcast destination + hwaddr, # Source address + struct.pack("!h", HTYPE_ARP), + ''.join(gratuitous_arp) + ] + sock.send(''.join(frame)) + sock.close() + + class ARPManager(base.Manager): """ A class to interact with entries in the ARP cache. Currently only really @@ -31,6 +89,30 @@ class ARPManager(base.Manager): """ EXECUTABLE = '/usr/sbin/arp' + def send_gratuitous_arp_for_floating_ips(self, config, generic_to_host): + """ + Send a gratuitous ARP for every Floating IP. + :type config: akanda.router.models.Configuration + :param config: An akanda.router.models.Configuration object containing + configuration information for the system's network + setup. + :type generic_to_host: callable + :param generic_to_host: A callable which translates a generic interface + name (e.g., "ge0") to a physical name (e.g., + "eth0") + """ + external_nets = filter( + lambda n: n.network_type == Network.TYPE_EXTERNAL, + config.networks + ) + for net in external_nets: + for fip in net.floating_ips: + utils.execute([ + 'akanda-gratuitous-arp', + generic_to_host(net.interface.ifname), + str(fip.floating_ip) + ], self.root_helper) + def remove_stale_entries(self, config): """ A wrapper function that iterates over the networks in and diff --git a/akanda/router/drivers/ip.py b/akanda/router/drivers/ip.py index ec442d5..4e12d1f 100644 --- a/akanda/router/drivers/ip.py +++ b/akanda/router/drivers/ip.py @@ -228,13 +228,6 @@ class IPManager(base.Manager): self.sudo(*fmt_args_add(item)) self.up(interface) - # Send a gratuitous ARP for new v4 addressees - ip, prefix = item - if ip.version == 4: - utils.execute([ - 'arping', '-A', '-c', '1', '-I', real_ifname, str(ip) - ], self.root_helper) - for item in (prev_set - next_set): self.sudo(*fmt_args_delete(item)) ip, prefix = item diff --git a/akanda/router/manager.py b/akanda/router/manager.py index fa6a38e..80eac54 100644 --- a/akanda/router/manager.py +++ b/akanda/router/manager.py @@ -101,6 +101,10 @@ class Manager(object): def update_arp(self): mgr = arp.ARPManager() + mgr.send_gratuitous_arp_for_floating_ips( + self.config, + self.ip_mgr.generic_to_host + ) mgr.remove_stale_entries(self.config) def get_interfaces(self): diff --git a/setup.cfg b/setup.cfg index 7c29265..206948f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,7 @@ console_scripts = akanda-configure-management=akanda.router.commands.management:configure_management akanda-api-dev-server=akanda.router.api.server:main akanda-metadata-proxy=akanda.router.metadata_proxy:main + akanda-gratuitous-arp=akanda.router.drivers.arp:send_gratuitous_arp [build_sphinx] all_files = 1 diff --git a/test/unit/drivers/test_arp.py b/test/unit/drivers/test_arp.py index 6e8e1d1..6e17596 100644 --- a/test/unit/drivers/test_arp.py +++ b/test/unit/drivers/test_arp.py @@ -16,8 +16,10 @@ import mock +import socket import unittest2 +from akanda.router import models from akanda.router.drivers import arp config = mock.Mock() @@ -26,6 +28,13 @@ alloc = mock.Mock() network.address_allocations = [alloc] config.networks = [network] +def _AF_PACKET_supported(): + try: + from socket import AF_PACKET + return True + except: + return False + class ARPTest(unittest2.TestCase): @@ -83,3 +92,89 @@ class ARPTest(unittest2.TestCase): mock.call('-an'), mock.call('-d', '10.10.10.2') ]) + + def test_send_gratuitous_arp_for_config(self): + config = models.Configuration({ + 'networks': [{ + 'network_id': 'ABC456', + 'interface': { + 'ifname': 'ge1', + 'name': 'ext', + }, + 'subnets': [{ + 'cidr': '172.16.77.0/24', + 'gateway_ip': '172.16.77.1', + 'dhcp_enabled': True, + 'dns_nameservers': [] + }], + 'network_type': models.Network.TYPE_EXTERNAL, + }], + 'floating_ips': [{ + 'fixed_ip': '192.168.0.2', + 'floating_ip': '172.16.77.50' + },{ + 'fixed_ip': '192.168.0.3', + 'floating_ip': '172.16.77.51' + },{ + 'fixed_ip': '192.168.0.4', + 'floating_ip': '172.16.77.52' + },{ + 'fixed_ip': '192.168.0.5', + 'floating_ip': '172.16.77.53' + }] + }) + + with mock.patch('akanda.router.utils.execute') as execute: + self.mgr.send_gratuitous_arp_for_floating_ips( + config, + lambda x: x.replace('ge', 'eth') + ) + assert execute.call_args_list == [ + mock.call( + ['akanda-gratuitous-arp', 'eth1', '172.16.77.50'], 'sudo' + ), + mock.call( + ['akanda-gratuitous-arp', 'eth1', '172.16.77.51'], 'sudo' + ), + mock.call( + ['akanda-gratuitous-arp', 'eth1', '172.16.77.52'], 'sudo' + ), + mock.call( + ['akanda-gratuitous-arp', 'eth1', '172.16.77.53'], 'sudo' + ) + ] + + @unittest2.skipIf( + not _AF_PACKET_supported(), + 'socket.AF_PACKET not supported on this platform' + ) + @mock.patch('socket.socket') + def test_send_gratuitous_arp(self, socket_constr): + socket_inst = socket_constr.return_value + socket_inst.getsockname.return_value = ( + None, None, None, None, 'A1:B2:C3:D4:E5:F6' + ) + + arp._send_gratuitous_arp('eth1', '1.2.3.4') + socket_constr.assert_called_once_with( + socket.AF_PACKET, socket.SOCK_RAW + ) + socket_inst.bind.assert_called_once_with(( + 'eth1', + 0x0806 + )) + data = socket_inst.send.call_args_list[0][0][0] + assert data == ''.join([ + '\xff\xff\xff\xff\xff\xff', # Broadcast destination + 'A1:B2:C3:D4:E5:F6', # Source hardware address + '\x08\x06', # HTYPE ARP + '\x00\x01', # Ethernet + '\x08\x00', # Protocol IPv4 + '\x06', # HADDR length, 6 for IEEE 802 MAC addresses + '\x04', # PADDR length, 4 for IPv4 + '\x00\x02', # OPER, 2 = ARP Reply + 'A1:B2:C3:D4:E5:F6', # Source MAC + '\x01\x02\x03\x04', # Source IP + 'A1:B2:C3:D4:E5:F6', # Target MAC matches + '\x01\x02\x03\x04' # Target IP matches + ]) diff --git a/test/unit/drivers/test_ip.py b/test/unit/drivers/test_ip.py index 5310701..737db8c 100644 --- a/test/unit/drivers/test_ip.py +++ b/test/unit/drivers/test_ip.py @@ -259,9 +259,6 @@ class IPTestCase(TestCase): ], 'sudo'), mock.call([cmd, 'link', 'set', 'em0', 'up'], 'sudo'), mock.call([cmd, 'addr', 'show', 'em0']), - mock.call([ - 'arping', '-A', '-c', '1', '-I', 'em0', '192.168.105.2' - ], 'sudo'), mock.call([ cmd, '-6', 'addr', 'add', 'fdca:3ba5:a17a:acda:20c:29ff:fe94:723d/64', 'dev', 'em0' @@ -317,9 +314,6 @@ class IPTestCase(TestCase): ], 'sudo'), mock.call(['/sbin/ip', 'link', 'set', 'em0', 'up'], 'sudo'), mock.call(['/sbin/ip', 'addr', 'show', 'em0']), - mock.call([ - 'arping', '-A', '-c', '1', '-I', 'em0', str(a.ip) - ], 'sudo'), mock.call([ '/sbin/ip', 'addr', 'del', str(c), 'dev', 'em0' ], 'sudo'),