diff --git a/etc/neutron/rootwrap.d/ebtables.filters b/etc/neutron/rootwrap.d/ebtables.filters new file mode 100644 index 00000000000..8e810e7b551 --- /dev/null +++ b/etc/neutron/rootwrap.d/ebtables.filters @@ -0,0 +1,11 @@ +# neutron-rootwrap command filters for nodes on which neutron is +# expected to control network +# +# This file should be owned by (and only-writeable by) the root user + +# format seems to be +# cmd-name: filter-name, raw-command, user, args + +[Filters] + +ebtables: CommandFilter, ebtables, root diff --git a/neutron/plugins/linuxbridge/agent/arp_protect.py b/neutron/plugins/linuxbridge/agent/arp_protect.py new file mode 100644 index 00000000000..10fcae52a63 --- /dev/null +++ b/neutron/plugins/linuxbridge/agent/arp_protect.py @@ -0,0 +1,128 @@ +# Copyright (c) 2015 Mirantis, 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. + +import netaddr +from oslo_concurrency import lockutils +from oslo_log import log as logging + +from neutron.agent.linux import ip_lib +from neutron.i18n import _LI + +LOG = logging.getLogger(__name__) +SPOOF_CHAIN_PREFIX = 'neutronARP-' + + +def setup_arp_spoofing_protection(vif, port_details): + current_rules = ebtables(['-L']).splitlines() + if not port_details.get('port_security_enabled', True): + # clear any previous entries related to this port + delete_arp_spoofing_protection([vif], current_rules) + LOG.info(_LI("Skipping ARP spoofing rules for port '%s' because " + "it has port security disabled"), vif) + return + # collect all of the addresses and cidrs that belong to the port + addresses = {f['ip_address'] for f in port_details['fixed_ips']} + if port_details.get('allowed_address_pairs'): + addresses |= {p['ip_address'] + for p in port_details['allowed_address_pairs']} + + addresses = {ip for ip in addresses + if netaddr.IPNetwork(ip).version == 4} + if any(netaddr.IPNetwork(ip).prefixlen == 0 for ip in addresses): + # don't try to install protection because a /0 prefix allows any + # address anyway and the ARP_SPA can only match on /1 or more. + return + + install_arp_spoofing_protection(vif, addresses, current_rules) + + +def chain_name(vif): + # start each chain with a common identifer for cleanup to find + return '%s%s' % (SPOOF_CHAIN_PREFIX, vif) + + +@lockutils.synchronized('ebtables') +def delete_arp_spoofing_protection(vifs, current_rules=None): + if not current_rules: + current_rules = ebtables(['-L']).splitlines() + # delete the jump rule and then delete the whole chain + jumps = [vif for vif in vifs if vif_jump_present(vif, current_rules)] + for vif in jumps: + ebtables(['-D', 'FORWARD', '-i', vif, '-j', + chain_name(vif), '-p', 'ARP']) + for vif in vifs: + if chain_exists(chain_name(vif), current_rules): + ebtables(['-X', chain_name(vif)]) + + +def delete_unreferenced_arp_protection(current_vifs): + # deletes all jump rules and chains that aren't in current_vifs but match + # the spoof prefix + output = ebtables(['-L']).splitlines() + to_delete = [] + for line in output: + # we're looking to find and turn the following: + # Bridge chain: SPOOF_CHAIN_PREFIXtap199, entries: 0, policy: DROP + # into 'tap199' + if line.startswith('Bridge chain: %s' % SPOOF_CHAIN_PREFIX): + devname = line.split(SPOOF_CHAIN_PREFIX, 1)[1].split(',')[0] + if devname not in current_vifs: + to_delete.append(devname) + LOG.info(_LI("Clearing orphaned ARP spoofing entries for devices %s"), + to_delete) + delete_arp_spoofing_protection(to_delete, output) + + +@lockutils.synchronized('ebtables') +def install_arp_spoofing_protection(vif, addresses, current_rules): + # make a VIF-specific ARP chain so we don't conflict with other rules + vif_chain = chain_name(vif) + if not chain_exists(vif_chain, current_rules): + ebtables(['-N', vif_chain, '-P', 'DROP']) + # flush the chain to clear previous accepts. this will cause dropped ARP + # packets until the allows are installed, but that's better than leaked + # spoofed packets and ARP can handle losses. + ebtables(['-F', vif_chain]) + for addr in addresses: + ebtables(['-A', vif_chain, '-p', 'ARP', '--arp-ip-src', addr, + '-j', 'ACCEPT']) + # check if jump rule already exists, if not, install it + if not vif_jump_present(vif, current_rules): + ebtables(['-A', 'FORWARD', '-i', vif, '-j', + vif_chain, '-p', 'ARP']) + + +def chain_exists(chain, current_rules): + for rule in current_rules: + if rule.startswith('Bridge chain: %s' % chain): + return True + return False + + +def vif_jump_present(vif, current_rules): + searches = (('-i %s' % vif), ('-j %s' % chain_name(vif)), ('-p ARP')) + for line in current_rules: + if all(s in line for s in searches): + return True + return False + + +# Used to scope ebtables commands in testing +NAMESPACE = None + + +def ebtables(comm): + execute = ip_lib.IPWrapper(NAMESPACE).netns.execute + return execute(['ebtables'] + comm, run_as_root=True) diff --git a/neutron/plugins/linuxbridge/agent/linuxbridge_neutron_agent.py b/neutron/plugins/linuxbridge/agent/linuxbridge_neutron_agent.py index db9eff1899c..e8be12b1a57 100644 --- a/neutron/plugins/linuxbridge/agent/linuxbridge_neutron_agent.py +++ b/neutron/plugins/linuxbridge/agent/linuxbridge_neutron_agent.py @@ -45,6 +45,7 @@ from neutron import context from neutron.i18n import _LE, _LI, _LW from neutron.openstack.common import loopingcall from neutron.plugins.common import constants as p_const +from neutron.plugins.linuxbridge.agent import arp_protect from neutron.plugins.linuxbridge.common import config # noqa from neutron.plugins.linuxbridge.common import constants as lconst @@ -753,6 +754,7 @@ class LinuxBridgeNeutronAgentRPC(object): def __init__(self, interface_mappings, polling_interval): self.polling_interval = polling_interval + self.prevent_arp_spoofing = cfg.CONF.AGENT.prevent_arp_spoofing self.setup_linux_bridge(interface_mappings) configurations = {'interface_mappings': interface_mappings} if self.br_mgr.vxlan_mode != lconst.VXLAN_NONE: @@ -870,6 +872,11 @@ class LinuxBridgeNeutronAgentRPC(object): if 'port_id' in device_details: LOG.info(_LI("Port %(device)s updated. Details: %(details)s"), {'device': device, 'details': device_details}) + if self.prevent_arp_spoofing: + port = self.br_mgr.get_tap_device_name( + device_details['port_id']) + arp_protect.setup_arp_spoofing_protection(port, + device_details) if device_details['admin_state_up']: # create the networking for the port network_type = device_details.get('network_type') @@ -923,6 +930,8 @@ class LinuxBridgeNeutronAgentRPC(object): LOG.info(_LI("Port %s updated."), device) else: LOG.debug("Device %s not defined on plugin", device) + if self.prevent_arp_spoofing: + arp_protect.delete_arp_spoofing_protection(devices) return resync def scan_devices(self, previous, sync): @@ -943,6 +952,10 @@ class LinuxBridgeNeutronAgentRPC(object): 'current': set(), 'updated': set(), 'removed': set()} + # clear any orphaned ARP spoofing rules (e.g. interface was + # manually deleted) + if self.prevent_arp_spoofing: + arp_protect.delete_unreferenced_arp_protection(current_devices) if sync: # This is the first iteration, or the previous one had a problem. diff --git a/neutron/plugins/linuxbridge/common/config.py b/neutron/plugins/linuxbridge/common/config.py index 90c6548dc83..88ba9b6d5e3 100644 --- a/neutron/plugins/linuxbridge/common/config.py +++ b/neutron/plugins/linuxbridge/common/config.py @@ -62,6 +62,22 @@ agent_opts = [ "polling for local device changes.")), cfg.BoolOpt('rpc_support_old_agents', default=False, help=_("Enable server RPC compatibility with old agents")), + # TODO(kevinbenton): The following opt is duplicated between the OVS agent + # and the Linuxbridge agent to make it easy to back-port. These shared opts + # should be moved into a common agent config options location as part of + # the deduplication work. + cfg.BoolOpt('prevent_arp_spoofing', default=False, + help=_("Enable suppression of ARP responses that don't match " + "an IP address that belongs to the port from which " + "they originate. Note: This prevents the VMs attached " + "to this agent from spoofing, it doesn't protect them " + "from other devices which have the capability to spoof " + "(e.g. bare metal or VMs attached to agents without " + "this flag set to True). Spoofing rules will not be " + "added to any ports that have port security disabled. " + "For LinuxBridge, this requires ebtables. For OVS, it " + "requires a version that supports matching ARP " + "headers.")) ] diff --git a/neutron/plugins/openvswitch/common/config.py b/neutron/plugins/openvswitch/common/config.py index a26178f5093..8046a2433e8 100644 --- a/neutron/plugins/openvswitch/common/config.py +++ b/neutron/plugins/openvswitch/common/config.py @@ -83,8 +83,9 @@ agent_opts = [ "(e.g. bare metal or VMs attached to agents without " "this flag set to True). Spoofing rules will not be " "added to any ports that have port security disabled. " - "This requires a version of OVS that supports matching " - "ARP headers.")), + "For LinuxBridge, this requires ebtables. For OVS, it " + "requires a version that supports matching ARP " + "headers.")), cfg.BoolOpt('dont_fragment', default=True, help=_("Set or un-set the don't fragment (DF) bit on " "outgoing IP packet carrying GRE/VXLAN tunnel.")), diff --git a/neutron/tests/common/machine_fixtures.py b/neutron/tests/common/machine_fixtures.py new file mode 100644 index 00000000000..864cad46be7 --- /dev/null +++ b/neutron/tests/common/machine_fixtures.py @@ -0,0 +1,96 @@ +# Copyright (c) 2015 Thales Services SAS +# +# 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 fixtures + +from neutron.agent.linux import ip_lib +from neutron.tests.common import net_helpers + + +class FakeMachine(fixtures.Fixture): + """Create a fake machine. + + :ivar bridge: bridge on which the fake machine is bound + :ivar ip_cidr: fake machine ip_cidr + :type ip_cidr: str + :ivar ip: fake machine ip + :type ip: str + :ivar gateway_ip: fake machine gateway ip + :type gateway_ip: str + + :ivar namespace: namespace emulating the machine + :type namespace: str + :ivar port: port binding the namespace to the bridge + :type port: IPDevice + """ + + def __init__(self, bridge, ip_cidr, gateway_ip=None): + super(FakeMachine, self).__init__() + self.bridge = bridge + self.ip_cidr = ip_cidr + self.ip = self.ip_cidr.partition('/')[0] + self.gateway_ip = gateway_ip + + def setUp(self): + super(FakeMachine, self).setUp() + ns_fixture = self.useFixture( + net_helpers.NamespaceFixture()) + self.namespace = ns_fixture.name + + self.port = self.useFixture( + net_helpers.PortFixture.get(self.bridge, self.namespace)).port + self.port.addr.add(self.ip_cidr) + + if self.gateway_ip: + net_helpers.set_namespace_gateway(self.port, self.gateway_ip) + + def execute(self, *args, **kwargs): + ns_ip_wrapper = ip_lib.IPWrapper(self.namespace) + return ns_ip_wrapper.netns.execute(*args, **kwargs) + + def assert_ping(self, dst_ip): + net_helpers.assert_ping(self.namespace, dst_ip) + + def assert_no_ping(self, dst_ip): + net_helpers.assert_no_ping(self.namespace, dst_ip) + + +class PeerMachines(fixtures.Fixture): + """Create 'amount' peered machines on an ip_cidr. + + :ivar bridge: bridge on which peer machines are bound + :ivar ip_cidr: ip_cidr on which peer machines have ips + :type ip_cidr: str + :ivar machines: fake machines + :type machines: FakeMachine list + """ + + CIDR = '192.168.0.1/24' + + def __init__(self, bridge, ip_cidr=None, gateway_ip=None, amount=2): + super(PeerMachines, self).__init__() + self.bridge = bridge + self.ip_cidr = ip_cidr or self.CIDR + self.gateway_ip = gateway_ip + self.amount = amount + + def setUp(self): + super(PeerMachines, self).setUp() + self.machines = [] + for index in range(self.amount): + ip_cidr = net_helpers.increment_ip_cidr(self.ip_cidr, index) + self.machines.append( + self.useFixture( + FakeMachine(self.bridge, ip_cidr, self.gateway_ip))) diff --git a/neutron/tests/common/net_helpers.py b/neutron/tests/common/net_helpers.py index faeec0c7c86..fce80b6a429 100644 --- a/neutron/tests/common/net_helpers.py +++ b/neutron/tests/common/net_helpers.py @@ -62,6 +62,31 @@ def set_namespace_gateway(port_dev, gateway_ip): port_dev.route.add_gateway(gateway_ip) +def assert_arping(src_namespace, dst_ip, source=None, timeout=1, count=1): + """Send arp request using arping executable. + + NOTE: ARP protocol is used in IPv4 only. IPv6 uses Neighbour Discovery + Protocol instead. + """ + ns_ip_wrapper = ip_lib.IPWrapper(src_namespace) + arping_cmd = ['arping', '-c', count, '-w', timeout] + if source: + arping_cmd.extend(['-s', source]) + arping_cmd.append(dst_ip) + ns_ip_wrapper.netns.execute(arping_cmd) + + +def assert_no_arping(src_namespace, dst_ip, source=None, timeout=1, count=1): + try: + assert_arping(src_namespace, dst_ip, source, timeout, count) + except RuntimeError: + pass + else: + tools.fail("destination ip %(destination)s is replying to arp from " + "namespace %(ns)s, but it shouldn't" % + {'ns': src_namespace, 'destination': dst_ip}) + + class NamespaceFixture(fixtures.Fixture): """Create a namespace. @@ -116,6 +141,15 @@ class VethFixture(fixtures.Fixture): # when a namespace owning a veth endpoint is deleted. pass + @staticmethod + def get_peer_name(name): + if name.startswith(VETH0_PREFIX): + return name.replace(VETH0_PREFIX, VETH1_PREFIX) + elif name.startswith(VETH1_PREFIX): + return name.replace(VETH1_PREFIX, VETH0_PREFIX) + else: + tools.fail('%s is not a valid VethFixture veth endpoint' % name) + @six.add_metaclass(abc.ABCMeta) class PortFixture(fixtures.Fixture): @@ -140,6 +174,17 @@ class PortFixture(fixtures.Fixture): if not self.bridge: self.bridge = self.useFixture(self._create_bridge_fixture()).bridge + @classmethod + def get(cls, bridge, namespace=None): + """Deduce PortFixture class from bridge type and instantiate it.""" + if isinstance(bridge, ovs_lib.OVSBridge): + return OVSPortFixture(bridge, namespace) + if isinstance(bridge, bridge_lib.BridgeDevice): + return LinuxBridgePortFixture(bridge, namespace) + if isinstance(bridge, VethBridge): + return VethPortFixture(bridge, namespace) + tools.fail('Unexpected bridge type: %s' % type(bridge)) + class OVSBridgeFixture(fixtures.Fixture): """Create an OVS bridge. diff --git a/neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py b/neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py new file mode 100644 index 00000000000..be7a9cd57ea --- /dev/null +++ b/neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py @@ -0,0 +1,100 @@ +# Copyright (c) 2015 Mirantis, 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. +from oslo_config import cfg + +from neutron.plugins.linuxbridge.agent import arp_protect +from neutron.tests.common import machine_fixtures +from neutron.tests.common import net_helpers +from neutron.tests.functional import base as functional_base + +no_arping = net_helpers.assert_no_arping +arping = net_helpers.assert_arping + + +class LinuxBridgeARPSpoofTestCase(functional_base.BaseSudoTestCase): + + def setUp(self): + super(LinuxBridgeARPSpoofTestCase, self).setUp() + cfg.CONF.set_override('prevent_arp_spoofing', True, 'AGENT') + lbfixture = self.useFixture(net_helpers.LinuxBridgeFixture()) + self.addCleanup(setattr, arp_protect, 'NAMESPACE', None) + arp_protect.NAMESPACE = lbfixture.namespace + bridge = lbfixture.bridge + self.source, self.destination, self.observer = self.useFixture( + machine_fixtures.PeerMachines(bridge, amount=3)).machines + + def _add_arp_protection(self, machine, addresses, extra_port_dict=None): + port_dict = {'fixed_ips': [{'ip_address': a} for a in addresses]} + if extra_port_dict: + port_dict.update(extra_port_dict) + name = net_helpers.VethFixture.get_peer_name(machine.port.name) + arp_protect.setup_arp_spoofing_protection(name, port_dict) + self.addCleanup(arp_protect.delete_arp_spoofing_protection, + [name]) + + def test_arp_no_protection(self): + arping(self.source.namespace, self.destination.ip) + arping(self.destination.namespace, self.source.ip) + + def test_arp_correct_protection(self): + self._add_arp_protection(self.source, [self.source.ip]) + self._add_arp_protection(self.destination, [self.destination.ip]) + arping(self.source.namespace, self.destination.ip) + arping(self.destination.namespace, self.source.ip) + + def test_arp_fails_incorrect_protection(self): + self._add_arp_protection(self.source, ['1.1.1.1']) + self._add_arp_protection(self.destination, ['2.2.2.2']) + no_arping(self.source.namespace, self.destination.ip) + no_arping(self.destination.namespace, self.source.ip) + + def test_arp_protection_removal(self): + self._add_arp_protection(self.source, ['1.1.1.1']) + self._add_arp_protection(self.destination, ['2.2.2.2']) + no_arping(self.observer.namespace, self.destination.ip) + no_arping(self.observer.namespace, self.source.ip) + name = net_helpers.VethFixture.get_peer_name(self.source.port.name) + arp_protect.delete_arp_spoofing_protection([name]) + # spoofing should have been removed from source, but not dest + arping(self.observer.namespace, self.source.ip) + no_arping(self.observer.namespace, self.destination.ip) + + def test_arp_protection_update(self): + self._add_arp_protection(self.source, ['1.1.1.1']) + self._add_arp_protection(self.destination, ['2.2.2.2']) + no_arping(self.observer.namespace, self.destination.ip) + no_arping(self.observer.namespace, self.source.ip) + self._add_arp_protection(self.source, ['192.0.0.0/1']) + # spoofing should have been updated on source, but not dest + arping(self.observer.namespace, self.source.ip) + no_arping(self.observer.namespace, self.destination.ip) + + def test_arp_protection_port_security_disabled(self): + self._add_arp_protection(self.source, ['1.1.1.1']) + no_arping(self.observer.namespace, self.source.ip) + self._add_arp_protection(self.source, ['1.1.1.1'], + {'port_security_enabled': False}) + arping(self.observer.namespace, self.source.ip) + + def test_arp_protection_dead_reference_removal(self): + self._add_arp_protection(self.source, ['1.1.1.1']) + self._add_arp_protection(self.destination, ['2.2.2.2']) + no_arping(self.observer.namespace, self.destination.ip) + no_arping(self.observer.namespace, self.source.ip) + name = net_helpers.VethFixture.get_peer_name(self.source.port.name) + # This should remove all arp protect rules that aren't source port + arp_protect.delete_unreferenced_arp_protection([name]) + no_arping(self.observer.namespace, self.source.ip) + arping(self.observer.namespace, self.destination.ip)