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