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