Source code for tBB.discoveries

#!/usr/bin/python3
#
# tBB is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# tBB is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


"""

Discovery methods implementations.

Library ``asyncio.subprocess`` is used to implement these methods.

"""


import asyncio
import logging
import time
from asyncio import coroutine
from asyncio.subprocess import PIPE, STDOUT
from net_elements import IPElement


logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)


@coroutine
[docs]def shell(command): """ Uses ``asyncio`` 's subprocess internally. :param command: command to execute :type command: str :return: stdout of command :rtype: list """ if type(command) != str: raise TypeError("command must be a string. Got: '{}'.".format(type(command))) process = yield from asyncio.create_subprocess_shell( command, stdin=PIPE, stdout=PIPE, stderr=STDOUT ) return (yield from process.communicate())[0].decode('utf-8').splitlines()
[docs]class PingedBroadcast(Exception): def __init__(self, ip): super().__init__("requested to ping broadcast '{}'.".format(ip))
[docs]class ParsingException(Exception): def __init__(self, ip, method, string): super().__init__("while running {} on {}. Got: {}.".format(method, ip, string))
[docs]class ICMPParsingException(ParsingException): def __init__(self, ip, string): super().__init__(ip, 'icmp', string)
[docs]class ARPParsingException(ParsingException): def __init__(self, ip, string): super().__init__(ip, 'arp', string)
[docs]class SYNParsingException(ParsingException): def __init__(self, ip, string): super().__init__(ip, 'syn', string)
[docs]class HostNameParsingException(ParsingException): def __init__(self, ip, string): super().__init__(ip, 'host_name', string)
[docs]class DiscoveryMethod: """Base-abstract class for all discovery methods.""" def __init__(self, short_name, enabled=True): self.enabled = enabled self.short_name = short_name @coroutine
[docs] def run(self, ip): """ Wrapper for ``DiscoveryMethod._run``. Does type checking. If ``ip`` is a string this function will create an IPElement and pass it to ``self._run``. :param ip: ip to run for. :return: whatever is returned by ``self._run`` """ if not isinstance(ip, IPElement) and type(ip) != str: raise TypeError("expected ip to be an IPElement instance or a string.") if type(ip) == str: ip = IPElement(ip) return (yield from self._run(ip))
@coroutine def _run(self, ip): """Runs the actual discovery. Abstract.""" raise NotImplementedError("run method of '{}' not implemented.".format(self.__class__.__name__)) @coroutine
[docs] def run_multiple(self, ips): """ Runs discovery for many ips. :param ips: ips to run for. :return: dict[ip, result] """ results = {} for ip in ips: results[ip] = yield from self.run(ip) return results
[docs]class ICMPDiscovery(DiscoveryMethod): """ICMP discovery method. Uses system's ``ping`` to perform requests.""" def __init__(self, count, timeout, flood=False, enabled=True): """ :param count: number of ping(s) to transmit. Has to be >= 1. :param timeout: pings timeout. 0 -> no timeout. :param flood: use ping option -f. Requires the script to be run as root. :type count: int :type timeout: int :type flood: bool """ super().__init__('icmp', enabled) if count < 1: raise ValueError("count must be >= 1.") self.count = count self.timeout = timeout self.flood = flood @coroutine def _run(self, ip): """ :param ip: see DiscoveryMethod.run :return: whether the host responded to the request :rtype: bool """ start = time.time() try: result = yield from shell("ping -c {} {} {} {} | grep 'received'".format( self.count, "-w {} -W {}".format( self.timeout, self.timeout, ) if self.timeout else "", "-f" if self.flood else "", ip.ip[0] )) except KeyboardInterrupt: return False took = time.time() - start # e.g.: ping = "1 packets transmitted, 1 received, 0% packet loss, time 0ms" # filtered_result = 1 -----------^ filtered_result = None for res in result: if res.find('broadcast') != -1: raise PingedBroadcast(ip) try: filtered_result = int(res.split(', ')[1].split(' ')[0]) except: raise ICMPParsingException(ip, res) logger.debug("Pinging IP '{}' resulted '{}'.".format(ip, filtered_result)) if not self.flood: if took >= self.count * 1.1 + 2: logger.warning("Ping to IP '{}' took {:.2f} seconds. Network congestion?".format(ip, took)) else: if took >= self.timeout + 0.5: logger.warning("Ping to IP '{}' took {:.2f} seconds. Network congestion?".format(ip, took)) return filtered_result != 0
DefaultICMPDiscovery = ICMPDiscovery(count=2, timeout=1) HeavyICMPDiscovery = ICMPDiscovery(count=4, timeout=0)
[docs]class ARPDiscovery(DiscoveryMethod): """ARP discovery method. Uses system's ``arping`` to perform requests.""" def __init__(self, count, interface, timeout, quit_on_first=True, enabled=True): """ :param count: number of arp requests to perform. Has to be >= 1. :param interface: a valid interface name for this device. :param timeout: pings timeout. 0 -> no timeout. :param quit_on_first: quit on first response received. :type count: int :type timeout: int :type quit_on_first: bool """ super().__init__('arp', enabled) if count < 1: raise ValueError("count must be >= 1.") self.count = count self.interface = interface self.timeout = timeout self.quit_on_first = quit_on_first @coroutine def _run(self, ip): """ :param ip: see DiscoveryMethod.run :return: whether the host responded to the request and the MAC address it responded with :rtype: bool, str """ start = time.time() try: result = yield from shell("arping -I {} -c {} {} {} {}".format( self.interface, self.count, "-f" if self.quit_on_first else "", "-w {}".format(self.timeout) if self.timeout else "", ip.ip[0] )) except KeyboardInterrupt: return False, None took = time.time() - start try: up = int(result[-1].split(' ')[1]) except ValueError: raise PingedBroadcast(ip) host = None for line in result: line = line if line.find('reply') > -1: try: host = line[line.find('[')+1:line.find(']')] except: raise ARPParsingException(ip, line) # e.g.: ARPING 192.168.2.27 from 192.168.2.90 eth0 # Unicast reply from 192.168.2.27 [D4:BE:D9:49:D3:0C] 0.975ms # host = ----------------------------^^^^^^^^^^^^^^^^^ # Sent 1 probes (1 broadcast(s)) # Received 1 response(s) # up = ------^ logger.debug("ARPinging IP '{}' resulted '{}'.".format(ip, up != 0)) if took >= self.count * 1.1 + 2: logger.warning("ARPing to IP '{}' took {:.2f} seconds. Network congestion?".format(ip, took)) return up != 0, host
DefaultARPDiscovery = ARPDiscovery(count=2, interface='eth0', timeout=1, quit_on_first=True) HeavyARPDiscovery = ARPDiscovery(count=4, interface='eth0', timeout=0, quit_on_first=True)
[docs]class SYNDiscovery(DiscoveryMethod): """SYN discovery method. Uses system's ``nc`` to perform requests.""" def __init__(self, ports, timeout, enabled=True): """ :param ports: ports to perform requests to. :param timeout: pings timeout. 0 -> no timeout. :type ports: str :type timeout: int """ super().__init__('syn', enabled) self.ports = ports self.timeout = timeout @coroutine def _run(self, ip): """ :param ip: see DiscoveryMethod.run :return: whether the host responded to the request :rtype: bool """ start = time.time() try: result = yield from shell("nc -zv -w {} {} {}".format( self.timeout, ip.ip[0], self.ports )) except KeyboardInterrupt: return False result = result[0] took = time.time() - start if result.find("No route to host.") > -1: return False try: filtered_result = result[result.find(') ')+2:result.find(': ', 10)] except: raise SYNParsingException(ip, result) # TODO: network congestion check if result.find("No route to host") > -1: filtered_result = 'timed out' logger.debug("Syn to IP '{}' resulted '{}'.".format(ip, filtered_result)) if filtered_result in ('succeeded', 'failed'): # not timed out return True return False
# e.g.: nc: connect to 192.168.2.75 port 22 (tcp) failed: Connection refused # filtered_result = --------------------------^^^^^^ DefaultSYNDiscovery = SYNDiscovery(ports='22', timeout=1) HeavySYNDiscovery = SYNDiscovery(ports='22', timeout=4)
[docs]class HostNameDiscovery(DiscoveryMethod): def __init__(self): super().__init__("host_name", enabled=True) def _run(self, ip): start = time.time() try: result = yield from shell("host {}".format( ip.ip[0], )) except KeyboardInterrupt: return False filtered_result = [] for res in result: if res.find('not found') > -1 or res.find('timed out') > -1: logger.debug("Host name of IP '{}' resulted 'not found'.".format(ip)) return False, tuple() try: res = res.split(' ')[-1][:-1] if res == '': res = 'empty-name' filtered_result.append(res) except: raise HostNameParsingException(ip, result) # e.g.: 90.2.168.192.in-addr.arpa domain name pointer portatile.hogwarts.local. # filtered_result = -------------------------------^^^^^^^^^^^^^^^^^^^^^^^^ took = time.time() - start # TODO: network congestion check logger.debug("Host name of IP '{}' resulted '{}'.".format(ip, filtered_result)) return True, tuple(sorted(filtered_result))