diff options
| author | Jim Rollenhagen <jim@jimrollenhagen.com> | 2014-05-07 09:28:19 -0700 |
|---|---|---|
| committer | Josh Gachnang <josh@pcsforeducation.com> | 2014-12-09 11:59:00 -0800 |
| commit | 15aaa03833208bb8ed8a5e164c164b9e073a4769 (patch) | |
| tree | 41c77add2183f83f918c429d44f2f8a822a833ee /ironic_python_agent | |
| parent | cfdedd30d369d4710ff55add09cc6fd8676ff9f7 (diff) | |
| download | ironic-python-agent-15aaa03833208bb8ed8a5e164c164b9e073a4769.tar.gz | |
Use LLDP to get switch port mapping
Provides a function to listen for LLDP packets on the network.
Listens on one or all of the network interfaces, and then parses the found
packets.
Change-Id: I1545a41f46cd0916aab9c43ce036865454fa66e0
Co-Authored-By: Josh Gachnang <josh@pcsforeducation.com>
Diffstat (limited to 'ironic_python_agent')
| -rw-r--r-- | ironic_python_agent/cmd/agent.py | 6 | ||||
| -rw-r--r-- | ironic_python_agent/netutils.py | 197 | ||||
| -rw-r--r-- | ironic_python_agent/tests/netutils.py | 258 |
3 files changed, 460 insertions, 1 deletions
diff --git a/ironic_python_agent/cmd/agent.py b/ironic_python_agent/cmd/agent.py index 73e227ae..6b26ed37 100644 --- a/ironic_python_agent/cmd/agent.py +++ b/ironic_python_agent/cmd/agent.py @@ -191,7 +191,11 @@ cli_opts = [ cfg.StrOpt('driver_name', default=APARAMS.get('ipa-driver-name', 'agent_ipmitool'), deprecated_name='driver-name', - help='The Ironic driver in use for this node') + help='The Ironic driver in use for this node'), + + cfg.FloatOpt('lldp_timeout', + default=APARAMS.get('lldp-timeout', 30.0), + help='The amount of seconds to wait for LLDP packets.') ] CONF.register_cli_opts(cli_opts) diff --git a/ironic_python_agent/netutils.py b/ironic_python_agent/netutils.py new file mode 100644 index 00000000..6ddc95cf --- /dev/null +++ b/ironic_python_agent/netutils.py @@ -0,0 +1,197 @@ +# Copyright 2014 Rackspace, 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 ctypes +import fcntl +import logging +import select +import socket +import struct +import sys + +from oslo.config import cfg + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +LLDP_ETHERTYPE = 0x88cc +IFF_PROMISC = 0x100 +SIOCGIFFLAGS = 0x8913 +SIOCSIFFLAGS = 0x8914 + + +class ifreq(ctypes.Structure): + """Class for setting flags on a socket.""" + _fields_ = [("ifr_ifrn", ctypes.c_char * 16), + ("ifr_flags", ctypes.c_short)] + + +class RawPromiscuousSockets(object): + def __init__(self, interface_names, protocol): + """Initialize context manager. + + :param interface_names: a list of interface names to bind to + :param protocol: the protocol to listen for + :returns: A list of tuple of (interface_name, bound_socket), or [] if + there is an exception binding or putting the sockets in + promiscuous mode + """ + if not interface_names: + raise ValueError('interface_names must be a non-empty list of ' + 'network interface names to bind to.') + self.protocol = protocol + # A 3-tuple of (interface_name, socket, ifreq object) + self.interfaces = [(name, self._get_socket(), ifreq()) + for name in interface_names] + + def __enter__(self): + for interface_name, sock, ifr in self.interfaces: + LOG.info('Interface %s entering promiscuous mode to capture ', + interface_name) + try: + ifr.ifr_ifrn = interface_name + # Get current flags + fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, ifr) # G for Get + # bitwise or the flags with promiscuous mode, set the new flags + ifr.ifr_flags |= IFF_PROMISC + fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifr) # S for Set + # Bind the socket so it can be used + LOG.debug('Binding interface %(interface) for protocol ' + '%(proto)s: %s', {'interface': interface_name, + 'proto': self.protocol}) + sock.bind((interface_name, self.protocol)) + except Exception: + LOG.warning('Failed to open all RawPromiscuousSockets, ' + 'attempting to close any opened sockets.') + if self.__exit__(*sys.exc_info()): + return [] + else: + LOG.exception('Could not successfully close all opened ' + 'RawPromiscuousSockets.') + raise + # No need to return each interfaces ifreq. + return [(sock[0], sock[1]) for sock in self.interfaces] + + def __exit__(self, exception_type, exception_val, trace): + if exception_type: + LOG.exception('Error while using raw socket: %(type)s: %(val)', + {'type': exception_type, 'val': exception_val}) + + for _name, sock, ifr in self.interfaces: + # bitwise or with the opposite of promiscuous mode to remove + ifr.ifr_flags &= ~IFF_PROMISC + # If these raise, they shouldn't be caught + fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifr) + sock.close() + # Return True to signify exit correctly, only used internally + return True + + def _get_socket(self): + return socket.socket(socket.AF_PACKET, socket.SOCK_RAW, self.protocol) + + +def get_lldp_info(interface_names): + """Get LLDP info from the switch(es) the agent is connected to. + + Listens on either a single or all interfaces for LLDP packets, then + parses them. If no LLDP packets are received before lldp_timeout, + returns a dictionary in the form {'interface': [],...}. + + :param interface_names: The interface to listen for packets on. If + None, will listen on each interface. + :return: A dictionary in the form + {'interface': [(lldp_type, lldp_data)],...} + """ + with RawPromiscuousSockets(interface_names, LLDP_ETHERTYPE) as interfaces: + try: + return _get_lldp_info(interfaces) + except Exception as e: + LOG.exception('Error while getting LLDP info: %s', str(e)) + raise + + +def _parse_tlv(buff): + """Iterate over a buffer and generate structured TLV data. + + :param buff: An ethernet packet with the header trimmed off (first + 14 bytes) + """ + lldp_info = [] + while buff: + # TLV structure: type (7 bits), length (9 bits), val (0-511 bytes) + tlvhdr = struct.unpack('!H', buff[:2])[0] + tlvtype = (tlvhdr & 0xfe00) >> 9 + tlvlen = (tlvhdr & 0x01ff) + tlvdata = buff[2:tlvlen + 2] + buff = buff[tlvlen + 2:] + lldp_info.append((tlvtype, tlvdata)) + return lldp_info + + +def _receive_lldp_packets(sock): + """Receive LLDP packets and process them. + + :param sock: A bound socket + :return: A list of tuples in the form (lldp_type, lldp_data) + """ + pkt = sock.recv(1600) + # Filter invalid packets + if not pkt or len(pkt) < 14: + return + # Skip header (dst MAC, src MAC, ethertype) + pkt = pkt[14:] + return _parse_tlv(pkt) + + +def _get_lldp_info(interfaces): + """Wait for packets on each socket, parse the received LLDP packets.""" + LOG.debug('Getting LLDP info for interfaces %s', interfaces) + + lldp_info = {} + if not interfaces: + return {} + + socks = [interface[1] for interface in interfaces] + + while interfaces: + LOG.info('Waiting on LLDP info for interfaces: %(interfaces)s, ' + 'timeout: %(timeout)s', {'interfaces': interfaces, + 'timeout': CONF.lldp_timeout}) + + # rlist is a list of sockets ready for reading + rlist, _, _ = select.select(socks, [], [], CONF.lldp_timeout) + if not rlist: + # Empty read list means timeout on all interfaces + LOG.warning('LLDP timed out, remaining interfaces: %s', + interfaces) + break + + for s in rlist: + # Find interface name matching socket ready for read + # Create a copy of interfaces to avoid deleting while iterating. + for index, interface in enumerate(list(interfaces)): + if s == interface[1]: + LOG.info('Found LLDP info for interface: %s', + interface[0]) + lldp_info[interface[0]] = ( + _receive_lldp_packets(s)) + # Remove interface from the list, only need one packet + del interfaces[index] + + # Add any interfaces that didn't get a packet as empty lists + for name, _sock in interfaces: + lldp_info[name] = [] + + return lldp_info diff --git a/ironic_python_agent/tests/netutils.py b/ironic_python_agent/tests/netutils.py new file mode 100644 index 00000000..5a6c5d57 --- /dev/null +++ b/ironic_python_agent/tests/netutils.py @@ -0,0 +1,258 @@ +# Copyright 2014 Rackspace, 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 binascii + +import mock +from oslotest import base as test_base + +from ironic_python_agent import hardware +from ironic_python_agent import netutils + +# hexlify-ed output from LLDP packet +FAKE_LLDP_PACKET = binascii.unhexlify( + '0180c200000e885a92365a3988cc' + '0000' + '020704885a92ec5459' + '040d0545746865726e6574312f3138' + '06020078' +) + + +class TestNetutils(test_base.BaseTestCase): + def setUp(self): + super(TestNetutils, self).setUp() + self.hardware = hardware.GenericHardwareManager() + + @mock.patch('fcntl.ioctl') + @mock.patch('select.select') + @mock.patch('socket.socket') + def test_get_lldp_info(self, sock_mock, select_mock, fcntl_mock): + expected_lldp = { + 'eth1': [ + (0, ''), + (1, '\x04\x88Z\x92\xecTY'), + (2, '\x05Ethernet1/18'), + (3, '\x00x')], + 'eth0': [ + (0, ''), + (1, '\x04\x88Z\x92\xecTY'), + (2, '\x05Ethernet1/18'), + (3, '\x00x')] + } + + interface_names = ['eth0', 'eth1'] + + sock1 = mock.Mock() + sock1.recv.return_value = FAKE_LLDP_PACKET + sock1.fileno.return_value = 4 + sock2 = mock.Mock() + sock2.recv.return_value = FAKE_LLDP_PACKET + sock2.fileno.return_value = 5 + + sock_mock.side_effect = [sock1, sock2] + + select_mock.side_effect = [ + ([sock1], [], []), + ([sock2], [], []) + ] + + lldp_info = netutils.get_lldp_info(interface_names) + self.assertEqual(expected_lldp, lldp_info) + + sock1.bind.assert_called_with(('eth0', netutils.LLDP_ETHERTYPE)) + sock2.bind.assert_called_with(('eth1', netutils.LLDP_ETHERTYPE)) + + sock1.recv.assert_called_with(1600) + sock2.recv.assert_called_with(1600) + + self.assertEqual(1, sock1.close.call_count) + self.assertEqual(1, sock2.close.call_count) + + # 2 interfaces, 2 calls to enter promiscuous mode, 1 to leave + self.assertEqual(6, fcntl_mock.call_count) + + @mock.patch('fcntl.ioctl') + @mock.patch('select.select') + @mock.patch('socket.socket') + def test_get_lldp_info_multiple(self, sock_mock, select_mock, fcntl_mock): + expected_lldp = { + 'eth1': [ + (0, ''), + (1, '\x04\x88Z\x92\xecTY'), + (2, '\x05Ethernet1/18'), + (3, '\x00x')], + 'eth0': [ + (0, ''), + (1, '\x04\x88Z\x92\xecTY'), + (2, '\x05Ethernet1/18'), + (3, '\x00x')] + } + + interface_names = ['eth0', 'eth1'] + + sock1 = mock.Mock() + sock1.recv.return_value = FAKE_LLDP_PACKET + sock1.fileno.return_value = 4 + sock2 = mock.Mock() + sock2.recv.return_value = FAKE_LLDP_PACKET + sock2.fileno.return_value = 5 + + sock_mock.side_effect = [sock1, sock2] + + select_mock.side_effect = [ + ([sock1, sock2], [], []), + ] + + lldp_info = netutils.get_lldp_info(interface_names) + self.assertEqual(expected_lldp, lldp_info) + + sock1.bind.assert_called_with(('eth0', netutils.LLDP_ETHERTYPE)) + sock2.bind.assert_called_with(('eth1', netutils.LLDP_ETHERTYPE)) + + sock1.recv.assert_called_with(1600) + sock2.recv.assert_called_with(1600) + + self.assertEqual(1, sock1.close.call_count) + self.assertEqual(1, sock2.close.call_count) + + # 2 interfaces, 2 calls to enter promiscuous mode, 1 to leave + self.assertEqual(6, fcntl_mock.call_count) + + @mock.patch('fcntl.ioctl') + @mock.patch('select.select') + @mock.patch('socket.socket') + def test_get_lldp_info_one_empty_interface(self, sock_mock, select_mock, + fcntl_mock): + expected_lldp = { + 'eth1': [], + 'eth0': [ + (0, ''), + (1, '\x04\x88Z\x92\xecTY'), + (2, '\x05Ethernet1/18'), + (3, '\x00x')] + } + + interface_names = ['eth0', 'eth1'] + + sock1 = mock.Mock() + sock1.recv.return_value = FAKE_LLDP_PACKET + sock1.fileno.return_value = 4 + sock2 = mock.Mock() + sock2.fileno.return_value = 5 + + sock_mock.side_effect = [sock1, sock2] + + select_mock.side_effect = [ + ([sock1], [], []), + ([], [], []), + ] + + lldp_info = netutils.get_lldp_info(interface_names) + self.assertEqual(expected_lldp, lldp_info) + + sock1.bind.assert_called_with(('eth0', netutils.LLDP_ETHERTYPE)) + sock2.bind.assert_called_with(('eth1', netutils.LLDP_ETHERTYPE)) + + sock1.recv.assert_called_with(1600) + sock2.recv.not_called() + + self.assertEqual(1, sock1.close.call_count) + self.assertEqual(1, sock2.close.call_count) + + # 2 interfaces, 2 calls to enter promiscuous mode, 1 to leave + self.assertEqual(6, fcntl_mock.call_count) + + @mock.patch('fcntl.ioctl') + @mock.patch('select.select') + @mock.patch('socket.socket') + def test_get_lldp_info_empty(self, sock_mock, select_mock, fcntl_mock): + expected_lldp = { + 'eth1': [], + 'eth0': [] + } + + interface_names = ['eth0', 'eth1'] + + sock1 = mock.Mock() + sock1.fileno.return_value = 4 + sock2 = mock.Mock() + sock2.fileno.return_value = 5 + + sock_mock.side_effect = [sock1, sock2] + + select_mock.side_effect = [ + ([], [], []), + ([], [], []) + ] + + lldp_info = netutils.get_lldp_info(interface_names) + self.assertEqual(expected_lldp, lldp_info) + + sock1.bind.assert_called_with(('eth0', netutils.LLDP_ETHERTYPE)) + sock2.bind.assert_called_with(('eth1', netutils.LLDP_ETHERTYPE)) + + sock1.recv.not_called() + sock2.recv.not_called() + + self.assertEqual(1, sock1.close.call_count) + self.assertEqual(1, sock2.close.call_count) + + # 2 interfaces, 2 calls to enter promiscuous mode, 1 to leave + self.assertEqual(6, fcntl_mock.call_count) + + @mock.patch('fcntl.ioctl') + @mock.patch('socket.socket') + def test_raw_promiscuous_sockets(self, sock_mock, fcntl_mock): + interfaces = ['eth0', 'ens9f1'] + protocol = 3 + sock1 = mock.Mock() + sock2 = mock.Mock() + + sock_mock.side_effect = [sock1, sock2] + + with netutils.RawPromiscuousSockets(interfaces, protocol) as sockets: + # 2 interfaces, 1 get, 1 set call each + self.assertEqual(4, fcntl_mock.call_count) + self.assertEqual([('eth0', sock1), ('ens9f1', sock2)], sockets) + sock1.bind.assert_called_once_with(('eth0', protocol)) + sock2.bind.assert_called_once_with(('ens9f1', protocol)) + + self.assertEqual(6, fcntl_mock.call_count) + + sock1.close.assert_called_once_with() + sock2.close.assert_called_once_with() + + @mock.patch('fcntl.ioctl') + @mock.patch('socket.socket') + def test_raw_promiscuous_sockets_bind_fail(self, sock_mock, fcntl_mock): + interfaces = ['eth0', 'ens9f1'] + protocol = 3 + sock1 = mock.Mock() + sock2 = mock.Mock() + + sock_mock.side_effect = [sock1, sock2] + sock_mock.bind.side_effects = [None, Exception] + + with netutils.RawPromiscuousSockets(interfaces, protocol) as sockets: + # Ensure this isn't run + self.assertEqual([], sockets) + + sock1.bind.assert_called_once_with(('eth0', protocol)) + sock2.bind.assert_called_once_with(('ens9f1', protocol)) + + self.assertEqual(6, fcntl_mock.call_count) + + sock1.close.assert_called_once_with() + sock2.close.assert_called_once_with() |
