summaryrefslogtreecommitdiff
path: root/ironic_python_agent
diff options
context:
space:
mode:
authorJim Rollenhagen <jim@jimrollenhagen.com>2014-05-07 09:28:19 -0700
committerJosh Gachnang <josh@pcsforeducation.com>2014-12-09 11:59:00 -0800
commit15aaa03833208bb8ed8a5e164c164b9e073a4769 (patch)
tree41c77add2183f83f918c429d44f2f8a822a833ee /ironic_python_agent
parentcfdedd30d369d4710ff55add09cc6fd8676ff9f7 (diff)
downloadironic-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.py6
-rw-r--r--ironic_python_agent/netutils.py197
-rw-r--r--ironic_python_agent/tests/netutils.py258
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()