interface.py revision af2fe4d810e1cc2db02a3412af6989793b8527ac
1# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import collections
6import logging
7import os
8import re
9
10from autotest_lib.client.bin import utils
11from autotest_lib.client.common_lib import error
12from autotest_lib.client.common_lib.cros.network import netblock
13
14# A tuple consisting of a readable part number (one of NAME_* below)
15# and a kernel module that provides the driver for this part (e.g. ath9k).
16DeviceDescription = collections.namedtuple('DeviceDescription',
17                                           ['name', 'kernel_module'])
18
19
20# A tuple describing a default route, consisting of an interface name,
21# gateway IP address, and the metric value visible in the routing table.
22DefaultRoute = collections.namedtuple('DefaultRoute', ['interface_name',
23                                                       'gateway',
24                                                       'metric'])
25
26NAME_MARVELL_88W8797_SDIO = 'Marvell 88W8797 SDIO'
27NAME_MARVELL_88W8887_SDIO = 'Marvell 88W8887 SDIO'
28NAME_MARVELL_88W8897_SDIO = 'Marvell 88W8897 SDIO'
29NAME_MARVELL_88W8897_PCIE = 'Marvell 88W8897 PCIE'
30NAME_ATHEROS_AR9280 = 'Atheros AR9280'
31NAME_ATHEROS_AR9382 = 'Atheros AR9382'
32NAME_ATHEROS_AR9462 = 'Atheros AR9462'
33NAME_INTEL_7260 = 'Intel 7260'
34NAME_INTEL_7265 = 'Intel 7265'
35NAME_BROADCOM_BCM4354_SDIO = 'Broadcom BCM4354 SDIO'
36NAME_BROADCOM_BCM4356_PCIE = 'Broadcom BCM4356 PCIE'
37NAME_UNKNOWN = 'Unknown WiFi Device'
38
39DEVICE_INFO_ROOT = '/sys/class/net'
40DeviceInfo = collections.namedtuple('DeviceInfo', ['vendor', 'device'])
41DEVICE_NAME_LOOKUP = {
42    DeviceInfo('0x02df', '0x9129'): NAME_MARVELL_88W8797_SDIO,
43    DeviceInfo('0x02df', '0x912d'): NAME_MARVELL_88W8897_SDIO,
44    DeviceInfo('0x02df', '0x9135'): NAME_MARVELL_88W8887_SDIO,
45    DeviceInfo('0x11ab', '0x2b38'): NAME_MARVELL_88W8897_PCIE,
46    DeviceInfo('0x168c', '0x002a'): NAME_ATHEROS_AR9280,
47    DeviceInfo('0x168c', '0x0030'): NAME_ATHEROS_AR9382,
48    DeviceInfo('0x168c', '0x0034'): NAME_ATHEROS_AR9462,
49    DeviceInfo('0x8086', '0x08b1'): NAME_INTEL_7260,
50    # TODO(wiley): Why is this number slightly different on some platforms?
51    #              Is it just a different part source?
52    DeviceInfo('0x8086', '0x08b2'): NAME_INTEL_7260,
53    DeviceInfo('0x8086', '0x095a'): NAME_INTEL_7265,
54    DeviceInfo('0x02d0', '0x4354'): NAME_BROADCOM_BCM4354_SDIO,
55    DeviceInfo('0x14e4', '0x43ec'): NAME_BROADCOM_BCM4356_PCIE,
56}
57
58class Interface:
59    """Interace is a class that contains the queriable address properties
60    of an network device.
61    """
62    ADDRESS_TYPE_MAC = 'link/ether'
63    ADDRESS_TYPE_IPV4 = 'inet'
64    ADDRESS_TYPE_IPV6 = 'inet6'
65    ADDRESS_TYPES = [ ADDRESS_TYPE_MAC, ADDRESS_TYPE_IPV4, ADDRESS_TYPE_IPV6 ]
66
67    INTERFACE_NAME_ETHERNET = 'eth0'  # Assume this is `the` ethernet interface.
68
69
70    @staticmethod
71    def get_connected_ethernet_interface(ignore_failures=False):
72        """Get an interface object representing a connected ethernet device.
73
74        Raises an exception if no such interface exists.
75
76        @param ignore_failures bool function will return None instead of raising
77                an exception on failures.
78        @return an Interface object except under the conditions described above.
79
80        """
81        # Assume that ethernet devices are called ethX until proven otherwise.
82        for device_name in ['eth%d' % i for i in range(5)]:
83            ethernet_if = Interface(device_name)
84            if ethernet_if.exists and ethernet_if.ipv4_address:
85                return ethernet_if
86
87        else:
88            if ignore_failures:
89                return None
90
91            raise error.TestFail('Failed to find ethernet interface.')
92
93
94    def __init__(self, name, host=None):
95        self._name = name
96        self._run = utils.run
97        if host is not None:
98            self._run = host.run
99
100
101    @property
102    def name(self):
103        """@return name of the interface (e.g. 'wlan0')."""
104        return self._name
105
106
107    @property
108    def addresses(self):
109        """@return the addresses (MAC, IP) associated with interface."""
110        # "ip addr show %s 2> /dev/null" returns something that looks like:
111        #
112        # 2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast
113        #    link/ether ac:16:2d:07:51:0f brd ff:ff:ff:ff:ff:ff
114        #    inet 172.22.73.124/22 brd 172.22.75.255 scope global eth0
115        #    inet6 2620:0:1000:1b02:ae16:2dff:fe07:510f/64 scope global dynamic
116        #       valid_lft 2591982sec preferred_lft 604782sec
117        #    inet6 fe80::ae16:2dff:fe07:510f/64 scope link
118        #       valid_lft forever preferred_lft forever
119        #
120        # We extract the second column from any entry for which the first
121        # column is an address type we are interested in.  For example,
122        # for "inet 172.22.73.124/22 ...", we will capture "172.22.73.124/22".
123        result = self._run('ip addr show %s 2> /dev/null' % self._name,
124                           ignore_status=True)
125        address_info = result.stdout
126        if result.exit_status != 0:
127            # The "ip" command will return non-zero if the interface does
128            # not exist.
129            return {}
130
131        addresses = {}
132        for address_line in address_info.splitlines():
133            address_parts = address_line.lstrip().split()
134            if len(address_parts) < 2:
135                continue
136            address_type, address_value = address_parts[:2]
137            if address_type in self.ADDRESS_TYPES:
138                if address_type not in addresses:
139                    addresses[address_type] = []
140                addresses[address_type].append(address_value)
141        return addresses
142
143
144    @property
145    def device_description(self):
146        """@return DeviceDescription object for a WiFi interface, or None."""
147        exists = lambda path: self._run(
148                'test -e "%s"' % path,
149                ignore_status=True).exit_status == 0
150        read_file = (lambda path: self._run('cat "%s"' % path).stdout.rstrip()
151                     if exists(path) else None)
152        readlink = lambda path: self._run('readlink "%s"' % path).stdout.strip()
153        if not self.is_wifi_device:
154            logging.error('Device description not supported on non-wifi '
155                          'interface: %s.', self._name)
156            return None
157
158        # This assumes that our path separator is the same as the remote host.
159        device_path = os.path.join(DEVICE_INFO_ROOT, self._name, 'device')
160        if not exists(device_path):
161            logging.error('No device information found at %s', device_path)
162            return None
163
164        # TODO(benchan): The 'vendor' / 'device' files do not always exist
165        # under the device path. We probably need to figure out an alternative
166        # way to determine the vendor and device ID.
167        vendor_id = read_file(os.path.join(device_path, 'vendor'))
168        product_id = read_file(os.path.join(device_path, 'device'))
169        driver_info = DeviceInfo(vendor_id, product_id)
170        if driver_info in DEVICE_NAME_LOOKUP:
171            device_name = DEVICE_NAME_LOOKUP[driver_info]
172            logging.debug('Device is %s',  device_name)
173        else:
174            logging.error('Device vendor/product pair %r for device %s is '
175                          'unknown!', driver_info, product_id)
176            device_name = NAME_UNKNOWN
177        module_name = os.path.basename(
178                readlink(os.path.join(device_path, 'driver', 'module')))
179        kernel_release = self._run('uname -r').stdout.strip()
180        module_path = self._run('find '
181                                '/lib/modules/%s/kernel/drivers/net '
182                                '-name %s.ko -printf %%P' %
183                                (kernel_release, module_name)).stdout
184        return DeviceDescription(device_name, module_path)
185
186
187    @property
188    def exists(self):
189        """@return True if this interface exists, False otherwise."""
190        # No valid interface has no addresses at all.
191        return bool(self.addresses)
192
193
194    @property
195    def mac_address(self):
196        """@return the (first) MAC address, e.g., "00:11:22:33:44:55"."""
197        return self.addresses.get(self.ADDRESS_TYPE_MAC, [None])[0]
198
199
200    @property
201    def ipv4_address_and_prefix(self):
202        """@return the IPv4 address/prefix, e.g., "192.186.0.1/24"."""
203        return self.addresses.get(self.ADDRESS_TYPE_IPV4, [None])[0]
204
205
206    @property
207    def ipv4_address(self):
208        """@return the (first) IPv4 address, e.g., "192.168.0.1"."""
209        netblock_addr = self.netblock
210        return netblock_addr.addr if netblock_addr else None
211
212
213    @property
214    def ipv4_prefix(self):
215        """@return the IPv4 address prefix e.g., 24."""
216        addr = self.netblock
217        return addr.prefix_len if addr else None
218
219
220    @property
221    def ipv4_subnet(self):
222        """@return string subnet of IPv4 address (e.g. '192.168.0.0')"""
223        addr = self.netblock
224        return addr.subnet if addr else None
225
226
227    @property
228    def ipv4_subnet_mask(self):
229        """@return the IPv4 subnet mask e.g., "255.255.255.0"."""
230        addr = self.netblock
231        return addr.netmask if addr else None
232
233
234    def is_wifi_device(self):
235        """@return True if iw thinks this is a wifi device."""
236        if self._run('iw dev %s info' % self._name,
237                     ignore_status=True).exit_status:
238            logging.debug('%s does not seem to be a wireless device.',
239                          self._name)
240            return False
241        return True
242
243
244    @property
245    def netblock(self):
246        """Return Netblock object for this interface's IPv4 address.
247
248        @return Netblock object (or None if no IPv4 address found).
249
250        """
251        netblock_str = self.ipv4_address_and_prefix
252        return netblock.from_addr(netblock_str) if netblock_str else None
253
254
255    @property
256    def signal_level(self):
257        """Get the signal level for an interface.
258
259        This is currently only defined for WiFi interfaces.
260
261        localhost test # iw dev mlan0 link
262        Connected to 04:f0:21:03:7d:b2 (on mlan0)
263                SSID: Perf_slvf0_ch36
264                freq: 5180
265                RX: 699407596 bytes (8165441 packets)
266                TX: 58632580 bytes (9923989 packets)
267                signal: -54 dBm
268                tx bitrate: 130.0 MBit/s MCS 15
269
270                bss flags:
271                dtim period:    2
272                beacon int:     100
273
274        @return signal level in dBm (a negative, integral number).
275
276        """
277        if not self.is_wifi_device():
278            return None
279
280        result_lines = self._run('iw dev %s link' %
281                                 self._name).stdout.splitlines()
282        signal_pattern = re.compile('signal:\s+([-0-9]+)\s+dbm')
283        for line in result_lines:
284            cleaned = line.strip().lower()
285            match = re.search(signal_pattern, cleaned)
286            if match is not None:
287                return int(match.group(1))
288
289        logging.error('Failed to find signal level for %s.', self._name)
290        return None
291
292
293    @property
294    def mtu(self):
295        """@return the interface configured maximum transmission unit (MTU)."""
296        # "ip addr show %s 2> /dev/null" returns something that looks like:
297        #
298        # 2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast
299        #    link/ether ac:16:2d:07:51:0f brd ff:ff:ff:ff:ff:ff
300        #    inet 172.22.73.124/22 brd 172.22.75.255 scope global eth0
301        #    inet6 2620:0:1000:1b02:ae16:2dff:fe07:510f/64 scope global dynamic
302        #       valid_lft 2591982sec preferred_lft 604782sec
303        #    inet6 fe80::ae16:2dff:fe07:510f/64 scope link
304        #       valid_lft forever preferred_lft forever
305        #
306        # We extract the 'mtu' value (in this example "1500")
307        try:
308            result = self._run('ip addr show %s 2> /dev/null' % self._name)
309            address_info = result.stdout
310        except error.CmdError, e:
311            # The "ip" command will return non-zero if the interface does
312            # not exist.
313            return None
314
315        match = re.search('mtu\s+(\d+)', address_info)
316        if not match:
317            raise error.TestFail('MTU information is not available.')
318        return int(match.group(1))
319
320
321    def noise_level(self, frequency_mhz):
322        """Get the noise level for an interface at a given frequency.
323
324        This is currently only defined for WiFi interfaces.
325
326        This only works on some devices because 'iw survey dump' (the method
327        used to get the noise) only works on some devices.  On other devices,
328        this method returns None.
329
330        @param frequency_mhz: frequency at which the noise level should be
331               measured and reported.
332        @return noise level in dBm (a negative, integral number) or None.
333
334        """
335        if not self.is_wifi_device():
336            return None
337
338        # This code has to find the frequency and then find the noise
339        # associated with that frequency because 'iw survey dump' output looks
340        # like this:
341        #
342        # localhost test # iw dev mlan0 survey dump
343        # ...
344        # Survey data from mlan0
345        #     frequency:              5805 MHz
346        #     noise:                  -91 dBm
347        #     channel active time:    124 ms
348        #     channel busy time:      1 ms
349        #     channel receive time:   1 ms
350        #     channel transmit time:  0 ms
351        # Survey data from mlan0
352        #     frequency:              5825 MHz
353        # ...
354
355        result_lines = self._run('iw dev %s survey dump' %
356                                 self._name).stdout.splitlines()
357        my_frequency_pattern = re.compile('frequency:\s*%d mhz' %
358                                          frequency_mhz)
359        any_frequency_pattern = re.compile('frequency:\s*\d{4} mhz')
360        inside_desired_frequency_block = False
361        noise_pattern = re.compile('noise:\s*([-0-9]+)\s+dbm')
362        for line in result_lines:
363            cleaned = line.strip().lower()
364            if my_frequency_pattern.match(cleaned):
365                inside_desired_frequency_block = True
366            elif inside_desired_frequency_block:
367                match = noise_pattern.match(cleaned)
368                if match is not None:
369                    return int(match.group(1))
370                if any_frequency_pattern.match(cleaned):
371                    inside_desired_frequency_block = False
372
373        logging.error('Failed to find noise level for %s at %d MHz.',
374                      self._name, frequency_mhz)
375        return None
376
377
378def get_prioritized_default_route(host=None, interface_name_regex=None):
379    """
380    Query a local or remote host for its prioritized default interface
381    and route.
382
383    @param interface_name_regex string regex to filter routes by interface.
384    @return DefaultRoute tuple, or None if no default routes are found.
385
386    """
387    # Build a list of default routes, filtered by interface if requested.
388    # Example command output: 'default via 172.23.188.254 dev eth0  metric 2'
389    run = host.run if host is not None else utils.run
390    output = run('ip route show').stdout
391    output_regex_str = 'default\s+via\s+(\S+)\s+dev\s+(\S+)\s+metric\s+(\d+)'
392    output_regex = re.compile(output_regex_str)
393    defaults = []
394    for item in output.splitlines():
395        if 'default' not in item:
396            continue
397        match = output_regex.match(item.strip())
398        if match is None:
399            raise error.TestFail('Unexpected route output: %s' % item)
400        gateway = match.group(1)
401        interface_name = match.group(2)
402        metric = int(match.group(3))
403        if interface_name_regex is not None:
404            if re.match(interface_name_regex, interface_name) is None:
405                continue
406        defaults.append(DefaultRoute(interface_name=interface_name,
407                                     gateway=gateway, metric=metric))
408    if not defaults:
409        return None
410
411    # Sort and return the route with the lowest metric value.
412    defaults.sort(key=lambda x: x.metric)
413    return defaults[0]
414
415